devrites 3.2.2 → 3.2.3

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 (196) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/README.md +1 -1
  3. package/docs/architecture.md +6 -2
  4. package/docs/command-map.md +3 -3
  5. package/docs/engine/agent-contract.md +3 -2
  6. package/docs/harness-compliance.md +2 -2
  7. package/docs/orchestration.md +15 -12
  8. package/docs/porting-to-a-new-harness.md +2 -1
  9. package/docs/skills.md +7 -2
  10. package/engine/hooks.go +39 -4
  11. package/engine/hooks_agent_dispatch.go +1193 -0
  12. package/engine/hooks_workspace.go +78 -10
  13. package/engine/internal/forge/forge.go +0 -2
  14. package/engine/internal/harness/compliance.go +1 -1
  15. package/engine/internal/harness/harness.go +3 -0
  16. package/engine/internal/lib/doubtcoverage.go +2 -12
  17. package/engine/internal/lib/reconcile.go +4 -29
  18. package/engine/internal/lib/reconcile_test.go +14 -10
  19. package/engine/main.go +4 -1
  20. package/engine/tests/hook_test.go +874 -1
  21. package/engine/tests/lib_parity_test.go +5 -17
  22. package/engine/tests/parity_reconcile_test.go +1 -1
  23. package/engine/tests/parity_test.go +2 -6
  24. package/pack/.claude/skills/devrites-api-interface/SKILL.md +1 -0
  25. package/pack/.claude/skills/devrites-audit/SKILL.md +5 -6
  26. package/pack/.claude/skills/devrites-browser-proof/SKILL.md +1 -0
  27. package/pack/.claude/skills/devrites-debug-recovery/SKILL.md +1 -0
  28. package/pack/.claude/skills/devrites-doubt/SKILL.md +2 -1
  29. package/pack/.claude/skills/devrites-frontend-craft/SKILL.md +1 -0
  30. package/pack/.claude/skills/devrites-interview/SKILL.md +1 -0
  31. package/pack/.claude/skills/devrites-lib/SKILL.md +1 -0
  32. package/pack/.claude/skills/devrites-lib/reference/model-tiers.md +4 -3
  33. package/pack/.claude/skills/devrites-lib/reference/parallel-dispatch.md +4 -5
  34. package/pack/.claude/skills/devrites-lib/reference/standards/agents.md +14 -8
  35. package/pack/.claude/skills/devrites-prose-craft/SKILL.md +1 -0
  36. package/pack/.claude/skills/devrites-refresh-indexes/SKILL.md +1 -0
  37. package/pack/.claude/skills/devrites-source-driven/SKILL.md +1 -0
  38. package/pack/.claude/skills/devrites-ux-shape/SKILL.md +1 -0
  39. package/pack/.claude/skills/rite/SKILL.md +1 -0
  40. package/pack/.claude/skills/rite-adopt/SKILL.md +1 -0
  41. package/pack/.claude/skills/rite-autocomplete/SKILL.md +1 -0
  42. package/pack/.claude/skills/rite-build/SKILL.md +2 -1
  43. package/pack/.claude/skills/rite-build/reference/phase-contract.md +3 -4
  44. package/pack/.claude/skills/rite-build/reference/wright-dispatch.md +11 -12
  45. package/pack/.claude/skills/rite-clarify/SKILL.md +1 -0
  46. package/pack/.claude/skills/rite-converge/SKILL.md +1 -0
  47. package/pack/.claude/skills/rite-customize/SKILL.md +1 -0
  48. package/pack/.claude/skills/rite-define/SKILL.md +1 -0
  49. package/pack/.claude/skills/rite-doctor/SKILL.md +1 -0
  50. package/pack/.claude/skills/rite-dogfood/SKILL.md +1 -0
  51. package/pack/.claude/skills/rite-explain/SKILL.md +4 -2
  52. package/pack/.claude/skills/rite-frame/SKILL.md +1 -0
  53. package/pack/.claude/skills/rite-handoff/SKILL.md +1 -0
  54. package/pack/.claude/skills/rite-learn/SKILL.md +1 -0
  55. package/pack/.claude/skills/rite-plan/SKILL.md +1 -0
  56. package/pack/.claude/skills/rite-polish/SKILL.md +1 -0
  57. package/pack/.claude/skills/rite-pov/SKILL.md +1 -0
  58. package/pack/.claude/skills/rite-pr-feedback/SKILL.md +1 -0
  59. package/pack/.claude/skills/rite-pressure-test/SKILL.md +1 -0
  60. package/pack/.claude/skills/rite-prototype/SKILL.md +1 -0
  61. package/pack/.claude/skills/rite-prove/SKILL.md +1 -0
  62. package/pack/.claude/skills/rite-quick/SKILL.md +1 -0
  63. package/pack/.claude/skills/rite-resolve/SKILL.md +1 -0
  64. package/pack/.claude/skills/rite-review/SKILL.md +1 -0
  65. package/pack/.claude/skills/rite-seal/SKILL.md +1 -0
  66. package/pack/.claude/skills/rite-seal/reference/phase-contract.md +2 -3
  67. package/pack/.claude/skills/rite-seal/reference/risk-and-rollback.md +10 -12
  68. package/pack/.claude/skills/rite-ship/SKILL.md +1 -0
  69. package/pack/.claude/skills/rite-spec/SKILL.md +1 -0
  70. package/pack/.claude/skills/rite-status/SKILL.md +1 -0
  71. package/pack/.claude/skills/rite-temper/SKILL.md +3 -2
  72. package/pack/.claude/skills/rite-upgrade/SKILL.md +3 -1
  73. package/pack/.claude/skills/rite-vet/SKILL.md +1 -0
  74. package/pack/.claude/skills/rite-zoom-out/SKILL.md +1 -0
  75. package/pack/generated/claude/skills/devrites-api-interface/SKILL.md +1 -0
  76. package/pack/generated/claude/skills/devrites-audit/SKILL.md +5 -6
  77. package/pack/generated/claude/skills/devrites-browser-proof/SKILL.md +1 -0
  78. package/pack/generated/claude/skills/devrites-debug-recovery/SKILL.md +1 -0
  79. package/pack/generated/claude/skills/devrites-doubt/SKILL.md +2 -1
  80. package/pack/generated/claude/skills/devrites-frontend-craft/SKILL.md +1 -0
  81. package/pack/generated/claude/skills/devrites-interview/SKILL.md +1 -0
  82. package/pack/generated/claude/skills/devrites-lib/SKILL.md +1 -0
  83. package/pack/generated/claude/skills/devrites-lib/reference/model-tiers.md +4 -3
  84. package/pack/generated/claude/skills/devrites-lib/reference/parallel-dispatch.md +4 -5
  85. package/pack/generated/claude/skills/devrites-lib/reference/standards/agents.md +14 -8
  86. package/pack/generated/claude/skills/devrites-prose-craft/SKILL.md +1 -0
  87. package/pack/generated/claude/skills/devrites-refresh-indexes/SKILL.md +1 -0
  88. package/pack/generated/claude/skills/devrites-source-driven/SKILL.md +1 -0
  89. package/pack/generated/claude/skills/devrites-ux-shape/SKILL.md +1 -0
  90. package/pack/generated/claude/skills/rite/SKILL.md +1 -0
  91. package/pack/generated/claude/skills/rite-adopt/SKILL.md +1 -0
  92. package/pack/generated/claude/skills/rite-autocomplete/SKILL.md +1 -0
  93. package/pack/generated/claude/skills/rite-build/SKILL.md +2 -1
  94. package/pack/generated/claude/skills/rite-build/reference/phase-contract.md +3 -4
  95. package/pack/generated/claude/skills/rite-build/reference/wright-dispatch.md +11 -12
  96. package/pack/generated/claude/skills/rite-clarify/SKILL.md +1 -0
  97. package/pack/generated/claude/skills/rite-converge/SKILL.md +1 -0
  98. package/pack/generated/claude/skills/rite-customize/SKILL.md +1 -0
  99. package/pack/generated/claude/skills/rite-define/SKILL.md +1 -0
  100. package/pack/generated/claude/skills/rite-doctor/SKILL.md +1 -0
  101. package/pack/generated/claude/skills/rite-dogfood/SKILL.md +1 -0
  102. package/pack/generated/claude/skills/rite-explain/SKILL.md +4 -2
  103. package/pack/generated/claude/skills/rite-frame/SKILL.md +1 -0
  104. package/pack/generated/claude/skills/rite-handoff/SKILL.md +1 -0
  105. package/pack/generated/claude/skills/rite-learn/SKILL.md +1 -0
  106. package/pack/generated/claude/skills/rite-plan/SKILL.md +1 -0
  107. package/pack/generated/claude/skills/rite-polish/SKILL.md +1 -0
  108. package/pack/generated/claude/skills/rite-pov/SKILL.md +1 -0
  109. package/pack/generated/claude/skills/rite-pr-feedback/SKILL.md +1 -0
  110. package/pack/generated/claude/skills/rite-pressure-test/SKILL.md +1 -0
  111. package/pack/generated/claude/skills/rite-prototype/SKILL.md +1 -0
  112. package/pack/generated/claude/skills/rite-prove/SKILL.md +1 -0
  113. package/pack/generated/claude/skills/rite-quick/SKILL.md +1 -0
  114. package/pack/generated/claude/skills/rite-resolve/SKILL.md +1 -0
  115. package/pack/generated/claude/skills/rite-review/SKILL.md +1 -0
  116. package/pack/generated/claude/skills/rite-seal/SKILL.md +1 -0
  117. package/pack/generated/claude/skills/rite-seal/reference/phase-contract.md +2 -3
  118. package/pack/generated/claude/skills/rite-seal/reference/risk-and-rollback.md +10 -12
  119. package/pack/generated/claude/skills/rite-ship/SKILL.md +1 -0
  120. package/pack/generated/claude/skills/rite-spec/SKILL.md +1 -0
  121. package/pack/generated/claude/skills/rite-status/SKILL.md +1 -0
  122. package/pack/generated/claude/skills/rite-temper/SKILL.md +3 -2
  123. package/pack/generated/claude/skills/rite-upgrade/SKILL.md +3 -1
  124. package/pack/generated/claude/skills/rite-vet/SKILL.md +1 -0
  125. package/pack/generated/claude/skills/rite-zoom-out/SKILL.md +1 -0
  126. package/pack/generated/codex/AGENTS.md +2 -4
  127. package/pack/generated/codex/hooks.json +24 -5
  128. package/pack/generated/codex/skills/devrites-api-interface/SKILL.md +6 -4
  129. package/pack/generated/codex/skills/devrites-audit/SKILL.md +10 -10
  130. package/pack/generated/codex/skills/devrites-browser-proof/SKILL.md +6 -4
  131. package/pack/generated/codex/skills/devrites-debug-recovery/SKILL.md +6 -4
  132. package/pack/generated/codex/skills/devrites-doubt/SKILL.md +7 -5
  133. package/pack/generated/codex/skills/devrites-frontend-craft/SKILL.md +6 -4
  134. package/pack/generated/codex/skills/devrites-interview/SKILL.md +6 -4
  135. package/pack/generated/codex/skills/devrites-lib/SKILL.md +6 -4
  136. package/pack/generated/codex/skills/devrites-lib/reference/model-tiers.md +4 -3
  137. package/pack/generated/codex/skills/devrites-lib/reference/parallel-dispatch.md +4 -5
  138. package/pack/generated/codex/skills/devrites-lib/reference/standards/agents.md +14 -8
  139. package/pack/generated/codex/skills/devrites-prose-craft/SKILL.md +6 -4
  140. package/pack/generated/codex/skills/devrites-refresh-indexes/SKILL.md +6 -4
  141. package/pack/generated/codex/skills/devrites-source-driven/SKILL.md +6 -4
  142. package/pack/generated/codex/skills/devrites-ux-shape/SKILL.md +6 -4
  143. package/pack/generated/codex/skills/rite/SKILL.md +6 -4
  144. package/pack/generated/codex/skills/rite-adopt/SKILL.md +6 -4
  145. package/pack/generated/codex/skills/rite-autocomplete/SKILL.md +6 -4
  146. package/pack/generated/codex/skills/rite-build/SKILL.md +7 -5
  147. package/pack/generated/codex/skills/rite-build/reference/phase-contract.md +3 -4
  148. package/pack/generated/codex/skills/rite-build/reference/wright-dispatch.md +11 -12
  149. package/pack/generated/codex/skills/rite-clarify/SKILL.md +6 -4
  150. package/pack/generated/codex/skills/rite-converge/SKILL.md +6 -4
  151. package/pack/generated/codex/skills/rite-customize/SKILL.md +6 -4
  152. package/pack/generated/codex/skills/rite-define/SKILL.md +6 -4
  153. package/pack/generated/codex/skills/rite-doctor/SKILL.md +6 -4
  154. package/pack/generated/codex/skills/rite-dogfood/SKILL.md +6 -4
  155. package/pack/generated/codex/skills/rite-explain/SKILL.md +9 -6
  156. package/pack/generated/codex/skills/rite-frame/SKILL.md +6 -4
  157. package/pack/generated/codex/skills/rite-handoff/SKILL.md +6 -4
  158. package/pack/generated/codex/skills/rite-learn/SKILL.md +6 -4
  159. package/pack/generated/codex/skills/rite-plan/SKILL.md +6 -4
  160. package/pack/generated/codex/skills/rite-polish/SKILL.md +6 -4
  161. package/pack/generated/codex/skills/rite-pov/SKILL.md +6 -4
  162. package/pack/generated/codex/skills/rite-pr-feedback/SKILL.md +6 -4
  163. package/pack/generated/codex/skills/rite-pressure-test/SKILL.md +6 -4
  164. package/pack/generated/codex/skills/rite-prototype/SKILL.md +6 -4
  165. package/pack/generated/codex/skills/rite-prove/SKILL.md +6 -4
  166. package/pack/generated/codex/skills/rite-quick/SKILL.md +6 -4
  167. package/pack/generated/codex/skills/rite-resolve/SKILL.md +6 -4
  168. package/pack/generated/codex/skills/rite-review/SKILL.md +6 -4
  169. package/pack/generated/codex/skills/rite-seal/SKILL.md +6 -4
  170. package/pack/generated/codex/skills/rite-seal/reference/phase-contract.md +2 -3
  171. package/pack/generated/codex/skills/rite-seal/reference/risk-and-rollback.md +10 -12
  172. package/pack/generated/codex/skills/rite-ship/SKILL.md +6 -4
  173. package/pack/generated/codex/skills/rite-spec/SKILL.md +6 -4
  174. package/pack/generated/codex/skills/rite-status/SKILL.md +6 -4
  175. package/pack/generated/codex/skills/rite-temper/SKILL.md +8 -6
  176. package/pack/generated/codex/skills/rite-upgrade/SKILL.md +8 -5
  177. package/pack/generated/codex/skills/rite-vet/SKILL.md +6 -4
  178. package/pack/generated/codex/skills/rite-zoom-out/SKILL.md +6 -4
  179. package/package.json +1 -1
  180. package/scripts/codex-generate.sh +32 -18
  181. package/scripts/live-hosts/agent-result.schema.json +2 -1
  182. package/scripts/live-hosts/claude.sh +2 -2
  183. package/scripts/live-hosts/codex.sh +4 -0
  184. package/scripts/live-hosts/fake-host.py +1 -8
  185. package/scripts/live-hosts/host-transport.py +1 -2
  186. package/scripts/run-agent-contract-evals.py +3 -3
  187. package/scripts/run-evals.sh +3 -34
  188. package/scripts/validate-frontmatter.py +34 -1
  189. package/engine/testdata/golden/TestParityA1Guard/inline-fallback-silent.golden +0 -1
  190. package/engine/testdata/golden/TestParityDoubtCoverage/arg=inline.golden +0 -3
  191. package/engine/testdata/golden/TestParityDoubtCoverage/arg=missing-inline.golden +0 -3
  192. package/scripts/eval-runner.py +0 -275
  193. package/scripts/eval-scorer.mjs +0 -46
  194. package/scripts/render-eval-summary.py +0 -48
  195. /package/engine/testdata/golden/TestParityReconcile/{inline-fallback → close-window}/snapshot.golden +0 -0
  196. /package/engine/testdata/golden/TestParityReconcile/{inline-fallback.golden → close-window.golden} +0 -0
@@ -11,6 +11,7 @@ import (
11
11
  "strconv"
12
12
  "strings"
13
13
  "testing"
14
+ "time"
14
15
  )
15
16
 
16
17
  // writeActive points the workspace's ACTIVE pointer at slug.
@@ -475,7 +476,7 @@ func TestHookWrightScopeBindsCodexWorkerDuringReconcileWindow(t *testing.T) {
475
476
  if err := os.WriteFile(filepath.Join(workspace, ".reconcile-base"), []byte("snapshot\n"), 0o600); err != nil {
476
477
  t.Fatal(err)
477
478
  }
478
- env := []string{"DEVRITES_CODEX_GENERIC_AGENT_COMPAT=1"}
479
+ env := []string{"DEVRITES_CODEX_GENERIC_AGENT_COMPAT=1", "DEVRITES_HOOK_PROFILE=strict"}
479
480
 
480
481
  for _, test := range []struct {
481
482
  name string
@@ -512,6 +513,137 @@ func TestHookWrightScopeBindsCodexWorkerDuringReconcileWindow(t *testing.T) {
512
513
  }
513
514
  }
514
515
 
516
+ func TestHookWrightScopeAcceptsCodexV2NamedRole(t *testing.T) {
517
+ root := newWorkspace(t)
518
+ writeActive(t, root, "auth-tokens")
519
+ workspace := filepath.Join(root, "features", "auth-tokens")
520
+ if err := os.WriteFile(filepath.Join(workspace, ".wright-allowlist"), []byte("src/app.go\n"), 0o600); err != nil {
521
+ t.Fatal(err)
522
+ }
523
+ for _, name := range []string{".reconcile-base", ".reconcile-allowlist", ".reconcile-devrites"} {
524
+ if err := os.WriteFile(filepath.Join(workspace, name), []byte(name+"\n"), 0o600); err != nil {
525
+ t.Fatal(err)
526
+ }
527
+ }
528
+
529
+ env := []string{"DEVRITES_HOOK_PROFILE=strict"}
530
+ for _, test := range []struct {
531
+ path string
532
+ deny bool
533
+ }{
534
+ {"src/app.go", false},
535
+ {"src/other.go", true},
536
+ } {
537
+ payload := hookPayload(t, map[string]any{
538
+ "hook_event_name": "PreToolUse",
539
+ "agent_type": "devrites-slice-wright",
540
+ "tool_name": "Edit",
541
+ "tool_input": map[string]any{"file_path": test.path},
542
+ })
543
+ out, stderr, code := runDevritesIO(t, root, payload, env,
544
+ "hook", "wright-scope", "--harness=codex")
545
+ if code != 0 {
546
+ t.Fatalf("%s exit=%d stderr=%s", test.path, code, stderr)
547
+ }
548
+ if test.deny {
549
+ if decision, _ := parsePermissionDecision(t, out); decision != "deny" {
550
+ t.Fatalf("%s should be denied: %s", test.path, out)
551
+ }
552
+ } else if strings.TrimSpace(out) != "" {
553
+ t.Fatalf("%s should be allowed: %s", test.path, out)
554
+ }
555
+ }
556
+ }
557
+
558
+ func TestHookWrightScopeRequiresSpawnedCodexWorkerForSourceWrites(t *testing.T) {
559
+ root := newWorkspace(t)
560
+ writeActive(t, root, "auth-tokens")
561
+ workspace := filepath.Join(root, "features", "auth-tokens")
562
+ if err := os.WriteFile(filepath.Join(workspace, ".reconcile-base"), []byte("snapshot\n"), 0o600); err != nil {
563
+ t.Fatal(err)
564
+ }
565
+ env := []string{"DEVRITES_CODEX_GENERIC_AGENT_COMPAT=1"}
566
+
567
+ runGuard := func(input string, deny bool) {
568
+ t.Helper()
569
+ out, errOut, code := runDevritesIO(t, root, input, env,
570
+ "hook", "wright-scope", "--harness=codex")
571
+ if code != 0 {
572
+ t.Fatalf("exit=%d out=%q stderr=%q", code, out, errOut)
573
+ }
574
+ if deny {
575
+ if decision, _ := parsePermissionDecision(t, out); decision != "deny" {
576
+ t.Fatalf("expected deny, got %q", out)
577
+ }
578
+ } else if strings.TrimSpace(out) != "" {
579
+ t.Fatalf("expected allow, got %q", out)
580
+ }
581
+ }
582
+
583
+ runGuard(`{"tool_name":"spawn_agent","tool_input":{"agent_type":"worker"}}`, false)
584
+ runGuard(`{"tool_name":"spawn_agent","tool_input":{"agent_type":"explorer"}}`, false)
585
+ runGuard(`{"tool_name":"Write","tool_input":{"file_path":".devrites/features/auth-tokens/state.md"}}`, false)
586
+ runGuard(`{"tool_name":"Write","tool_input":{"file_path":"src/app.go"}}`, true)
587
+ runGuard(`{"tool_name":"Bash","tool_input":{"command":"printf x > src/app.go"}}`, true)
588
+ runGuard(`{"tool_name":"js","tool_input":{"code":"writeFile()"}}`, true)
589
+ out, errOut, code := runDevritesIO(t, root,
590
+ `{"tool_name":"Bash","tool_input":{"command":"printf x > src/app.go"}}`,
591
+ nil, "hook", "wright-scope", "--harness=claude")
592
+ if code != 0 {
593
+ t.Fatalf("Claude root guard: exit=%d stderr=%q", code, errOut)
594
+ }
595
+ if decision, _ := parsePermissionDecision(t, out); decision != "deny" {
596
+ t.Fatalf("Claude root source write was not denied: %q", out)
597
+ }
598
+
599
+ // A legacy marker is inert: it cannot authorize root source writes.
600
+ if err := os.WriteFile(filepath.Join(workspace, ".reconcile-inline"), nil, 0o600); err != nil {
601
+ t.Fatal(err)
602
+ }
603
+ runGuard(`{"tool_name":"Bash","tool_input":{"command":"printf x > src/app.go"}}`, true)
604
+ runGuard(`{"tool_name":"js","tool_input":{"code":"writeFile()"}}`, true)
605
+
606
+ projectDir := filepath.Dir(root)
607
+ if err := os.MkdirAll(filepath.Join(projectDir, "src"), 0o755); err != nil {
608
+ t.Fatal(err)
609
+ }
610
+ source := filepath.Join(projectDir, "src", "app.go")
611
+ if err := os.WriteFile(source, []byte("package src\n"), 0o600); err != nil {
612
+ t.Fatal(err)
613
+ }
614
+ alias := filepath.Join(workspace, "source-alias")
615
+ if err := os.Symlink(source, alias); err != nil {
616
+ t.Skipf("symlinks unavailable: %v", err)
617
+ }
618
+ runGuard(`{"tool_name":"Write","tool_input":{"file_path":".devrites/features/auth-tokens/source-alias"}}`, true)
619
+ }
620
+
621
+ func TestHookA1GuardIgnoresLegacyInlineMarker(t *testing.T) {
622
+ root := newWorkspace(t)
623
+ writeActive(t, root, "auth-tokens")
624
+ workspace := filepath.Join(root, "features", "auth-tokens")
625
+ for _, name := range []string{".reconcile-base", ".reconcile-inline"} {
626
+ if err := os.WriteFile(filepath.Join(workspace, name), nil, 0o600); err != nil {
627
+ t.Fatal(err)
628
+ }
629
+ }
630
+ input := `{"tool_name":"Edit","tool_input":{"file_path":"src/app.go"}}`
631
+ env := []string{"DEVRITES_A1_HOOK=enforce"}
632
+
633
+ for _, host := range []string{"codex", "claude"} {
634
+ t.Run(host, func(t *testing.T) {
635
+ out, errOut, code := runDevritesIO(t, root, input, env,
636
+ "hook", "a1-guard", "--harness="+host)
637
+ if code != 0 {
638
+ t.Fatalf("exit=%d stderr=%q", code, errOut)
639
+ }
640
+ if decision, _ := parsePermissionDecision(t, out); decision != "deny" {
641
+ t.Fatalf("legacy inline marker authorized root source write: %q", out)
642
+ }
643
+ })
644
+ }
645
+ }
646
+
515
647
  func TestHookWrightScopeFailsClosedWhenWorkspaceResolutionFails(t *testing.T) {
516
648
  root := newWorkspace(t)
517
649
  if err := os.Remove(filepath.Join(root, "ACTIVE")); err != nil && !os.IsNotExist(err) {
@@ -733,3 +865,744 @@ func TestHookSubagentOrientSilentForNonDevritesAgent(t *testing.T) {
733
865
  }
734
866
  }
735
867
  }
868
+
869
+ func hookPayload(t *testing.T, value map[string]any) string {
870
+ t.Helper()
871
+ raw, err := json.Marshal(value)
872
+ if err != nil {
873
+ t.Fatal(err)
874
+ }
875
+ return string(raw)
876
+ }
877
+
878
+ func writeCodexAgentContract(t *testing.T, root, role string) {
879
+ t.Helper()
880
+ dir := filepath.Join(filepath.Dir(root), ".codex", "agents")
881
+ if err := os.MkdirAll(dir, 0o755); err != nil {
882
+ t.Fatal(err)
883
+ }
884
+ contract := "name = \"" + role + "\"\ndeveloper_instructions = '''\nROLE-CONTRACT:" + role + "\n'''\n"
885
+ if err := os.WriteFile(filepath.Join(dir, role+".toml"), []byte(contract), 0o600); err != nil {
886
+ t.Fatal(err)
887
+ }
888
+ }
889
+
890
+ func writeCodexSkillContract(t *testing.T, root, skill, roles string) {
891
+ t.Helper()
892
+ dir := filepath.Join(filepath.Dir(root), ".agents", "skills", skill)
893
+ if err := os.MkdirAll(dir, 0o755); err != nil {
894
+ t.Fatal(err)
895
+ }
896
+ contract := "---\nname: " + skill + "\ndescription: Test skill.\nrequired-agent-roles: " + roles + "\n---\n"
897
+ if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(contract), 0o600); err != nil {
898
+ t.Fatal(err)
899
+ }
900
+ }
901
+
902
+ func writeCodexV2Rollouts(
903
+ t *testing.T,
904
+ codeHome, projectRoot, sessionID, turnID, role, taskName string,
905
+ includeWait, includeInstructions, includeResult bool,
906
+ ) {
907
+ t.Helper()
908
+ dir := filepath.Join(codeHome, "sessions", "2026", "07", "25")
909
+ if err := os.MkdirAll(dir, 0o700); err != nil {
910
+ t.Fatal(err)
911
+ }
912
+ at := time.Now().UTC()
913
+ parent := []map[string]any{
914
+ {
915
+ "timestamp": at.Format(time.RFC3339Nano),
916
+ "type": "session_meta",
917
+ "payload": map[string]any{
918
+ "session_id": sessionID,
919
+ "id": sessionID,
920
+ "cwd": projectRoot,
921
+ },
922
+ },
923
+ {
924
+ "timestamp": at.Add(time.Millisecond).Format(time.RFC3339Nano),
925
+ "type": "response_item",
926
+ "payload": map[string]any{
927
+ "type": "function_call",
928
+ "name": "spawn_agent",
929
+ "arguments": `{"agent_type":"` + role + `","task_name":"` + taskName + `","fork_turns":"none","message":"encrypted"}`,
930
+ "internal_chat_message_metadata_passthrough": map[string]any{
931
+ "turn_id": turnID,
932
+ },
933
+ },
934
+ },
935
+ }
936
+ if includeWait {
937
+ parent = append(parent, map[string]any{
938
+ "timestamp": at.Add(2 * time.Millisecond).Format(time.RFC3339Nano),
939
+ "type": "response_item",
940
+ "payload": map[string]any{
941
+ "type": "function_call",
942
+ "name": "wait_agent",
943
+ "arguments": `{"timeout_ms":3600000}`,
944
+ "internal_chat_message_metadata_passthrough": map[string]any{
945
+ "turn_id": turnID,
946
+ },
947
+ },
948
+ })
949
+ }
950
+ if includeResult {
951
+ parent = append(parent, map[string]any{
952
+ "timestamp": at.Add(3 * time.Millisecond).Format(time.RFC3339Nano),
953
+ "type": "response_item",
954
+ "payload": map[string]any{
955
+ "type": "agent_message",
956
+ "author": "/root/" + taskName,
957
+ "recipient": "/root",
958
+ "content": []map[string]any{{
959
+ "type": "input_text",
960
+ "text": "Message Type: FINAL_ANSWER\nPayload:\nagent-result/v1 complete",
961
+ }},
962
+ "internal_chat_message_metadata_passthrough": map[string]any{
963
+ "turn_id": turnID,
964
+ },
965
+ },
966
+ })
967
+ }
968
+ writeJSONLines(t, filepath.Join(dir, "rollout-parent-"+sessionID+".jsonl"), parent)
969
+
970
+ child := []map[string]any{
971
+ {
972
+ "timestamp": at.Add(time.Millisecond).Format(time.RFC3339Nano),
973
+ "type": "session_meta",
974
+ "payload": map[string]any{
975
+ "session_id": sessionID,
976
+ "id": "child-" + taskName,
977
+ "parent_thread_id": sessionID,
978
+ "cwd": projectRoot,
979
+ "agent_path": "/root/" + taskName,
980
+ "agent_role": role,
981
+ },
982
+ },
983
+ }
984
+ if includeInstructions {
985
+ child = append(child, map[string]any{
986
+ "timestamp": at.Add(2 * time.Millisecond).Format(time.RFC3339Nano),
987
+ "type": "response_item",
988
+ "payload": map[string]any{
989
+ "type": "message",
990
+ "role": "developer",
991
+ "content": []map[string]any{{
992
+ "type": "input_text",
993
+ "text": "ROLE-CONTRACT:" + role,
994
+ }},
995
+ },
996
+ })
997
+ }
998
+ if includeResult {
999
+ child = append(child, map[string]any{
1000
+ "timestamp": at.Add(3 * time.Millisecond).Format(time.RFC3339Nano),
1001
+ "type": "event_msg",
1002
+ "payload": map[string]any{
1003
+ "type": "task_complete",
1004
+ "last_agent_message": "agent-result/v1 complete",
1005
+ },
1006
+ })
1007
+ }
1008
+ writeJSONLines(t, filepath.Join(dir, "rollout-child-"+taskName+".jsonl"), child)
1009
+ }
1010
+
1011
+ func writeJSONLines(t *testing.T, path string, records []map[string]any) {
1012
+ t.Helper()
1013
+ var lines []string
1014
+ for _, record := range records {
1015
+ raw, err := json.Marshal(record)
1016
+ if err != nil {
1017
+ t.Fatal(err)
1018
+ }
1019
+ lines = append(lines, string(raw))
1020
+ }
1021
+ if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")+"\n"), 0o600); err != nil {
1022
+ t.Fatal(err)
1023
+ }
1024
+ }
1025
+
1026
+ func TestCodexAgentDispatchArmsInstalledSkillRequirements(t *testing.T) {
1027
+ for _, tc := range []struct {
1028
+ name string
1029
+ invocation string
1030
+ roles string
1031
+ wantRoles []string
1032
+ }{
1033
+ {
1034
+ name: "single-role-dollar-invocation",
1035
+ invocation: "$rite-vet",
1036
+ roles: "devrites-plan-reviewer",
1037
+ wantRoles: []string{"devrites-plan-reviewer"},
1038
+ },
1039
+ {
1040
+ name: "multi-role-slash-invocation",
1041
+ invocation: "/rite-review",
1042
+ roles: "devrites-spec-reviewer, devrites-code-reviewer",
1043
+ wantRoles: []string{"devrites-code-reviewer", "devrites-spec-reviewer"},
1044
+ },
1045
+ {
1046
+ name: "declared-no-agent",
1047
+ invocation: "$rite-status",
1048
+ roles: "none",
1049
+ },
1050
+ } {
1051
+ t.Run(tc.name, func(t *testing.T) {
1052
+ root := newWorkspace(t)
1053
+ skill := strings.TrimLeft(tc.invocation, "$/")
1054
+ writeCodexSkillContract(t, root, skill, tc.roles)
1055
+ for _, role := range tc.wantRoles {
1056
+ writeCodexAgentContract(t, root, role)
1057
+ }
1058
+ sessionID, turnID := "session-"+tc.name, "turn-"+tc.name
1059
+ out, stderr, code := runDevritesIO(t, root, hookPayload(t, map[string]any{
1060
+ "hook_event_name": "UserPromptSubmit",
1061
+ "session_id": sessionID,
1062
+ "turn_id": turnID,
1063
+ "prompt": tc.invocation,
1064
+ }), nil, "hook", "agent-dispatch", "--harness=codex")
1065
+ if code != 0 {
1066
+ t.Fatalf("arm exit=%d stderr=%s", code, stderr)
1067
+ }
1068
+ for _, role := range tc.wantRoles {
1069
+ if !strings.Contains(out, role) {
1070
+ t.Fatalf("arm output omitted %s: %s", role, out)
1071
+ }
1072
+ }
1073
+
1074
+ stopOut, stopErr, stopCode := runDevritesIO(t, root, hookPayload(t, map[string]any{
1075
+ "hook_event_name": "Stop",
1076
+ "session_id": sessionID,
1077
+ "turn_id": turnID,
1078
+ "stop_hook_active": false,
1079
+ }), nil, "hook", "agent-dispatch", "--harness=codex")
1080
+ if stopCode != 0 {
1081
+ t.Fatalf("stop exit=%d stderr=%s", stopCode, stopErr)
1082
+ }
1083
+ if len(tc.wantRoles) == 0 {
1084
+ if strings.TrimSpace(stopOut) != "" {
1085
+ t.Fatalf("no-agent skill was blocked: %s", stopOut)
1086
+ }
1087
+ return
1088
+ }
1089
+ for _, role := range tc.wantRoles {
1090
+ if strings.Contains(stopOut, role) {
1091
+ return
1092
+ }
1093
+ }
1094
+ t.Fatalf("required skill roles did not block Stop: %s", stopOut)
1095
+ })
1096
+ }
1097
+ }
1098
+
1099
+ func TestCodexAgentDispatchRejectsInvalidInstalledSkillRequirements(t *testing.T) {
1100
+ root := newWorkspace(t)
1101
+ writeCodexSkillContract(t, root, "rite-vet", "root-inline-reviewer")
1102
+ out, stderr, code := runDevritesIO(t, root, hookPayload(t, map[string]any{
1103
+ "hook_event_name": "UserPromptSubmit",
1104
+ "session_id": "session-invalid-skill",
1105
+ "turn_id": "turn-invalid-skill",
1106
+ "prompt": "$rite-vet",
1107
+ }), nil, "hook", "agent-dispatch", "--harness=codex")
1108
+ if code != 0 {
1109
+ t.Fatalf("invalid metadata exit=%d stderr=%s", code, stderr)
1110
+ }
1111
+ var decision struct {
1112
+ Decision string `json:"decision"`
1113
+ Reason string `json:"reason"`
1114
+ }
1115
+ if err := json.Unmarshal([]byte(strings.TrimSpace(out)), &decision); err != nil {
1116
+ t.Fatalf("invalid metadata response: %v\n%s", err, out)
1117
+ }
1118
+ if decision.Decision != "block" || !strings.Contains(decision.Reason, "invalid required agent role") {
1119
+ t.Fatalf("invalid skill metadata did not fail closed: %#v", decision)
1120
+ }
1121
+ stopOut, stopErr, stopCode := runDevritesIO(t, root, hookPayload(t, map[string]any{
1122
+ "hook_event_name": "Stop",
1123
+ "session_id": "session-invalid-skill",
1124
+ "turn_id": "turn-invalid-skill",
1125
+ }), nil, "hook", "agent-dispatch", "--harness=codex")
1126
+ if stopCode != 0 || !strings.Contains(stopOut, "required-agent-roles contract is invalid") {
1127
+ t.Fatalf("invalid skill metadata did not remain fail closed at Stop: exit=%d out=%s stderr=%s", stopCode, stopOut, stopErr)
1128
+ }
1129
+ }
1130
+
1131
+ func TestCodexAgentDispatchDoesNotArmSkillNamesInsideURLs(t *testing.T) {
1132
+ root := newWorkspace(t)
1133
+ writeCodexSkillContract(t, root, "rite-vet", "devrites-plan-reviewer")
1134
+ sessionID, turnID := "session-skill-url", "turn-skill-url"
1135
+ runDevritesIO(t, root, hookPayload(t, map[string]any{
1136
+ "hook_event_name": "UserPromptSubmit",
1137
+ "session_id": sessionID,
1138
+ "turn_id": turnID,
1139
+ "prompt": "Read https://example.test/rite-vet before answering.",
1140
+ }), nil, "hook", "agent-dispatch", "--harness=codex")
1141
+ out, stderr, code := runDevritesIO(t, root, hookPayload(t, map[string]any{
1142
+ "hook_event_name": "Stop",
1143
+ "session_id": sessionID,
1144
+ "turn_id": turnID,
1145
+ }), nil, "hook", "agent-dispatch", "--harness=codex")
1146
+ if code != 0 || strings.TrimSpace(out) != "" {
1147
+ t.Fatalf("URL path armed a skill: exit=%d out=%s stderr=%s", code, out, stderr)
1148
+ }
1149
+ }
1150
+
1151
+ func TestCodexAgentDispatchDoesNotArmBareAgentContractReference(t *testing.T) {
1152
+ root := newWorkspace(t)
1153
+ writeCodexAgentContract(t, root, "devrites-security-auditor")
1154
+ sessionID, turnID := "session-agent-reference", "turn-agent-reference"
1155
+ runDevritesIO(t, root, hookPayload(t, map[string]any{
1156
+ "hook_event_name": "UserPromptSubmit",
1157
+ "session_id": sessionID,
1158
+ "turn_id": turnID,
1159
+ "prompt": "Explain .codex/agents/devrites-security-auditor.toml without running it.",
1160
+ }), nil, "hook", "agent-dispatch", "--harness=codex")
1161
+ out, stderr, code := runDevritesIO(t, root, hookPayload(t, map[string]any{
1162
+ "hook_event_name": "Stop",
1163
+ "session_id": sessionID,
1164
+ "turn_id": turnID,
1165
+ }), nil, "hook", "agent-dispatch", "--harness=codex")
1166
+ if code != 0 || strings.TrimSpace(out) != "" {
1167
+ t.Fatalf("bare agent reference armed dispatch: exit=%d out=%s stderr=%s", code, out, stderr)
1168
+ }
1169
+ }
1170
+
1171
+ func TestCodexAgentDispatchBlocksFalseWaitAndStop(t *testing.T) {
1172
+ root := newWorkspace(t)
1173
+ writeCodexSkillContract(t, root, "devrites-audit", "devrites-security-auditor")
1174
+ sessionID, turnID := "session-false-wait", "turn-false-wait"
1175
+ prompt := hookPayload(t, map[string]any{
1176
+ "hook_event_name": "UserPromptSubmit",
1177
+ "session_id": sessionID,
1178
+ "turn_id": turnID,
1179
+ "prompt": "$devrites-audit Call spawn_agent and tell it to read .codex/agents/devrites-security-auditor.toml.",
1180
+ })
1181
+ if _, stderr, code := runDevritesIO(t, root, prompt, nil,
1182
+ "hook", "agent-dispatch", "--harness=codex"); code != 0 {
1183
+ t.Fatalf("arm exit=%d stderr=%s", code, stderr)
1184
+ }
1185
+
1186
+ wait := hookPayload(t, map[string]any{
1187
+ "hook_event_name": "PreToolUse",
1188
+ "session_id": sessionID,
1189
+ "turn_id": turnID,
1190
+ "tool_name": "wait",
1191
+ "tool_input": map[string]any{"receiver_thread_ids": []string{}},
1192
+ })
1193
+ out, stderr, code := runDevritesIO(t, root, wait, nil,
1194
+ "hook", "agent-dispatch", "--harness=codex")
1195
+ if code != 0 {
1196
+ t.Fatalf("wait exit=%d stderr=%s", code, stderr)
1197
+ }
1198
+ if decision, reason := parsePermissionDecision(t, out); decision != "deny" ||
1199
+ !strings.Contains(reason, "spawn_agent") {
1200
+ t.Fatalf("false wait not denied: decision=%q reason=%q out=%s", decision, reason, out)
1201
+ }
1202
+
1203
+ stop := hookPayload(t, map[string]any{
1204
+ "hook_event_name": "Stop",
1205
+ "session_id": sessionID,
1206
+ "turn_id": turnID,
1207
+ "stop_hook_active": false,
1208
+ })
1209
+ out, stderr, code = runDevritesIO(t, root, stop, nil,
1210
+ "hook", "agent-dispatch", "--harness=codex")
1211
+ if code != 0 {
1212
+ t.Fatalf("stop exit=%d stderr=%s", code, stderr)
1213
+ }
1214
+ var stopDecision struct {
1215
+ Decision string `json:"decision"`
1216
+ Reason string `json:"reason"`
1217
+ }
1218
+ if err := json.Unmarshal([]byte(strings.TrimSpace(out)), &stopDecision); err != nil {
1219
+ t.Fatalf("invalid Stop response: %v\n%s", err, out)
1220
+ }
1221
+ if stopDecision.Decision != "block" || !strings.Contains(stopDecision.Reason, "spawn_agent") {
1222
+ t.Fatalf("false completion not blocked: %#v", stopDecision)
1223
+ }
1224
+
1225
+ stop = hookPayload(t, map[string]any{
1226
+ "hook_event_name": "Stop",
1227
+ "session_id": sessionID,
1228
+ "turn_id": turnID,
1229
+ "stop_hook_active": true,
1230
+ })
1231
+ out, stderr, code = runDevritesIO(t, root, stop, nil,
1232
+ "hook", "agent-dispatch", "--harness=codex")
1233
+ if code != 0 {
1234
+ t.Fatalf("second stop exit=%d stderr=%s", code, stderr)
1235
+ }
1236
+ var terminal struct {
1237
+ Continue bool `json:"continue"`
1238
+ StopReason string `json:"stopReason"`
1239
+ }
1240
+ if err := json.Unmarshal([]byte(strings.TrimSpace(out)), &terminal); err != nil {
1241
+ t.Fatalf("invalid terminal Stop response: %v\n%s", err, out)
1242
+ }
1243
+ if terminal.Continue || !strings.Contains(terminal.StopReason, "spawn_agent") {
1244
+ t.Fatalf("second false completion was not stopped: %#v", terminal)
1245
+ }
1246
+ }
1247
+
1248
+ func TestCodexAgentDispatchConfirmsGenericRoleAndResult(t *testing.T) {
1249
+ root := newWorkspace(t)
1250
+ sessionID, turnID := "session-complete", "turn-complete"
1251
+ role := "devrites-security-auditor"
1252
+ writeCodexAgentContract(t, root, role)
1253
+ writeCodexSkillContract(t, root, "devrites-audit", role)
1254
+
1255
+ prompt := hookPayload(t, map[string]any{
1256
+ "hook_event_name": "UserPromptSubmit",
1257
+ "session_id": sessionID,
1258
+ "turn_id": turnID,
1259
+ "prompt": "$devrites-audit Call spawn_agent and tell it to read .codex/agents/" + role + ".toml.",
1260
+ })
1261
+ runDevritesIO(t, root, prompt, nil, "hook", "agent-dispatch", "--harness=codex")
1262
+
1263
+ spawn := hookPayload(t, map[string]any{
1264
+ "hook_event_name": "PreToolUse",
1265
+ "session_id": sessionID,
1266
+ "turn_id": turnID,
1267
+ "tool_name": "spawn_agent",
1268
+ "tool_use_id": "spawn-1",
1269
+ "tool_input": map[string]any{
1270
+ "agent_type": "explorer",
1271
+ "fork_turns": "none",
1272
+ "message": "Read .codex/agents/" + role + ".toml and return the requested report.",
1273
+ },
1274
+ })
1275
+ out, stderr, code := runDevritesIO(t, root, spawn, nil,
1276
+ "hook", "agent-dispatch", "--harness=codex")
1277
+ if code != 0 {
1278
+ t.Fatalf("spawn exit=%d out=%s stderr=%s", code, out, stderr)
1279
+ }
1280
+ var rewrite struct {
1281
+ HookSpecificOutput struct {
1282
+ PermissionDecision string `json:"permissionDecision"`
1283
+ UpdatedInput struct {
1284
+ Message string `json:"message"`
1285
+ } `json:"updatedInput"`
1286
+ } `json:"hookSpecificOutput"`
1287
+ }
1288
+ if err := json.Unmarshal([]byte(strings.TrimSpace(out)), &rewrite); err != nil {
1289
+ t.Fatalf("invalid spawn rewrite: %v\n%s", err, out)
1290
+ }
1291
+ if rewrite.HookSpecificOutput.PermissionDecision != "allow" ||
1292
+ !strings.Contains(rewrite.HookSpecificOutput.UpdatedInput.Message, "ROLE-CONTRACT:"+role) ||
1293
+ !strings.Contains(rewrite.HookSpecificOutput.UpdatedInput.Message, "return the requested report") {
1294
+ t.Fatalf("generic spawn did not receive developer_instructions: %#v", rewrite)
1295
+ }
1296
+
1297
+ start := hookPayload(t, map[string]any{
1298
+ "hook_event_name": "SubagentStart",
1299
+ "session_id": sessionID,
1300
+ "turn_id": "child-turn",
1301
+ "agent_id": "agent-1",
1302
+ "agent_type": "explorer",
1303
+ })
1304
+ out, stderr, code = runDevritesIO(t, root, start, nil,
1305
+ "hook", "subagent-orient", "--harness=codex")
1306
+ if code != 0 {
1307
+ t.Fatalf("start exit=%d stderr=%s", code, stderr)
1308
+ }
1309
+ var startEnvelope struct {
1310
+ HookSpecificOutput struct {
1311
+ AdditionalContext string `json:"additionalContext"`
1312
+ } `json:"hookSpecificOutput"`
1313
+ }
1314
+ if err := json.Unmarshal([]byte(strings.TrimSpace(out)), &startEnvelope); err != nil {
1315
+ t.Fatalf("invalid start response: %v\n%s", err, out)
1316
+ }
1317
+ if !strings.Contains(startEnvelope.HookSpecificOutput.AdditionalContext,
1318
+ ".codex/agents/"+role+".toml") {
1319
+ t.Fatalf("generic child did not receive role contract: %s", out)
1320
+ }
1321
+
1322
+ stopAgent := hookPayload(t, map[string]any{
1323
+ "hook_event_name": "SubagentStop",
1324
+ "session_id": sessionID,
1325
+ "turn_id": "child-turn",
1326
+ "agent_id": "agent-1",
1327
+ "agent_type": "explorer",
1328
+ "last_assistant_message": "agent-result/v1 complete",
1329
+ })
1330
+ if out, stderr, code := runDevritesIO(t, root, stopAgent, nil,
1331
+ "hook", "agent-dispatch", "--harness=codex"); code != 0 || strings.TrimSpace(out) != "" {
1332
+ t.Fatalf("subagent stop exit=%d out=%s stderr=%s", code, out, stderr)
1333
+ }
1334
+
1335
+ stop := hookPayload(t, map[string]any{
1336
+ "hook_event_name": "Stop",
1337
+ "session_id": sessionID,
1338
+ "turn_id": turnID,
1339
+ "stop_hook_active": false,
1340
+ })
1341
+ if out, stderr, code := runDevritesIO(t, root, stop, nil,
1342
+ "hook", "agent-dispatch", "--harness=codex"); code != 0 || strings.TrimSpace(out) != "" {
1343
+ t.Fatalf("resolved stop exit=%d out=%s stderr=%s", code, out, stderr)
1344
+ }
1345
+ }
1346
+
1347
+ func TestCodexAgentDispatchConfirmsDurableV2NamedRole(t *testing.T) {
1348
+ root := newWorkspace(t)
1349
+ codeHome := t.TempDir()
1350
+ sessionID, turnID := "session-v2-named", "turn-v2-named"
1351
+ role := "devrites-security-auditor"
1352
+ writeCodexAgentContract(t, root, role)
1353
+ writeCodexSkillContract(t, root, "devrites-audit", role)
1354
+
1355
+ runDevritesIO(t, root, hookPayload(t, map[string]any{
1356
+ "hook_event_name": "UserPromptSubmit",
1357
+ "session_id": sessionID,
1358
+ "turn_id": turnID,
1359
+ "prompt": "$devrites-audit Call spawn_agent and tell it to read .codex/agents/" + role + ".toml.",
1360
+ }), nil, "hook", "agent-dispatch", "--harness=codex")
1361
+
1362
+ writeCodexV2Rollouts(
1363
+ t, codeHome, filepath.Dir(root), sessionID, turnID, role,
1364
+ "devrites_security_auditor_named", true, true, true,
1365
+ )
1366
+ stop := hookPayload(t, map[string]any{
1367
+ "hook_event_name": "Stop",
1368
+ "session_id": sessionID,
1369
+ "turn_id": turnID,
1370
+ "stop_hook_active": false,
1371
+ })
1372
+ if out, stderr, code := runDevritesIO(t, root, stop,
1373
+ []string{"CODEX_HOME=" + codeHome},
1374
+ "hook", "agent-dispatch", "--harness=codex"); code != 0 || strings.TrimSpace(out) != "" {
1375
+ t.Fatalf("durable v2 stop exit=%d out=%s stderr=%s", code, out, stderr)
1376
+ }
1377
+ }
1378
+
1379
+ func TestCodexAgentDispatchRejectsIncompleteDurableV2(t *testing.T) {
1380
+ for _, tc := range []struct {
1381
+ name string
1382
+ role string
1383
+ includeWait bool
1384
+ includeRules bool
1385
+ includeResult bool
1386
+ }{
1387
+ {name: "generic-role", role: "explorer", includeWait: true, includeRules: true, includeResult: true},
1388
+ {name: "missing-wait", role: "devrites-security-auditor", includeRules: true, includeResult: true},
1389
+ {name: "missing-rules", role: "devrites-security-auditor", includeWait: true, includeResult: true},
1390
+ {name: "missing-result", role: "devrites-security-auditor", includeWait: true, includeRules: true},
1391
+ } {
1392
+ t.Run(tc.name, func(t *testing.T) {
1393
+ root := newWorkspace(t)
1394
+ codeHome := t.TempDir()
1395
+ sessionID, turnID := "session-"+tc.name, "turn-"+tc.name
1396
+ role := "devrites-security-auditor"
1397
+ writeCodexAgentContract(t, root, role)
1398
+ writeCodexSkillContract(t, root, "devrites-audit", role)
1399
+ runDevritesIO(t, root, hookPayload(t, map[string]any{
1400
+ "hook_event_name": "UserPromptSubmit",
1401
+ "session_id": sessionID,
1402
+ "turn_id": turnID,
1403
+ "prompt": "$devrites-audit Read .codex/agents/" + role + ".toml.",
1404
+ }), nil, "hook", "agent-dispatch", "--harness=codex")
1405
+ writeCodexV2Rollouts(
1406
+ t, codeHome, filepath.Dir(root), sessionID, turnID, tc.role,
1407
+ "devrites_security_auditor_named", tc.includeWait, tc.includeRules, tc.includeResult,
1408
+ )
1409
+ stop := hookPayload(t, map[string]any{
1410
+ "hook_event_name": "Stop",
1411
+ "session_id": sessionID,
1412
+ "turn_id": turnID,
1413
+ })
1414
+ out, stderr, code := runDevritesIO(t, root, stop,
1415
+ []string{"CODEX_HOME=" + codeHome},
1416
+ "hook", "agent-dispatch", "--harness=codex")
1417
+ if code != 0 {
1418
+ t.Fatalf("stop exit=%d stderr=%s", code, stderr)
1419
+ }
1420
+ var decision struct {
1421
+ Decision string `json:"decision"`
1422
+ }
1423
+ if err := json.Unmarshal([]byte(strings.TrimSpace(out)), &decision); err != nil {
1424
+ t.Fatalf("invalid Stop response: %v\n%s", err, out)
1425
+ }
1426
+ if decision.Decision != "block" {
1427
+ t.Fatalf("incomplete durable V2 dispatch passed: %s", out)
1428
+ }
1429
+ })
1430
+ }
1431
+ }
1432
+
1433
+ func TestCodexAgentDispatchGatesReconcileOnBoundWrightResult(t *testing.T) {
1434
+ root := newWorkspace(t)
1435
+ writeActive(t, root, "auth-tokens")
1436
+ workspace := filepath.Join(root, "features", "auth-tokens")
1437
+ for _, name := range []string{".reconcile-base", ".reconcile-allowlist", ".reconcile-devrites"} {
1438
+ if err := os.WriteFile(filepath.Join(workspace, name), []byte(name+"\n"), 0o600); err != nil {
1439
+ t.Fatal(err)
1440
+ }
1441
+ }
1442
+ sessionID, turnID := "session-wright", "turn-wright"
1443
+ writeCodexAgentContract(t, root, "devrites-slice-wright")
1444
+ writeCodexSkillContract(t, root, "rite-build", "devrites-slice-wright")
1445
+ runDevritesIO(t, root, hookPayload(t, map[string]any{
1446
+ "hook_event_name": "UserPromptSubmit",
1447
+ "session_id": sessionID,
1448
+ "turn_id": turnID,
1449
+ "prompt": "$rite-build",
1450
+ }), nil, "hook", "agent-dispatch", "--harness=codex")
1451
+
1452
+ reconcile := hookPayload(t, map[string]any{
1453
+ "hook_event_name": "PreToolUse",
1454
+ "session_id": sessionID,
1455
+ "turn_id": turnID,
1456
+ "tool_name": "Bash",
1457
+ "tool_input": map[string]any{"command": "rtk devrites-engine reconcile close"},
1458
+ })
1459
+ out, _, _ := runDevritesIO(t, root, reconcile, nil,
1460
+ "hook", "agent-dispatch", "--harness=codex")
1461
+ if decision, reason := parsePermissionDecision(t, out); decision != "deny" ||
1462
+ !strings.Contains(reason, "confirmed") {
1463
+ t.Fatalf("premature reconcile not denied: decision=%q reason=%q", decision, reason)
1464
+ }
1465
+
1466
+ for _, payload := range []map[string]any{
1467
+ {
1468
+ "hook_event_name": "PreToolUse",
1469
+ "session_id": sessionID,
1470
+ "turn_id": turnID,
1471
+ "tool_name": "spawn_agent",
1472
+ "tool_use_id": "spawn-wright",
1473
+ "tool_input": map[string]any{
1474
+ "agent_type": "worker",
1475
+ "fork_turns": "none",
1476
+ "message": "Read .codex/agents/devrites-slice-wright.toml.",
1477
+ },
1478
+ },
1479
+ {
1480
+ "hook_event_name": "SubagentStart",
1481
+ "session_id": sessionID,
1482
+ "turn_id": "child-wright",
1483
+ "agent_id": "agent-wright",
1484
+ "agent_type": "worker",
1485
+ },
1486
+ {
1487
+ "hook_event_name": "SubagentStop",
1488
+ "session_id": sessionID,
1489
+ "turn_id": "child-wright",
1490
+ "agent_id": "agent-wright",
1491
+ "agent_type": "worker",
1492
+ "last_assistant_message": "agent-result/v1 complete",
1493
+ },
1494
+ {
1495
+ "hook_event_name": "PreToolUse",
1496
+ "session_id": sessionID,
1497
+ "turn_id": turnID,
1498
+ "tool_name": "wait",
1499
+ "tool_input": map[string]any{"ids": []string{"agent-wright"}},
1500
+ },
1501
+ } {
1502
+ hook := "agent-dispatch"
1503
+ if payload["hook_event_name"] == "SubagentStart" {
1504
+ hook = "subagent-orient"
1505
+ }
1506
+ if _, stderr, code := runDevritesIO(t, root, hookPayload(t, payload), nil,
1507
+ "hook", hook, "--harness=codex"); code != 0 {
1508
+ t.Fatalf("%s exit=%d stderr=%s", hook, code, stderr)
1509
+ }
1510
+ }
1511
+
1512
+ if out, stderr, code := runDevritesIO(t, root, reconcile, nil,
1513
+ "hook", "agent-dispatch", "--harness=codex"); code != 0 || strings.TrimSpace(out) != "" {
1514
+ t.Fatalf("completed wright reconcile exit=%d out=%s stderr=%s", code, out, stderr)
1515
+ }
1516
+ }
1517
+
1518
+ func TestCodexAgentDispatchBindsDurableV2WrightToReconcileWindow(t *testing.T) {
1519
+ for _, tc := range []struct {
1520
+ name string
1521
+ changeWindow bool
1522
+ wantPermitted bool
1523
+ }{
1524
+ {name: "same-window", wantPermitted: true},
1525
+ {name: "changed-window", changeWindow: true},
1526
+ } {
1527
+ t.Run(tc.name, func(t *testing.T) {
1528
+ root := newWorkspace(t)
1529
+ codeHome := t.TempDir()
1530
+ writeActive(t, root, "auth-tokens")
1531
+ workspace := filepath.Join(root, "features", "auth-tokens")
1532
+ for _, name := range []string{".reconcile-base", ".reconcile-allowlist", ".reconcile-devrites"} {
1533
+ if err := os.WriteFile(filepath.Join(workspace, name), []byte(name+"\n"), 0o600); err != nil {
1534
+ t.Fatal(err)
1535
+ }
1536
+ }
1537
+ sessionID, turnID := "session-v2-wright-"+tc.name, "turn-v2-wright-"+tc.name
1538
+ role := "devrites-slice-wright"
1539
+ writeCodexAgentContract(t, root, role)
1540
+ writeCodexSkillContract(t, root, "rite-build", role)
1541
+ runDevritesIO(t, root, hookPayload(t, map[string]any{
1542
+ "hook_event_name": "UserPromptSubmit",
1543
+ "session_id": sessionID,
1544
+ "turn_id": turnID,
1545
+ "prompt": "$rite-build",
1546
+ }), nil, "hook", "agent-dispatch", "--harness=codex")
1547
+ writeCodexV2Rollouts(
1548
+ t, codeHome, filepath.Dir(root), sessionID, turnID, role,
1549
+ "devrites_slice_wright_named", true, true, true,
1550
+ )
1551
+ if tc.changeWindow {
1552
+ future := time.Now().Add(time.Minute)
1553
+ if err := os.Chtimes(
1554
+ filepath.Join(workspace, ".reconcile-devrites"), future, future,
1555
+ ); err != nil {
1556
+ t.Fatal(err)
1557
+ }
1558
+ }
1559
+ reconcile := hookPayload(t, map[string]any{
1560
+ "hook_event_name": "PreToolUse",
1561
+ "session_id": sessionID,
1562
+ "turn_id": turnID,
1563
+ "tool_name": "Bash",
1564
+ "tool_input": map[string]any{"command": "rtk devrites-engine reconcile close"},
1565
+ })
1566
+ out, stderr, code := runDevritesIO(t, root, reconcile,
1567
+ []string{"CODEX_HOME=" + codeHome},
1568
+ "hook", "agent-dispatch", "--harness=codex")
1569
+ if code != 0 {
1570
+ t.Fatalf("reconcile exit=%d stderr=%s", code, stderr)
1571
+ }
1572
+ if tc.wantPermitted && strings.TrimSpace(out) != "" {
1573
+ t.Fatalf("same-window V2 wright was rejected: %s", out)
1574
+ }
1575
+ if !tc.wantPermitted {
1576
+ if decision, _ := parsePermissionDecision(t, out); decision != "deny" {
1577
+ t.Fatalf("changed-window V2 wright was accepted: %s", out)
1578
+ }
1579
+ }
1580
+ })
1581
+ }
1582
+ }
1583
+
1584
+ func TestCodexAgentDispatchRejectsWrongGenericRole(t *testing.T) {
1585
+ root := newWorkspace(t)
1586
+ writeCodexAgentContract(t, root, "devrites-security-auditor")
1587
+ spawn := hookPayload(t, map[string]any{
1588
+ "hook_event_name": "PreToolUse",
1589
+ "session_id": "session-role",
1590
+ "turn_id": "turn-role",
1591
+ "tool_name": "spawn_agent",
1592
+ "tool_use_id": "spawn-role",
1593
+ "tool_input": map[string]any{
1594
+ "agent_type": "worker",
1595
+ "fork_turns": "none",
1596
+ "message": "Read .codex/agents/devrites-security-auditor.toml.",
1597
+ },
1598
+ })
1599
+ out, stderr, code := runDevritesIO(t, root, spawn, nil,
1600
+ "hook", "agent-dispatch", "--harness=codex")
1601
+ if code != 0 {
1602
+ t.Fatalf("exit=%d stderr=%s", code, stderr)
1603
+ }
1604
+ if decision, reason := parsePermissionDecision(t, out); decision != "deny" ||
1605
+ !strings.Contains(reason, "devrites-slice-wright") {
1606
+ t.Fatalf("worker role mismatch not denied: decision=%q reason=%q", decision, reason)
1607
+ }
1608
+ }