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
@@ -1,42 +1,147 @@
1
1
  package lib
2
2
 
3
3
  import (
4
+ "bytes"
5
+ "context"
6
+ "errors"
7
+ "fmt"
8
+ "os"
4
9
  "os/exec"
5
10
  "path/filepath"
6
11
  "strings"
12
+ "time"
7
13
  )
8
14
 
9
- // gitToplevel returns the absolute path of the git working tree containing dir,
10
- // or "" when dir is not inside a repository. The git-backed gates
11
- // (test-integrity and reconcile) skip rather than block when it is empty.
12
- func gitToplevel(dir string) string {
13
- out, err := exec.Command("git", "-C", dir, "rev-parse", "--show-toplevel").Output()
15
+ var errNotGitRepository = errors.New("not a git repository")
16
+
17
+ const (
18
+ gitCommandTimeout = 30 * time.Second
19
+ gitOutputLimit = 16 << 20
20
+ )
21
+
22
+ type cappedGitOutput struct {
23
+ bytes.Buffer
24
+ truncated bool
25
+ }
26
+
27
+ func (w *cappedGitOutput) Write(p []byte) (int, error) {
28
+ remaining := gitOutputLimit - w.Len()
29
+ if remaining > 0 {
30
+ if len(p) > remaining {
31
+ _, _ = w.Buffer.Write(p[:remaining])
32
+ w.truncated = true
33
+ } else {
34
+ _, _ = w.Buffer.Write(p)
35
+ }
36
+ } else {
37
+ w.truncated = true
38
+ }
39
+ return len(p), nil
40
+ }
41
+
42
+ type gitCommandError struct {
43
+ args []string
44
+ output string
45
+ exitCode int
46
+ err error
47
+ }
48
+
49
+ func (e *gitCommandError) Error() string {
50
+ detail := strings.TrimSpace(e.output)
51
+ if detail == "" {
52
+ return fmt.Sprintf("git %s: %v", strings.Join(e.args, " "), e.err)
53
+ }
54
+ return fmt.Sprintf("git %s: %v: %s", strings.Join(e.args, " "), e.err, detail)
55
+ }
56
+
57
+ func (e *gitCommandError) Unwrap() error { return e.err }
58
+
59
+ func runGitCommand(dir string, env []string, args ...string) ([]byte, error) {
60
+ ctx, cancel := context.WithTimeout(context.Background(), gitCommandTimeout)
61
+ defer cancel()
62
+ commandArgs := append([]string{"-C", dir}, args...)
63
+ cmd := exec.CommandContext(ctx, "git", commandArgs...)
64
+ cmd.WaitDelay = 2 * time.Second
65
+ if env == nil {
66
+ env = os.Environ()
67
+ } else {
68
+ env = append([]string{}, env...)
69
+ }
70
+ cmd.Env = append(
71
+ env,
72
+ "GIT_TERMINAL_PROMPT=0",
73
+ "GCM_INTERACTIVE=never",
74
+ "GIT_PAGER=cat",
75
+ "PAGER=cat",
76
+ "LC_ALL=C",
77
+ )
78
+ var output cappedGitOutput
79
+ cmd.Stdout = &output
80
+ cmd.Stderr = &output
81
+ err := cmd.Run()
82
+ if err == nil {
83
+ return output.Bytes(), nil
84
+ }
85
+ code := -1
86
+ var exitErr *exec.ExitError
87
+ if errors.As(err, &exitErr) {
88
+ code = exitErr.ExitCode()
89
+ }
90
+ if ctx.Err() != nil {
91
+ err = ctx.Err()
92
+ }
93
+ detail := output.String()
94
+ if output.truncated {
95
+ detail += "\n[git output truncated]"
96
+ }
97
+ return nil, &gitCommandError{
98
+ args: args,
99
+ output: detail,
100
+ exitCode: code,
101
+ err: err,
102
+ }
103
+ }
104
+
105
+ func gitErrorExitCode(err error) (int, bool) {
106
+ var commandErr *gitCommandError
107
+ if !errors.As(err, &commandErr) {
108
+ return 0, false
109
+ }
110
+ return commandErr.exitCode, commandErr.exitCode >= 0
111
+ }
112
+
113
+ // gitToplevel returns the absolute path of the Git working tree containing dir.
114
+ // Repository absence is distinct from Git execution failure so integrity gates
115
+ // can fail closed on a missing binary, corrupt repository, or poisoned runtime.
116
+ func gitToplevel(dir string) (string, error) {
117
+ out, err := runGitCommand(dir, nil, "rev-parse", "--show-toplevel")
14
118
  if err != nil {
15
- return ""
119
+ var commandErr *gitCommandError
120
+ if errors.As(err, &commandErr) {
121
+ detail := strings.ToLower(commandErr.output)
122
+ if strings.Contains(detail, "not a git repository") ||
123
+ strings.Contains(detail, "not a git work tree") {
124
+ return "", errNotGitRepository
125
+ }
126
+ }
127
+ return "", err
128
+ }
129
+ root := strings.TrimSpace(string(out))
130
+ if root == "" {
131
+ return "", fmt.Errorf("git rev-parse returned an empty top-level path")
16
132
  }
17
- return filepath.Clean(strings.TrimSpace(string(out)))
133
+ return filepath.Clean(root), nil
18
134
  }
19
135
 
20
136
  // gitDiffNames lists the paths that differ, relative to gitRoot. With one ref it
21
137
  // diffs the working tree against that ref; with two it diffs the two tree objects.
22
- func gitDiffNames(gitRoot string, refs ...string) []string {
23
- args := append([]string{"-C", gitRoot, "diff", "--name-only"}, refs...)
24
- out, err := exec.Command("git", args...).Output()
138
+ func gitDiffNames(gitRoot string, refs ...string) ([]string, error) {
139
+ args := append([]string{"diff", "--name-only"}, refs...)
140
+ out, err := runGitCommand(gitRoot, nil, args...)
25
141
  if err != nil {
26
- return nil
142
+ return nil, err
27
143
  }
28
- return splitLinesNoTrailing(out)
29
- }
30
-
31
- // stripASCIISpace removes every whitespace character from s (used to normalise an
32
- // ACTIVE pointer before comparing it to a slug).
33
- func stripASCIISpace(s string) string {
34
- return strings.Map(func(r rune) rune {
35
- if strings.ContainsRune(spaceChars, r) {
36
- return -1
37
- }
38
- return r
39
- }, s)
144
+ return splitLinesNoTrailing(out), nil
40
145
  }
41
146
 
42
147
  // isAllDigits reports whether s is a non-empty run of ASCII digits.
package/engine/main.go CHANGED
@@ -56,7 +56,8 @@ Usage:
56
56
  devrites-engine mutation-gate [slug] Advisory: detect the mutation runner and scope it
57
57
  devrites-engine test-integrity [slug] Gate: no test deleted, skipped, or de-asserted
58
58
  devrites-engine review-integrity [slug] Gate: no adversarial review axis is silent (zero findings, no justification)
59
- devrites-engine reconcile <sub> [slug] A1 gate: snapshot|check|close the wright's source writes
59
+ devrites-engine dispatch-waive <reason> Record a deterministic pre-dispatch STOP reason
60
+ devrites-engine reconcile <sub> [slug] A1 gate: snapshot|check|restore-check|close the wright's source writes
60
61
  devrites-engine resolve <qid> "<ans>" Resolve an open question; keep state.md consistent
61
62
  devrites-engine close-out <slug> Archive a shipped feature and clear ACTIVE
62
63
  devrites-engine archive-search "<nouns>" Find prior shipped specs that overlap the query
@@ -238,6 +239,8 @@ func run(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
238
239
  return lib.TestIntegrity(root, args[1:], stdout, stderr)
239
240
  case "review-integrity":
240
241
  return lib.ReviewIntegrity(root, args[1:], stdout, stderr)
242
+ case "dispatch-waive":
243
+ return lib.DispatchWaive(args[1:], stdout, stderr)
241
244
  case "reconcile":
242
245
  return lib.Reconcile(root, args[1:], stdout, stderr)
243
246
  case "resolve":
@@ -1,2 +1,3 @@
1
1
  exit 0
2
+ reconcile: OK: every slice change matches the orchestrator allowlist; baseline retained for post-slice gates (run "reconcile close" afterward).
2
3
  reconcile: closed slice window for feat.
@@ -1 +1 @@
1
- exit 0
1
+ exit 6
@@ -1 +1 @@
1
- exit 0
1
+ exit 2
@@ -236,6 +236,18 @@ func parsePermissionDecision(t *testing.T, stdout string) (decision, reason stri
236
236
  return env.HookSpecificOutput.PermissionDecision, env.HookSpecificOutput.PermissionDecisionReason
237
237
  }
238
238
 
239
+ func parseStopDecision(t *testing.T, stdout string) (decision, reason string) {
240
+ t.Helper()
241
+ var env struct {
242
+ Decision string `json:"decision"`
243
+ Reason string `json:"reason"`
244
+ }
245
+ if err := json.Unmarshal([]byte(strings.TrimSpace(stdout)), &env); err != nil {
246
+ t.Fatalf("stdout is not a valid Stop decision: %v\n%s", err, stdout)
247
+ }
248
+ return env.Decision, env.Reason
249
+ }
250
+
239
251
  func TestHookReviewerReadonlyEnforceDeniesMutatingBash(t *testing.T) {
240
252
  for _, h := range []string{"claude", "codex"} {
241
253
  h := h
@@ -260,14 +272,22 @@ func TestHookReviewerReadonlyEnforceDeniesMutatingBash(t *testing.T) {
260
272
 
261
273
  func TestHookReviewerReadonlyAllowsSafeBash(t *testing.T) {
262
274
  root := newWorkspace(t)
263
- in := `{"tool_name":"Bash","tool_input":{"command":"grep -rn foo ."}}`
264
- out, _, code := runDevritesIO(t, root, in, []string{"DEVRITES_REVIEWER_RO=enforce"},
265
- "hook", "reviewer-readonly", "--harness=claude")
266
- if code != 0 {
267
- t.Fatalf("exit = %d, want 0", code)
268
- }
269
- if strings.TrimSpace(out) != "" {
270
- t.Errorf("stdout = %q, want silent for a read-only command", out)
275
+ for _, command := range []string{
276
+ "grep -rn foo .",
277
+ `cd "` + filepath.Dir(root) + `" && git diff --binary --full-index --no-renames HEAD HEAD --`,
278
+ } {
279
+ in := hookPayload(t, map[string]any{
280
+ "tool_name": "Bash",
281
+ "tool_input": map[string]any{"command": command},
282
+ })
283
+ out, _, code := runDevritesIO(t, root, in, []string{"DEVRITES_REVIEWER_RO=enforce"},
284
+ "hook", "reviewer-readonly", "--harness=claude")
285
+ if code != 0 {
286
+ t.Fatalf("%q exit = %d, want 0", command, code)
287
+ }
288
+ if strings.TrimSpace(out) != "" {
289
+ t.Errorf("%q stdout = %q, want silent for a read-only command", command, out)
290
+ }
271
291
  }
272
292
  }
273
293
 
@@ -440,20 +460,87 @@ func TestHookReviewerReadonlyActiveLeafDeniesEveryMutationSurface(t *testing.T)
440
460
 
441
461
  func TestHookReviewerReadonlyActiveLeafAllowsBoundedProof(t *testing.T) {
442
462
  root := newWorkspace(t)
443
- in := `{"tool_name":"Bash","tool_input":{"command":"go test ./... -count=1"}}`
444
- out, _, code := runDevritesIO(t, root, in, []string{
463
+ env := []string{
445
464
  "DEVRITES_AGENT_RUN=1",
446
465
  "DEVRITES_ACTIVE_AGENT=devrites-proof-runner",
447
- }, "hook", "reviewer-readonly", "--harness=claude")
466
+ }
467
+ for _, command := range []string{
468
+ "go test ./... -count=1",
469
+ "command -v devrites-engine",
470
+ "devrites-engine conventions orient",
471
+ "devrites-engine learnings mine",
472
+ } {
473
+ in := fmt.Sprintf(`{"tool_name":"Bash","tool_input":{"command":%q}}`, command)
474
+ out, _, code := runDevritesIO(t, root, in, env,
475
+ "hook", "reviewer-readonly", "--harness=claude")
476
+ if code != 0 || strings.TrimSpace(out) != "" {
477
+ t.Fatalf("%q should be allowed: exit=%d out=%q", command, code, out)
478
+ }
479
+ }
480
+ in := `{"tool_name":"Bash","tool_input":{"command":"go test ./... -count=1"}}`
481
+ out, _, code := runDevritesIO(t, root, in, env,
482
+ "hook", "wright-scope", "--harness=claude")
448
483
  if code != 0 || strings.TrimSpace(out) != "" {
449
- t.Fatalf("bounded proof should be allowed: exit=%d out=%q", code, out)
484
+ t.Fatalf("wright hook must not shadow reviewer policy: exit=%d out=%q", code, out)
450
485
  }
451
- out, _, code = runDevritesIO(t, root, in, []string{
486
+ }
487
+
488
+ func TestHookReviewerReadonlyProofRunnerKeepsArtifactProofRootOwned(t *testing.T) {
489
+ root := newWorkspace(t)
490
+ env := []string{
452
491
  "DEVRITES_AGENT_RUN=1",
453
492
  "DEVRITES_ACTIVE_AGENT=devrites-proof-runner",
454
- }, "hook", "wright-scope", "--harness=claude")
493
+ }
494
+ for _, command := range []string{
495
+ "npm run build",
496
+ "npm run test:e2e -- tests/admin-locations.e2e.spec.ts",
497
+ "go build ./...",
498
+ "bash scripts/mutate-repository.sh",
499
+ "npm run deploy",
500
+ "make deploy",
501
+ "docker compose up -d",
502
+ "printf x > src/app.go",
503
+ "npm install left-pad",
504
+ "python -c 'open(\"src/app.go\", \"w\").write(\"x\")'",
505
+ } {
506
+ in := fmt.Sprintf(`{"tool_name":"Bash","tool_input":{"command":%q}}`, command)
507
+ out, errOut, code := runDevritesIO(t, root, in, env,
508
+ "hook", "reviewer-readonly", "--harness=claude")
509
+ if code != 0 {
510
+ t.Fatalf("root-owned proof command guard exit=%d stderr=%q", code, errOut)
511
+ }
512
+ if decision, _ := parsePermissionDecision(t, out); decision != "deny" {
513
+ t.Fatalf("proof leaf command was not denied: command=%q out=%q", command, out)
514
+ }
515
+ }
516
+ }
517
+
518
+ func TestHookRedwatchRecognizesCodexShellAliases(t *testing.T) {
519
+ root := newWorkspace(t)
520
+ writeActive(t, root, "auth-tokens")
521
+ red := filepath.Join(root, "features", "auth-tokens", ".red")
522
+
523
+ fail := `{"tool_name":"functions.exec_command","tool_input":{"cmd":"npm test"},"tool_response":"Tests: 1 failed, 3 passed"}`
524
+ out, errOut, code := runDevritesIO(t, root, fail, nil,
525
+ "hook", "redwatch", "--harness=codex")
526
+ if code != 0 {
527
+ t.Fatalf("failing exec_command exit=%d stderr=%q", code, errOut)
528
+ }
529
+ if !strings.Contains(out, "tests/build are RED") {
530
+ t.Fatalf("failing exec_command omitted RED context: %q", out)
531
+ }
532
+ if _, err := os.Stat(red); err != nil {
533
+ t.Fatalf("failing exec_command did not create .red: %v", err)
534
+ }
535
+
536
+ pass := `{"tool_name":"exec_command","tool_input":{"cmd":"npm test"},"tool_response":"PASS: all tests passed"}`
537
+ out, errOut, code = runDevritesIO(t, root, pass, nil,
538
+ "hook", "redwatch", "--harness=codex")
455
539
  if code != 0 || strings.TrimSpace(out) != "" {
456
- t.Fatalf("wright hook must not shadow reviewer policy: exit=%d out=%q", code, out)
540
+ t.Fatalf("passing exec_command exit=%d out=%q stderr=%q", code, out, errOut)
541
+ }
542
+ if _, err := os.Stat(red); !os.IsNotExist(err) {
543
+ t.Fatalf("passing exec_command retained .red: %v", err)
457
544
  }
458
545
  }
459
546
 
@@ -502,7 +589,7 @@ func TestHookWrightScopeUsesExactOrchestratorAllowlist(t *testing.T) {
502
589
  }
503
590
  }
504
591
 
505
- func TestHookWrightScopeBindsCodexWorkerDuringReconcileWindow(t *testing.T) {
592
+ func TestHookWrightScopeRequiresBoundCodexWorkerDuringReconcileWindow(t *testing.T) {
506
593
  root := newWorkspace(t)
507
594
  writeActive(t, root, "auth-tokens")
508
595
  workspace := filepath.Join(root, "features", "auth-tokens")
@@ -519,7 +606,7 @@ func TestHookWrightScopeBindsCodexWorkerDuringReconcileWindow(t *testing.T) {
519
606
  input string
520
607
  deny bool
521
608
  }{
522
- {"listed edit", `{"tool_name":"Edit","tool_input":{"file_path":"src/app.go"},"agent_type":"worker"}`, false},
609
+ {"unbound listed edit", `{"tool_name":"Edit","tool_input":{"file_path":"src/app.go"},"agent_type":"worker"}`, true},
523
610
  {"unlisted edit", `{"tool_name":"Edit","tool_input":{"file_path":"src/other.go"},"agent_type":"worker"}`, true},
524
611
  } {
525
612
  t.Run(test.name, func(t *testing.T) {
@@ -549,6 +636,90 @@ func TestHookWrightScopeBindsCodexWorkerDuringReconcileWindow(t *testing.T) {
549
636
  }
550
637
  }
551
638
 
639
+ func TestHookWrightScopeAllowsOnlyBoundCodexV1WorkerPaths(t *testing.T) {
640
+ root := newWorkspace(t)
641
+ writeActive(t, root, "auth-tokens")
642
+ workspace := filepath.Join(root, "features", "auth-tokens")
643
+ for name, body := range map[string]string{
644
+ ".wright-allowlist": "src/app.go\n",
645
+ ".reconcile-base": "snapshot\n",
646
+ ".reconcile-allowlist": "src/app.go\n",
647
+ ".reconcile-devrites": "[]\n",
648
+ } {
649
+ if err := os.WriteFile(filepath.Join(workspace, name), []byte(body), 0o600); err != nil {
650
+ t.Fatal(err)
651
+ }
652
+ }
653
+ if err := os.MkdirAll(filepath.Join(workspace, ".reconcile-objects"), 0o700); err != nil {
654
+ t.Fatal(err)
655
+ }
656
+ sessionID, turnID := "session-bound-worker", "turn-bound-worker"
657
+ writeCodexAgentContract(t, root, "devrites-slice-wright")
658
+ writeCodexSkillContract(t, root, "rite-build", "devrites-slice-wright")
659
+ runDevritesIO(t, root, hookPayload(t, map[string]any{
660
+ "hook_event_name": "UserPromptSubmit",
661
+ "session_id": sessionID,
662
+ "turn_id": turnID,
663
+ "prompt": "$rite-build",
664
+ }), nil, "hook", "agent-dispatch", "--harness=codex")
665
+ if out, stderr, code := runDevritesIO(t, root, hookPayload(t, map[string]any{
666
+ "hook_event_name": "PreToolUse",
667
+ "session_id": sessionID,
668
+ "turn_id": turnID,
669
+ "tool_name": "spawn_agent",
670
+ "tool_use_id": "spawn-bound-worker",
671
+ "tool_input": map[string]any{
672
+ "agent_type": "worker",
673
+ "fork_turns": "none",
674
+ "message": "Read .codex/agents/devrites-slice-wright.toml.",
675
+ },
676
+ }), nil, "hook", "agent-dispatch", "--harness=codex"); code != 0 {
677
+ t.Fatalf("spawn exit=%d out=%s stderr=%s", code, out, stderr)
678
+ } else if decision, reason := parsePermissionDecision(t, out); decision != "allow" {
679
+ t.Fatalf("spawn denied: %s (%s)", decision, reason)
680
+ }
681
+ if out, stderr, code := runDevritesIO(t, root, hookPayload(t, map[string]any{
682
+ "hook_event_name": "SubagentStart",
683
+ "session_id": sessionID,
684
+ "turn_id": "child-turn",
685
+ "agent_id": "bound-worker",
686
+ "agent_type": "worker",
687
+ }), nil, "hook", "subagent-orient", "--harness=codex"); code != 0 ||
688
+ !strings.Contains(out, "Confirmed role") {
689
+ t.Fatalf("start exit=%d out=%s stderr=%s", code, out, stderr)
690
+ }
691
+
692
+ env := []string{"DEVRITES_CODEX_GENERIC_AGENT_COMPAT=1", "DEVRITES_HOOK_PROFILE=strict"}
693
+ for _, test := range []struct {
694
+ path string
695
+ deny bool
696
+ }{
697
+ {"src/app.go", false},
698
+ {"src/other.go", true},
699
+ } {
700
+ input := hookPayload(t, map[string]any{
701
+ "hook_event_name": "PreToolUse",
702
+ "session_id": sessionID,
703
+ "agent_id": "bound-worker",
704
+ "agent_type": "worker",
705
+ "tool_name": "Edit",
706
+ "tool_input": map[string]any{"file_path": test.path},
707
+ })
708
+ out, stderr, code := runDevritesIO(t, root, input, env,
709
+ "hook", "wright-scope", "--harness=codex")
710
+ if code != 0 {
711
+ t.Fatalf("%s exit=%d stderr=%s", test.path, code, stderr)
712
+ }
713
+ if test.deny {
714
+ if decision, _ := parsePermissionDecision(t, out); decision != "deny" {
715
+ t.Fatalf("%s should be denied: %s", test.path, out)
716
+ }
717
+ } else if strings.TrimSpace(out) != "" {
718
+ t.Fatalf("%s should be allowed: %s", test.path, out)
719
+ }
720
+ }
721
+ }
722
+
552
723
  func TestHookWrightScopeAcceptsCodexV2NamedRole(t *testing.T) {
553
724
  root := newWorkspace(t)
554
725
  writeActive(t, root, "auth-tokens")
@@ -629,6 +800,8 @@ func TestHookWrightScopeRequiresSpawnedCodexWorkerForSourceWrites(t *testing.T)
629
800
  runGuard(`{"tool_name":"Write","tool_input":{"file_path":"src/app.go"}}`, true)
630
801
  runGuard(`{"tool_name":"Bash","tool_input":{"command":"printf x > src/app.go"}}`, true)
631
802
  runGuard(`{"tool_name":"js","tool_input":{"code":"writeFile()"}}`, true)
803
+ runGuard(`{"tool_name":"Bash","tool_input":{"command":"devrites-engine reconcile restore-check auth-tokens"}}`, false)
804
+ runGuard(`{"tool_name":"Bash","tool_input":{"command":"devrites-engine reconcile restore-check auth-tokens && printf x > src/app.go"}}`, true)
632
805
  out, errOut, code := runDevritesIO(t, root,
633
806
  `{"tool_name":"Bash","tool_input":{"command":"printf x > src/app.go"}}`,
634
807
  nil, "hook", "wright-scope", "--harness=claude")
@@ -1544,6 +1717,74 @@ func TestCodexAgentDispatchBlocksFalseWaitAndStop(t *testing.T) {
1544
1717
  }
1545
1718
  }
1546
1719
 
1720
+ func TestCodexAgentDispatchRequiresSuccessfulReasonBoundWaiver(t *testing.T) {
1721
+ root := newWorkspace(t)
1722
+ role := "devrites-plan-reviewer"
1723
+ writeCodexAgentContract(t, root, role)
1724
+ writeCodexSkillContract(t, root, "rite-vet", role)
1725
+ sessionID, turnID := "session-waiver", "turn-waiver"
1726
+ runDevritesIO(t, root, hookPayload(t, map[string]any{
1727
+ "hook_event_name": "UserPromptSubmit",
1728
+ "session_id": sessionID,
1729
+ "turn_id": turnID,
1730
+ "prompt": "$rite-vet",
1731
+ }), nil, "hook", "agent-dispatch", "--harness=codex")
1732
+
1733
+ invalid := hookPayload(t, map[string]any{
1734
+ "hook_event_name": "PreToolUse",
1735
+ "session_id": sessionID,
1736
+ "turn_id": turnID,
1737
+ "tool_name": "functions.exec_command",
1738
+ "tool_input": map[string]any{"cmd": "devrites-engine dispatch-waive wrong-phase && true"},
1739
+ })
1740
+ out, _, _ := runDevritesIO(t, root, invalid, nil,
1741
+ "hook", "agent-dispatch", "--harness=codex")
1742
+ if decision, _ := parsePermissionDecision(t, out); decision != "deny" {
1743
+ t.Fatalf("chained waiver was not denied: %s", out)
1744
+ }
1745
+
1746
+ command := "devrites-engine dispatch-waive wrong-phase"
1747
+ pre := hookPayload(t, map[string]any{
1748
+ "hook_event_name": "PreToolUse",
1749
+ "session_id": sessionID,
1750
+ "turn_id": turnID,
1751
+ "tool_name": "functions.exec_command",
1752
+ "tool_input": map[string]any{"cmd": command},
1753
+ })
1754
+ if out, stderr, code := runDevritesIO(t, root, pre, nil,
1755
+ "hook", "agent-dispatch", "--harness=codex"); code != 0 || strings.TrimSpace(out) != "" {
1756
+ t.Fatalf("waiver pre exit=%d out=%s stderr=%s", code, out, stderr)
1757
+ }
1758
+
1759
+ stop := hookPayload(t, map[string]any{
1760
+ "hook_event_name": "Stop",
1761
+ "session_id": sessionID,
1762
+ "turn_id": turnID,
1763
+ })
1764
+ out, _, _ = runDevritesIO(t, root, stop, nil,
1765
+ "hook", "agent-dispatch", "--harness=codex")
1766
+ if decision, _ := parseStopDecision(t, out); decision != "block" {
1767
+ t.Fatalf("PreToolUse waiver falsely satisfied dispatch: %s", out)
1768
+ }
1769
+
1770
+ post := hookPayload(t, map[string]any{
1771
+ "hook_event_name": "PostToolUse",
1772
+ "session_id": sessionID,
1773
+ "turn_id": turnID,
1774
+ "tool_name": "functions.exec_command",
1775
+ "tool_input": map[string]any{"cmd": command},
1776
+ "tool_response": "dispatch-waive: accepted wrong-phase",
1777
+ })
1778
+ if out, stderr, code := runDevritesIO(t, root, post, nil,
1779
+ "hook", "agent-dispatch", "--harness=codex"); code != 0 || strings.TrimSpace(out) != "" {
1780
+ t.Fatalf("waiver post exit=%d out=%s stderr=%s", code, out, stderr)
1781
+ }
1782
+ if out, stderr, code := runDevritesIO(t, root, stop, nil,
1783
+ "hook", "agent-dispatch", "--harness=codex"); code != 0 || strings.TrimSpace(out) != "" {
1784
+ t.Fatalf("waived stop exit=%d out=%s stderr=%s", code, out, stderr)
1785
+ }
1786
+ }
1787
+
1547
1788
  func TestCodexAgentDispatchConfirmsGenericRoleAndResult(t *testing.T) {
1548
1789
  root := newWorkspace(t)
1549
1790
  sessionID, turnID := "session-complete", "turn-complete"
@@ -1664,12 +1905,99 @@ func TestCodexAgentDispatchConfirmsGenericRoleAndResult(t *testing.T) {
1664
1905
  t.Fatalf("wait exit=%d out=%s stderr=%s", code, out, stderr)
1665
1906
  }
1666
1907
 
1908
+ if out, stderr, code := runDevritesIO(t, root, stop, nil,
1909
+ "hook", "agent-dispatch", "--harness=codex"); code != 0 {
1910
+ t.Fatalf("pre-wait stop exit=%d out=%s stderr=%s", code, out, stderr)
1911
+ } else if decision, _ := parseStopDecision(t, out); decision != "block" {
1912
+ t.Fatalf("PreToolUse wait falsely satisfied dispatch: %s", out)
1913
+ }
1914
+
1915
+ waitTimeout := hookPayload(t, map[string]any{
1916
+ "hook_event_name": "PostToolUse",
1917
+ "session_id": sessionID,
1918
+ "turn_id": turnID,
1919
+ "tool_name": "wait",
1920
+ "tool_input": map[string]any{"ids": []string{"agent-1"}},
1921
+ "tool_response": map[string]any{"timed_out": true},
1922
+ })
1923
+ if out, stderr, code := runDevritesIO(t, root, waitTimeout, nil,
1924
+ "hook", "agent-dispatch", "--harness=codex"); code != 0 || strings.TrimSpace(out) != "" {
1925
+ t.Fatalf("timed-out wait post exit=%d out=%s stderr=%s", code, out, stderr)
1926
+ }
1927
+ if out, stderr, code := runDevritesIO(t, root, stop, nil,
1928
+ "hook", "agent-dispatch", "--harness=codex"); code != 0 {
1929
+ t.Fatalf("post-timeout stop exit=%d out=%s stderr=%s", code, out, stderr)
1930
+ } else if decision, _ := parseStopDecision(t, out); decision != "block" {
1931
+ t.Fatalf("timed-out wait falsely satisfied dispatch: %s", out)
1932
+ }
1933
+
1934
+ waitDone := hookPayload(t, map[string]any{
1935
+ "hook_event_name": "PostToolUse",
1936
+ "session_id": sessionID,
1937
+ "turn_id": turnID,
1938
+ "tool_name": "wait",
1939
+ "tool_input": map[string]any{"ids": []string{"agent-1"}},
1940
+ "tool_response": map[string]any{"status": "completed"},
1941
+ })
1942
+ if out, stderr, code := runDevritesIO(t, root, waitDone, nil,
1943
+ "hook", "agent-dispatch", "--harness=codex"); code != 0 || strings.TrimSpace(out) != "" {
1944
+ t.Fatalf("wait post exit=%d out=%s stderr=%s", code, out, stderr)
1945
+ }
1946
+
1667
1947
  if out, stderr, code := runDevritesIO(t, root, stop, nil,
1668
1948
  "hook", "agent-dispatch", "--harness=codex"); code != 0 || strings.TrimSpace(out) != "" {
1669
1949
  t.Fatalf("resolved stop exit=%d out=%s stderr=%s", code, out, stderr)
1670
1950
  }
1671
1951
  }
1672
1952
 
1953
+ func TestCodexAgentDispatchRefusesAmbiguousGenericStart(t *testing.T) {
1954
+ root := newWorkspace(t)
1955
+ sessionID, turnID := "session-ambiguous-start", "turn-ambiguous-start"
1956
+ roles := []string{"devrites-security-auditor", "devrites-plan-reviewer"}
1957
+ for _, role := range roles {
1958
+ writeCodexAgentContract(t, root, role)
1959
+ }
1960
+ writeCodexSkillContract(t, root, "devrites-audit", strings.Join(roles, ","))
1961
+ runDevritesIO(t, root, hookPayload(t, map[string]any{
1962
+ "hook_event_name": "UserPromptSubmit",
1963
+ "session_id": sessionID,
1964
+ "turn_id": turnID,
1965
+ "prompt": "$devrites-audit",
1966
+ }), nil, "hook", "agent-dispatch", "--harness=codex")
1967
+
1968
+ for i, role := range roles {
1969
+ out, stderr, code := runDevritesIO(t, root, hookPayload(t, map[string]any{
1970
+ "hook_event_name": "PreToolUse",
1971
+ "session_id": sessionID,
1972
+ "turn_id": turnID,
1973
+ "tool_name": "spawn_agent",
1974
+ "tool_use_id": fmt.Sprintf("spawn-%d", i),
1975
+ "tool_input": map[string]any{
1976
+ "agent_type": "explorer",
1977
+ "fork_turns": "none",
1978
+ "message": "Read .codex/agents/" + role + ".toml.",
1979
+ },
1980
+ }), nil, "hook", "agent-dispatch", "--harness=codex")
1981
+ if code != 0 {
1982
+ t.Fatalf("spawn %s exit=%d out=%s stderr=%s", role, code, out, stderr)
1983
+ }
1984
+ }
1985
+
1986
+ out, stderr, code := runDevritesIO(t, root, hookPayload(t, map[string]any{
1987
+ "hook_event_name": "SubagentStart",
1988
+ "session_id": sessionID,
1989
+ "turn_id": "child-turn",
1990
+ "agent_id": "ambiguous-agent",
1991
+ "agent_type": "explorer",
1992
+ }), nil, "hook", "subagent-orient", "--harness=codex")
1993
+ if code != 0 {
1994
+ t.Fatalf("start exit=%d stderr=%s", code, stderr)
1995
+ }
1996
+ if !strings.Contains(out, "Dispatch binding failed") {
1997
+ t.Fatalf("ambiguous generic start was bound to an arbitrary role: %s", out)
1998
+ }
1999
+ }
2000
+
1673
2001
  func TestCodexAgentDispatchConfirmsDurableV2NamedRole(t *testing.T) {
1674
2002
  root := newWorkspace(t)
1675
2003
  codeHome := t.TempDir()
@@ -1960,6 +2288,14 @@ func TestCodexAgentDispatchGatesReconcileCloseOnBoundWrightResult(t *testing.T)
1960
2288
  "tool_name": "wait",
1961
2289
  "tool_input": map[string]any{"ids": []string{"agent-wright"}},
1962
2290
  },
2291
+ {
2292
+ "hook_event_name": "PostToolUse",
2293
+ "session_id": sessionID,
2294
+ "turn_id": turnID,
2295
+ "tool_name": "wait",
2296
+ "tool_input": map[string]any{"ids": []string{"agent-wright"}},
2297
+ "tool_response": map[string]any{"status": "completed"},
2298
+ },
1963
2299
  } {
1964
2300
  hook := "agent-dispatch"
1965
2301
  if payload["hook_event_name"] == "SubagentStart" {
@@ -71,6 +71,9 @@ func TestParityReconcile(t *testing.T) {
71
71
  t.Run("close-window", func(t *testing.T) {
72
72
  work := newRepo(t)
73
73
  snapshot(t, work)
74
+ if out, code := runArgv(t, work, libRootEnv(work), "", binPath, "reconcile", "check", "feat"); code != 0 {
75
+ t.Fatalf("pre-close reconcile check = %d, want 0\n%s", code, out)
76
+ }
74
77
  out, code := runArgv(t, work, libRootEnv(work), "", binPath, "reconcile", "close", "feat")
75
78
  assertGolden(t, out, code)
76
79
  })
@@ -78,7 +81,7 @@ func TestParityReconcile(t *testing.T) {
78
81
  t.Run("not-git", func(t *testing.T) {
79
82
  work := t.TempDir()
80
83
  makeFeatureDir(t, work, "feat")
81
- check(t, work) // exit 0
84
+ check(t, work) // fail closed outside Git
82
85
  })
83
86
 
84
87
  t.Run("bad-mode", func(t *testing.T) {