devrites 3.2.2 → 3.2.4

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 +14 -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 +877 -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 +15 -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 +15 -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 +15 -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,747 @@ 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" ||
1222
+ !strings.Contains(stopDecision.Reason, "spawn_agent") ||
1223
+ !strings.Contains(stopDecision.Reason, "visible tool schema") ||
1224
+ !strings.Contains(stopDecision.Reason, "send agent_type anyway") {
1225
+ t.Fatalf("false completion not blocked: %#v", stopDecision)
1226
+ }
1227
+
1228
+ stop = hookPayload(t, map[string]any{
1229
+ "hook_event_name": "Stop",
1230
+ "session_id": sessionID,
1231
+ "turn_id": turnID,
1232
+ "stop_hook_active": true,
1233
+ })
1234
+ out, stderr, code = runDevritesIO(t, root, stop, nil,
1235
+ "hook", "agent-dispatch", "--harness=codex")
1236
+ if code != 0 {
1237
+ t.Fatalf("second stop exit=%d stderr=%s", code, stderr)
1238
+ }
1239
+ var terminal struct {
1240
+ Continue bool `json:"continue"`
1241
+ StopReason string `json:"stopReason"`
1242
+ }
1243
+ if err := json.Unmarshal([]byte(strings.TrimSpace(out)), &terminal); err != nil {
1244
+ t.Fatalf("invalid terminal Stop response: %v\n%s", err, out)
1245
+ }
1246
+ if terminal.Continue || !strings.Contains(terminal.StopReason, "spawn_agent") {
1247
+ t.Fatalf("second false completion was not stopped: %#v", terminal)
1248
+ }
1249
+ }
1250
+
1251
+ func TestCodexAgentDispatchConfirmsGenericRoleAndResult(t *testing.T) {
1252
+ root := newWorkspace(t)
1253
+ sessionID, turnID := "session-complete", "turn-complete"
1254
+ role := "devrites-security-auditor"
1255
+ writeCodexAgentContract(t, root, role)
1256
+ writeCodexSkillContract(t, root, "devrites-audit", role)
1257
+
1258
+ prompt := hookPayload(t, map[string]any{
1259
+ "hook_event_name": "UserPromptSubmit",
1260
+ "session_id": sessionID,
1261
+ "turn_id": turnID,
1262
+ "prompt": "$devrites-audit Call spawn_agent and tell it to read .codex/agents/" + role + ".toml.",
1263
+ })
1264
+ runDevritesIO(t, root, prompt, nil, "hook", "agent-dispatch", "--harness=codex")
1265
+
1266
+ spawn := hookPayload(t, map[string]any{
1267
+ "hook_event_name": "PreToolUse",
1268
+ "session_id": sessionID,
1269
+ "turn_id": turnID,
1270
+ "tool_name": "spawn_agent",
1271
+ "tool_use_id": "spawn-1",
1272
+ "tool_input": map[string]any{
1273
+ "agent_type": "explorer",
1274
+ "fork_turns": "none",
1275
+ "message": "Read .codex/agents/" + role + ".toml and return the requested report.",
1276
+ },
1277
+ })
1278
+ out, stderr, code := runDevritesIO(t, root, spawn, nil,
1279
+ "hook", "agent-dispatch", "--harness=codex")
1280
+ if code != 0 {
1281
+ t.Fatalf("spawn exit=%d out=%s stderr=%s", code, out, stderr)
1282
+ }
1283
+ var rewrite struct {
1284
+ HookSpecificOutput struct {
1285
+ PermissionDecision string `json:"permissionDecision"`
1286
+ UpdatedInput struct {
1287
+ Message string `json:"message"`
1288
+ } `json:"updatedInput"`
1289
+ } `json:"hookSpecificOutput"`
1290
+ }
1291
+ if err := json.Unmarshal([]byte(strings.TrimSpace(out)), &rewrite); err != nil {
1292
+ t.Fatalf("invalid spawn rewrite: %v\n%s", err, out)
1293
+ }
1294
+ if rewrite.HookSpecificOutput.PermissionDecision != "allow" ||
1295
+ !strings.Contains(rewrite.HookSpecificOutput.UpdatedInput.Message, "ROLE-CONTRACT:"+role) ||
1296
+ !strings.Contains(rewrite.HookSpecificOutput.UpdatedInput.Message, "return the requested report") {
1297
+ t.Fatalf("generic spawn did not receive developer_instructions: %#v", rewrite)
1298
+ }
1299
+
1300
+ start := hookPayload(t, map[string]any{
1301
+ "hook_event_name": "SubagentStart",
1302
+ "session_id": sessionID,
1303
+ "turn_id": "child-turn",
1304
+ "agent_id": "agent-1",
1305
+ "agent_type": "explorer",
1306
+ })
1307
+ out, stderr, code = runDevritesIO(t, root, start, nil,
1308
+ "hook", "subagent-orient", "--harness=codex")
1309
+ if code != 0 {
1310
+ t.Fatalf("start exit=%d stderr=%s", code, stderr)
1311
+ }
1312
+ var startEnvelope struct {
1313
+ HookSpecificOutput struct {
1314
+ AdditionalContext string `json:"additionalContext"`
1315
+ } `json:"hookSpecificOutput"`
1316
+ }
1317
+ if err := json.Unmarshal([]byte(strings.TrimSpace(out)), &startEnvelope); err != nil {
1318
+ t.Fatalf("invalid start response: %v\n%s", err, out)
1319
+ }
1320
+ if !strings.Contains(startEnvelope.HookSpecificOutput.AdditionalContext,
1321
+ ".codex/agents/"+role+".toml") {
1322
+ t.Fatalf("generic child did not receive role contract: %s", out)
1323
+ }
1324
+
1325
+ stopAgent := hookPayload(t, map[string]any{
1326
+ "hook_event_name": "SubagentStop",
1327
+ "session_id": sessionID,
1328
+ "turn_id": "child-turn",
1329
+ "agent_id": "agent-1",
1330
+ "agent_type": "explorer",
1331
+ "last_assistant_message": "agent-result/v1 complete",
1332
+ })
1333
+ if out, stderr, code := runDevritesIO(t, root, stopAgent, nil,
1334
+ "hook", "agent-dispatch", "--harness=codex"); code != 0 || strings.TrimSpace(out) != "" {
1335
+ t.Fatalf("subagent stop exit=%d out=%s stderr=%s", code, out, stderr)
1336
+ }
1337
+
1338
+ stop := hookPayload(t, map[string]any{
1339
+ "hook_event_name": "Stop",
1340
+ "session_id": sessionID,
1341
+ "turn_id": turnID,
1342
+ "stop_hook_active": false,
1343
+ })
1344
+ if out, stderr, code := runDevritesIO(t, root, stop, nil,
1345
+ "hook", "agent-dispatch", "--harness=codex"); code != 0 || strings.TrimSpace(out) != "" {
1346
+ t.Fatalf("resolved stop exit=%d out=%s stderr=%s", code, out, stderr)
1347
+ }
1348
+ }
1349
+
1350
+ func TestCodexAgentDispatchConfirmsDurableV2NamedRole(t *testing.T) {
1351
+ root := newWorkspace(t)
1352
+ codeHome := t.TempDir()
1353
+ sessionID, turnID := "session-v2-named", "turn-v2-named"
1354
+ role := "devrites-security-auditor"
1355
+ writeCodexAgentContract(t, root, role)
1356
+ writeCodexSkillContract(t, root, "devrites-audit", role)
1357
+
1358
+ runDevritesIO(t, root, hookPayload(t, map[string]any{
1359
+ "hook_event_name": "UserPromptSubmit",
1360
+ "session_id": sessionID,
1361
+ "turn_id": turnID,
1362
+ "prompt": "$devrites-audit Call spawn_agent and tell it to read .codex/agents/" + role + ".toml.",
1363
+ }), nil, "hook", "agent-dispatch", "--harness=codex")
1364
+
1365
+ writeCodexV2Rollouts(
1366
+ t, codeHome, filepath.Dir(root), sessionID, turnID, role,
1367
+ "devrites_security_auditor_named", true, true, true,
1368
+ )
1369
+ stop := hookPayload(t, map[string]any{
1370
+ "hook_event_name": "Stop",
1371
+ "session_id": sessionID,
1372
+ "turn_id": turnID,
1373
+ "stop_hook_active": false,
1374
+ })
1375
+ if out, stderr, code := runDevritesIO(t, root, stop,
1376
+ []string{"CODEX_HOME=" + codeHome},
1377
+ "hook", "agent-dispatch", "--harness=codex"); code != 0 || strings.TrimSpace(out) != "" {
1378
+ t.Fatalf("durable v2 stop exit=%d out=%s stderr=%s", code, out, stderr)
1379
+ }
1380
+ }
1381
+
1382
+ func TestCodexAgentDispatchRejectsIncompleteDurableV2(t *testing.T) {
1383
+ for _, tc := range []struct {
1384
+ name string
1385
+ role string
1386
+ includeWait bool
1387
+ includeRules bool
1388
+ includeResult bool
1389
+ }{
1390
+ {name: "generic-role", role: "explorer", includeWait: true, includeRules: true, includeResult: true},
1391
+ {name: "missing-wait", role: "devrites-security-auditor", includeRules: true, includeResult: true},
1392
+ {name: "missing-rules", role: "devrites-security-auditor", includeWait: true, includeResult: true},
1393
+ {name: "missing-result", role: "devrites-security-auditor", includeWait: true, includeRules: true},
1394
+ } {
1395
+ t.Run(tc.name, func(t *testing.T) {
1396
+ root := newWorkspace(t)
1397
+ codeHome := t.TempDir()
1398
+ sessionID, turnID := "session-"+tc.name, "turn-"+tc.name
1399
+ role := "devrites-security-auditor"
1400
+ writeCodexAgentContract(t, root, role)
1401
+ writeCodexSkillContract(t, root, "devrites-audit", role)
1402
+ runDevritesIO(t, root, hookPayload(t, map[string]any{
1403
+ "hook_event_name": "UserPromptSubmit",
1404
+ "session_id": sessionID,
1405
+ "turn_id": turnID,
1406
+ "prompt": "$devrites-audit Read .codex/agents/" + role + ".toml.",
1407
+ }), nil, "hook", "agent-dispatch", "--harness=codex")
1408
+ writeCodexV2Rollouts(
1409
+ t, codeHome, filepath.Dir(root), sessionID, turnID, tc.role,
1410
+ "devrites_security_auditor_named", tc.includeWait, tc.includeRules, tc.includeResult,
1411
+ )
1412
+ stop := hookPayload(t, map[string]any{
1413
+ "hook_event_name": "Stop",
1414
+ "session_id": sessionID,
1415
+ "turn_id": turnID,
1416
+ })
1417
+ out, stderr, code := runDevritesIO(t, root, stop,
1418
+ []string{"CODEX_HOME=" + codeHome},
1419
+ "hook", "agent-dispatch", "--harness=codex")
1420
+ if code != 0 {
1421
+ t.Fatalf("stop exit=%d stderr=%s", code, stderr)
1422
+ }
1423
+ var decision struct {
1424
+ Decision string `json:"decision"`
1425
+ }
1426
+ if err := json.Unmarshal([]byte(strings.TrimSpace(out)), &decision); err != nil {
1427
+ t.Fatalf("invalid Stop response: %v\n%s", err, out)
1428
+ }
1429
+ if decision.Decision != "block" {
1430
+ t.Fatalf("incomplete durable V2 dispatch passed: %s", out)
1431
+ }
1432
+ })
1433
+ }
1434
+ }
1435
+
1436
+ func TestCodexAgentDispatchGatesReconcileOnBoundWrightResult(t *testing.T) {
1437
+ root := newWorkspace(t)
1438
+ writeActive(t, root, "auth-tokens")
1439
+ workspace := filepath.Join(root, "features", "auth-tokens")
1440
+ for _, name := range []string{".reconcile-base", ".reconcile-allowlist", ".reconcile-devrites"} {
1441
+ if err := os.WriteFile(filepath.Join(workspace, name), []byte(name+"\n"), 0o600); err != nil {
1442
+ t.Fatal(err)
1443
+ }
1444
+ }
1445
+ sessionID, turnID := "session-wright", "turn-wright"
1446
+ writeCodexAgentContract(t, root, "devrites-slice-wright")
1447
+ writeCodexSkillContract(t, root, "rite-build", "devrites-slice-wright")
1448
+ runDevritesIO(t, root, hookPayload(t, map[string]any{
1449
+ "hook_event_name": "UserPromptSubmit",
1450
+ "session_id": sessionID,
1451
+ "turn_id": turnID,
1452
+ "prompt": "$rite-build",
1453
+ }), nil, "hook", "agent-dispatch", "--harness=codex")
1454
+
1455
+ reconcile := hookPayload(t, map[string]any{
1456
+ "hook_event_name": "PreToolUse",
1457
+ "session_id": sessionID,
1458
+ "turn_id": turnID,
1459
+ "tool_name": "Bash",
1460
+ "tool_input": map[string]any{"command": "rtk devrites-engine reconcile close"},
1461
+ })
1462
+ out, _, _ := runDevritesIO(t, root, reconcile, nil,
1463
+ "hook", "agent-dispatch", "--harness=codex")
1464
+ if decision, reason := parsePermissionDecision(t, out); decision != "deny" ||
1465
+ !strings.Contains(reason, "confirmed") {
1466
+ t.Fatalf("premature reconcile not denied: decision=%q reason=%q", decision, reason)
1467
+ }
1468
+
1469
+ for _, payload := range []map[string]any{
1470
+ {
1471
+ "hook_event_name": "PreToolUse",
1472
+ "session_id": sessionID,
1473
+ "turn_id": turnID,
1474
+ "tool_name": "spawn_agent",
1475
+ "tool_use_id": "spawn-wright",
1476
+ "tool_input": map[string]any{
1477
+ "agent_type": "worker",
1478
+ "fork_turns": "none",
1479
+ "message": "Read .codex/agents/devrites-slice-wright.toml.",
1480
+ },
1481
+ },
1482
+ {
1483
+ "hook_event_name": "SubagentStart",
1484
+ "session_id": sessionID,
1485
+ "turn_id": "child-wright",
1486
+ "agent_id": "agent-wright",
1487
+ "agent_type": "worker",
1488
+ },
1489
+ {
1490
+ "hook_event_name": "SubagentStop",
1491
+ "session_id": sessionID,
1492
+ "turn_id": "child-wright",
1493
+ "agent_id": "agent-wright",
1494
+ "agent_type": "worker",
1495
+ "last_assistant_message": "agent-result/v1 complete",
1496
+ },
1497
+ {
1498
+ "hook_event_name": "PreToolUse",
1499
+ "session_id": sessionID,
1500
+ "turn_id": turnID,
1501
+ "tool_name": "wait",
1502
+ "tool_input": map[string]any{"ids": []string{"agent-wright"}},
1503
+ },
1504
+ } {
1505
+ hook := "agent-dispatch"
1506
+ if payload["hook_event_name"] == "SubagentStart" {
1507
+ hook = "subagent-orient"
1508
+ }
1509
+ if _, stderr, code := runDevritesIO(t, root, hookPayload(t, payload), nil,
1510
+ "hook", hook, "--harness=codex"); code != 0 {
1511
+ t.Fatalf("%s exit=%d stderr=%s", hook, code, stderr)
1512
+ }
1513
+ }
1514
+
1515
+ if out, stderr, code := runDevritesIO(t, root, reconcile, nil,
1516
+ "hook", "agent-dispatch", "--harness=codex"); code != 0 || strings.TrimSpace(out) != "" {
1517
+ t.Fatalf("completed wright reconcile exit=%d out=%s stderr=%s", code, out, stderr)
1518
+ }
1519
+ }
1520
+
1521
+ func TestCodexAgentDispatchBindsDurableV2WrightToReconcileWindow(t *testing.T) {
1522
+ for _, tc := range []struct {
1523
+ name string
1524
+ changeWindow bool
1525
+ wantPermitted bool
1526
+ }{
1527
+ {name: "same-window", wantPermitted: true},
1528
+ {name: "changed-window", changeWindow: true},
1529
+ } {
1530
+ t.Run(tc.name, func(t *testing.T) {
1531
+ root := newWorkspace(t)
1532
+ codeHome := t.TempDir()
1533
+ writeActive(t, root, "auth-tokens")
1534
+ workspace := filepath.Join(root, "features", "auth-tokens")
1535
+ for _, name := range []string{".reconcile-base", ".reconcile-allowlist", ".reconcile-devrites"} {
1536
+ if err := os.WriteFile(filepath.Join(workspace, name), []byte(name+"\n"), 0o600); err != nil {
1537
+ t.Fatal(err)
1538
+ }
1539
+ }
1540
+ sessionID, turnID := "session-v2-wright-"+tc.name, "turn-v2-wright-"+tc.name
1541
+ role := "devrites-slice-wright"
1542
+ writeCodexAgentContract(t, root, role)
1543
+ writeCodexSkillContract(t, root, "rite-build", role)
1544
+ runDevritesIO(t, root, hookPayload(t, map[string]any{
1545
+ "hook_event_name": "UserPromptSubmit",
1546
+ "session_id": sessionID,
1547
+ "turn_id": turnID,
1548
+ "prompt": "$rite-build",
1549
+ }), nil, "hook", "agent-dispatch", "--harness=codex")
1550
+ writeCodexV2Rollouts(
1551
+ t, codeHome, filepath.Dir(root), sessionID, turnID, role,
1552
+ "devrites_slice_wright_named", true, true, true,
1553
+ )
1554
+ if tc.changeWindow {
1555
+ future := time.Now().Add(time.Minute)
1556
+ if err := os.Chtimes(
1557
+ filepath.Join(workspace, ".reconcile-devrites"), future, future,
1558
+ ); err != nil {
1559
+ t.Fatal(err)
1560
+ }
1561
+ }
1562
+ reconcile := hookPayload(t, map[string]any{
1563
+ "hook_event_name": "PreToolUse",
1564
+ "session_id": sessionID,
1565
+ "turn_id": turnID,
1566
+ "tool_name": "Bash",
1567
+ "tool_input": map[string]any{"command": "rtk devrites-engine reconcile close"},
1568
+ })
1569
+ out, stderr, code := runDevritesIO(t, root, reconcile,
1570
+ []string{"CODEX_HOME=" + codeHome},
1571
+ "hook", "agent-dispatch", "--harness=codex")
1572
+ if code != 0 {
1573
+ t.Fatalf("reconcile exit=%d stderr=%s", code, stderr)
1574
+ }
1575
+ if tc.wantPermitted && strings.TrimSpace(out) != "" {
1576
+ t.Fatalf("same-window V2 wright was rejected: %s", out)
1577
+ }
1578
+ if !tc.wantPermitted {
1579
+ if decision, _ := parsePermissionDecision(t, out); decision != "deny" {
1580
+ t.Fatalf("changed-window V2 wright was accepted: %s", out)
1581
+ }
1582
+ }
1583
+ })
1584
+ }
1585
+ }
1586
+
1587
+ func TestCodexAgentDispatchRejectsWrongGenericRole(t *testing.T) {
1588
+ root := newWorkspace(t)
1589
+ writeCodexAgentContract(t, root, "devrites-security-auditor")
1590
+ spawn := hookPayload(t, map[string]any{
1591
+ "hook_event_name": "PreToolUse",
1592
+ "session_id": "session-role",
1593
+ "turn_id": "turn-role",
1594
+ "tool_name": "spawn_agent",
1595
+ "tool_use_id": "spawn-role",
1596
+ "tool_input": map[string]any{
1597
+ "agent_type": "worker",
1598
+ "fork_turns": "none",
1599
+ "message": "Read .codex/agents/devrites-security-auditor.toml.",
1600
+ },
1601
+ })
1602
+ out, stderr, code := runDevritesIO(t, root, spawn, nil,
1603
+ "hook", "agent-dispatch", "--harness=codex")
1604
+ if code != 0 {
1605
+ t.Fatalf("exit=%d stderr=%s", code, stderr)
1606
+ }
1607
+ if decision, reason := parsePermissionDecision(t, out); decision != "deny" ||
1608
+ !strings.Contains(reason, "devrites-slice-wright") {
1609
+ t.Fatalf("worker role mismatch not denied: decision=%q reason=%q", decision, reason)
1610
+ }
1611
+ }