devrites 3.2.1 → 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 (197) hide show
  1. package/CHANGELOG.md +15 -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 +56 -7
  11. package/engine/hooks_agent_dispatch.go +1193 -0
  12. package/engine/hooks_workspace.go +92 -9
  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 +966 -0
  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 -3
  127. package/pack/generated/codex/hooks.json +38 -4
  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 +46 -16
  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/npm-audit-exceptions.json +12 -0
  187. package/scripts/run-agent-contract-evals.py +3 -3
  188. package/scripts/run-evals.sh +3 -34
  189. package/scripts/validate-frontmatter.py +34 -1
  190. package/engine/testdata/golden/TestParityA1Guard/inline-fallback-silent.golden +0 -1
  191. package/engine/testdata/golden/TestParityDoubtCoverage/arg=inline.golden +0 -3
  192. package/engine/testdata/golden/TestParityDoubtCoverage/arg=missing-inline.golden +0 -3
  193. package/scripts/eval-runner.py +0 -275
  194. package/scripts/eval-scorer.mjs +0 -46
  195. package/scripts/render-eval-summary.py +0 -48
  196. /package/engine/testdata/golden/TestParityReconcile/{inline-fallback → close-window}/snapshot.golden +0 -0
  197. /package/engine/testdata/golden/TestParityReconcile/{inline-fallback.golden → close-window.golden} +0 -0
@@ -0,0 +1,1193 @@
1
+ package main
2
+
3
+ import (
4
+ "bufio"
5
+ "bytes"
6
+ "crypto/sha256"
7
+ "encoding/hex"
8
+ "encoding/json"
9
+ "fmt"
10
+ "io"
11
+ "io/fs"
12
+ "os"
13
+ "path/filepath"
14
+ "regexp"
15
+ "sort"
16
+ "strings"
17
+ "time"
18
+
19
+ "github.com/devrites/devrites/internal/harness"
20
+ "github.com/devrites/devrites/internal/orient"
21
+ "github.com/devrites/devrites/internal/safepath"
22
+ )
23
+
24
+ const (
25
+ agentDispatchStateVersion = "devrites-agent-dispatch/v1"
26
+ agentDispatchStateDir = "devrites-agent-dispatch-v1"
27
+ agentDispatchMetadataRole = "devrites-skill-contract-error"
28
+ maxCodexRolloutLine = 8 << 20
29
+ )
30
+
31
+ var (
32
+ devritesAgentPathRe = regexp.MustCompile(`\.codex/agents/(devrites-[a-z0-9-]+)\.toml`)
33
+ devritesAgentNameRe = regexp.MustCompile(`^devrites-[a-z0-9-]+$`)
34
+ skillInvocationRe = regexp.MustCompile(`(?i)(?:^|[^a-z0-9_./-])(?:\$|/)([a-z][a-z0-9-]*)\b`)
35
+ reconcileTerminalRe = regexp.MustCompile(`(?m)(?:^|[;&|]\s*)(?:rtk\s+)?(?:[A-Za-z0-9_./-]+/)?devrites-engine\s+reconcile\s+(?:check|close)\b`)
36
+ )
37
+
38
+ type agentDispatchHookInput struct {
39
+ HookEventName string `json:"hook_event_name"`
40
+ SessionID string `json:"session_id"`
41
+ TurnID string `json:"turn_id"`
42
+ ToolName string `json:"tool_name"`
43
+ ToolUseID string `json:"tool_use_id"`
44
+ AgentType string `json:"agent_type"`
45
+ AgentID string `json:"agent_id"`
46
+ Prompt string `json:"prompt"`
47
+ StopHookActive bool `json:"stop_hook_active"`
48
+ LastAssistantMessage string `json:"last_assistant_message"`
49
+ ToolInputRaw json.RawMessage `json:"-"`
50
+ ToolInput struct {
51
+ Command string `json:"command"`
52
+ AgentType string `json:"agent_type"`
53
+ ForkTurns string `json:"fork_turns"`
54
+ Message string `json:"message"`
55
+ TaskName string `json:"task_name"`
56
+ ReceiverThreadIDs []string `json:"receiver_thread_ids"`
57
+ IDs []string `json:"ids"`
58
+ AgentIDs []string `json:"agent_ids"`
59
+ Target string `json:"target"`
60
+ } `json:"tool_input"`
61
+ }
62
+
63
+ type agentDispatchEvent struct {
64
+ Version string `json:"version"`
65
+ Event string `json:"event"`
66
+ TurnID string `json:"turn_id"`
67
+ ToolUseID string `json:"tool_use_id,omitempty"`
68
+ AgentID string `json:"agent_id,omitempty"`
69
+ AgentType string `json:"agent_type,omitempty"`
70
+ Role string `json:"role,omitempty"`
71
+ WindowID string `json:"window_id,omitempty"`
72
+ ResultSHA256 string `json:"result_sha256,omitempty"`
73
+ AtUnixNano int64 `json:"at_unix_nano"`
74
+ }
75
+
76
+ type agentDispatchAttempt struct {
77
+ Role string
78
+ AgentType string
79
+ ToolUseID string
80
+ AgentID string
81
+ WindowID string
82
+ Started bool
83
+ Stopped bool
84
+ Waited bool
85
+ ResultSHA256 string
86
+ }
87
+
88
+ type codexRolloutRecord struct {
89
+ Timestamp string `json:"timestamp"`
90
+ Type string `json:"type"`
91
+ Payload struct {
92
+ Type string `json:"type"`
93
+ SessionID string `json:"session_id"`
94
+ ID string `json:"id"`
95
+ ParentThreadID string `json:"parent_thread_id"`
96
+ CWD string `json:"cwd"`
97
+ AgentPath string `json:"agent_path"`
98
+ AgentRole string `json:"agent_role"`
99
+ Name string `json:"name"`
100
+ Arguments string `json:"arguments"`
101
+ Author string `json:"author"`
102
+ Recipient string `json:"recipient"`
103
+ Role string `json:"role"`
104
+ LastAgentResult string `json:"last_agent_message"`
105
+ Content []struct {
106
+ Type string `json:"type"`
107
+ Text string `json:"text"`
108
+ } `json:"content"`
109
+ Metadata struct {
110
+ TurnID string `json:"turn_id"`
111
+ } `json:"internal_chat_message_metadata_passthrough"`
112
+ } `json:"payload"`
113
+ }
114
+
115
+ type codexV2Spawn struct {
116
+ Role string
117
+ AgentPath string
118
+ SpawnLine int
119
+ ResultLine int
120
+ SpawnedAt time.Time
121
+ Result string
122
+ }
123
+
124
+ func parseAgentDispatchHookInput(r io.Reader) (agentDispatchHookInput, error) {
125
+ var in agentDispatchHookInput
126
+ data, err := io.ReadAll(r)
127
+ if err != nil {
128
+ return in, err
129
+ }
130
+ if err := json.Unmarshal(data, &in); err != nil {
131
+ return in, err
132
+ }
133
+ var raw struct {
134
+ ToolInput json.RawMessage `json:"tool_input"`
135
+ }
136
+ if err := json.Unmarshal(data, &raw); err != nil {
137
+ return in, err
138
+ }
139
+ in.ToolInputRaw = raw.ToolInput
140
+ in.HookEventName = strings.TrimSpace(in.HookEventName)
141
+ in.SessionID = strings.TrimSpace(in.SessionID)
142
+ in.TurnID = strings.TrimSpace(in.TurnID)
143
+ in.ToolName = strings.TrimSpace(in.ToolName)
144
+ in.ToolUseID = strings.TrimSpace(in.ToolUseID)
145
+ in.AgentType = strings.TrimSpace(in.AgentType)
146
+ in.AgentID = strings.TrimSpace(in.AgentID)
147
+ in.ToolInput.AgentType = strings.TrimSpace(in.ToolInput.AgentType)
148
+ in.ToolInput.ForkTurns = strings.TrimSpace(in.ToolInput.ForkTurns)
149
+ in.ToolInput.TaskName = strings.TrimSpace(in.ToolInput.TaskName)
150
+ return in, nil
151
+ }
152
+
153
+ func agentDispatchRoot() (string, bool) {
154
+ root, err := orient.ResolveRoot(os.Getenv("DEVRITES_ROOT"))
155
+ if err != nil {
156
+ return "", false
157
+ }
158
+ return root, true
159
+ }
160
+
161
+ func agentDispatchLogPath(root, sessionID string) (string, error) {
162
+ if strings.TrimSpace(sessionID) == "" {
163
+ return "", fmt.Errorf("missing session_id")
164
+ }
165
+ rootSum := sha256.Sum256([]byte(filepath.Clean(root)))
166
+ sessionSum := sha256.Sum256([]byte(sessionID))
167
+ dir := filepath.Join(os.TempDir(), agentDispatchStateDir, hex.EncodeToString(rootSum[:]))
168
+ if err := os.MkdirAll(dir, 0o700); err != nil {
169
+ return "", err
170
+ }
171
+ if err := os.Chmod(dir, 0o700); err != nil {
172
+ return "", err
173
+ }
174
+ return filepath.Join(dir, hex.EncodeToString(sessionSum[:])+".jsonl"), nil
175
+ }
176
+
177
+ func appendAgentDispatchEvent(root, sessionID string, event agentDispatchEvent) error {
178
+ path, err := agentDispatchLogPath(root, sessionID)
179
+ if err != nil {
180
+ return err
181
+ }
182
+ event.Version = agentDispatchStateVersion
183
+ event.AtUnixNano = time.Now().UnixNano()
184
+ data, err := json.Marshal(event)
185
+ if err != nil {
186
+ return err
187
+ }
188
+ data = append(data, '\n')
189
+ f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600)
190
+ if err != nil {
191
+ return err
192
+ }
193
+ defer f.Close()
194
+ if err := f.Chmod(0o600); err != nil {
195
+ return err
196
+ }
197
+ _, err = f.Write(data)
198
+ return err
199
+ }
200
+
201
+ func readAgentDispatchEvents(root, sessionID string) ([]agentDispatchEvent, error) {
202
+ path, err := agentDispatchLogPath(root, sessionID)
203
+ if err != nil {
204
+ return nil, err
205
+ }
206
+ data, err := os.ReadFile(path)
207
+ if os.IsNotExist(err) {
208
+ return nil, nil
209
+ }
210
+ if err != nil {
211
+ return nil, err
212
+ }
213
+ var events []agentDispatchEvent
214
+ for _, line := range bytes.Split(data, []byte{'\n'}) {
215
+ if len(bytes.TrimSpace(line)) == 0 {
216
+ continue
217
+ }
218
+ var event agentDispatchEvent
219
+ if err := json.Unmarshal(line, &event); err != nil {
220
+ return nil, fmt.Errorf("invalid agent dispatch state: %w", err)
221
+ }
222
+ if event.Version != agentDispatchStateVersion {
223
+ return nil, fmt.Errorf("unsupported agent dispatch state %q", event.Version)
224
+ }
225
+ events = append(events, event)
226
+ }
227
+ return events, nil
228
+ }
229
+
230
+ func explicitRolesInText(text string) []string {
231
+ matches := devritesAgentPathRe.FindAllStringSubmatch(text, -1)
232
+ seen := map[string]struct{}{}
233
+ var roles []string
234
+ for _, match := range matches {
235
+ role := match[1]
236
+ if _, ok := seen[role]; ok {
237
+ continue
238
+ }
239
+ seen[role] = struct{}{}
240
+ roles = append(roles, role)
241
+ }
242
+ sort.Strings(roles)
243
+ return roles
244
+ }
245
+
246
+ func requiredAgentRolesFromSkill(path string) ([]string, error) {
247
+ info, err := os.Stat(path)
248
+ if err != nil {
249
+ return nil, err
250
+ }
251
+ if !info.Mode().IsRegular() {
252
+ return nil, fmt.Errorf("skill contract is not a regular file")
253
+ }
254
+ if info.Size() > 256<<10 {
255
+ return nil, fmt.Errorf("skill contract exceeds 256 KiB")
256
+ }
257
+ data, err := os.ReadFile(path)
258
+ if err != nil {
259
+ return nil, err
260
+ }
261
+ lines := strings.Split(strings.ReplaceAll(string(data), "\r\n", "\n"), "\n")
262
+ if len(lines) == 0 || strings.TrimSpace(lines[0]) != "---" {
263
+ return nil, fmt.Errorf("skill contract is missing frontmatter")
264
+ }
265
+ value := ""
266
+ found := false
267
+ closed := false
268
+ for _, line := range lines[1:] {
269
+ if strings.TrimSpace(line) == "---" {
270
+ closed = true
271
+ break
272
+ }
273
+ if !strings.HasPrefix(line, "required-agent-roles:") {
274
+ continue
275
+ }
276
+ if found {
277
+ return nil, fmt.Errorf("required-agent-roles is duplicated")
278
+ }
279
+ found = true
280
+ value = strings.TrimSpace(strings.TrimPrefix(line, "required-agent-roles:"))
281
+ }
282
+ if !closed {
283
+ return nil, fmt.Errorf("skill contract has unterminated frontmatter")
284
+ }
285
+ if !found {
286
+ return nil, fmt.Errorf("skill contract is missing required-agent-roles")
287
+ }
288
+ value = strings.Trim(value, `"'`)
289
+ if value == "none" {
290
+ return nil, nil
291
+ }
292
+ if value == "" {
293
+ return nil, fmt.Errorf("required-agent-roles is empty")
294
+ }
295
+ seen := map[string]struct{}{}
296
+ var roles []string
297
+ for _, item := range strings.Split(value, ",") {
298
+ role := strings.TrimSpace(item)
299
+ if !devritesAgentNameRe.MatchString(role) {
300
+ return nil, fmt.Errorf("invalid required agent role %q", role)
301
+ }
302
+ if _, ok := seen[role]; ok {
303
+ return nil, fmt.Errorf("duplicate required agent role %q", role)
304
+ }
305
+ seen[role] = struct{}{}
306
+ roles = append(roles, role)
307
+ }
308
+ sort.Strings(roles)
309
+ return roles, nil
310
+ }
311
+
312
+ func requiredRolesInPrompt(root, text string) ([]string, error) {
313
+ seen := map[string]struct{}{}
314
+ skillsDir := filepath.Join(filepath.Dir(root), ".agents", "skills")
315
+ for _, match := range skillInvocationRe.FindAllStringSubmatch(text, -1) {
316
+ skill := strings.ToLower(match[1])
317
+ path := filepath.Join(skillsDir, skill, "SKILL.md")
318
+ if !safepath.WithinResolved(path, skillsDir) {
319
+ return nil, fmt.Errorf("unsafe skill path for %s", skill)
320
+ }
321
+ roles, err := requiredAgentRolesFromSkill(path)
322
+ if os.IsNotExist(err) {
323
+ continue
324
+ }
325
+ if err != nil {
326
+ return nil, fmt.Errorf("%s: %w", skill, err)
327
+ }
328
+ for _, role := range roles {
329
+ seen[role] = struct{}{}
330
+ }
331
+ }
332
+ roles := make([]string, 0, len(seen))
333
+ for role := range seen {
334
+ roles = append(roles, role)
335
+ }
336
+ sort.Strings(roles)
337
+ return roles, nil
338
+ }
339
+
340
+ func expectedAgentType(role string) string {
341
+ if role == "devrites-slice-wright" {
342
+ return "worker"
343
+ }
344
+ return "explorer"
345
+ }
346
+
347
+ func agentRoleDeveloperInstructions(root, role string) (string, error) {
348
+ agentsDir := filepath.Join(filepath.Dir(root), ".codex", "agents")
349
+ path := filepath.Join(agentsDir, role+".toml")
350
+ if !safepath.WithinResolved(path, agentsDir) {
351
+ return "", fmt.Errorf("unsafe role path")
352
+ }
353
+ info, err := os.Stat(path)
354
+ if err != nil || !info.Mode().IsRegular() {
355
+ return "", fmt.Errorf("role contract is not a regular file")
356
+ }
357
+ if info.Size() > 256<<10 {
358
+ return "", fmt.Errorf("role contract exceeds 256 KiB")
359
+ }
360
+ data, err := os.ReadFile(path)
361
+ if err != nil {
362
+ return "", err
363
+ }
364
+ lines := strings.Split(strings.ReplaceAll(string(data), "\r\n", "\n"), "\n")
365
+ wantName := `name = "` + role + `"`
366
+ nameMatches := false
367
+ start := -1
368
+ for i, line := range lines {
369
+ switch strings.TrimSpace(line) {
370
+ case wantName:
371
+ nameMatches = true
372
+ case "developer_instructions = '''":
373
+ start = i + 1
374
+ }
375
+ if start >= 0 {
376
+ break
377
+ }
378
+ }
379
+ if !nameMatches || start < 0 {
380
+ return "", fmt.Errorf("role contract name or developer_instructions is invalid")
381
+ }
382
+ for i := start; i < len(lines); i++ {
383
+ if strings.TrimSpace(lines[i]) != "'''" {
384
+ continue
385
+ }
386
+ instructions := strings.TrimSpace(strings.Join(lines[start:i], "\n"))
387
+ if instructions == "" {
388
+ return "", fmt.Errorf("developer_instructions is empty")
389
+ }
390
+ return instructions, nil
391
+ }
392
+ return "", fmt.Errorf("developer_instructions is unterminated")
393
+ }
394
+
395
+ func currentReconcileWindowID() string {
396
+ _, _, dir, ok := resolveWorkspace()
397
+ if !ok {
398
+ return ""
399
+ }
400
+ names := []string{".reconcile-base", ".reconcile-allowlist", ".reconcile-devrites"}
401
+ h := sha256.New()
402
+ for _, name := range names {
403
+ data, err := os.ReadFile(filepath.Join(dir, name))
404
+ if err != nil {
405
+ return ""
406
+ }
407
+ _, _ = io.WriteString(h, name)
408
+ _, _ = h.Write([]byte{0})
409
+ _, _ = h.Write(data)
410
+ _, _ = h.Write([]byte{0})
411
+ }
412
+ return hex.EncodeToString(h.Sum(nil))
413
+ }
414
+
415
+ func dispatchTurnState(events []agentDispatchEvent, turnID string) (map[string]struct{}, []*agentDispatchAttempt) {
416
+ armed := map[string]struct{}{}
417
+ attemptByTool := map[string]*agentDispatchAttempt{}
418
+ attemptByAgent := map[string]*agentDispatchAttempt{}
419
+ var attempts []*agentDispatchAttempt
420
+ for _, event := range events {
421
+ if event.TurnID != turnID {
422
+ continue
423
+ }
424
+ switch event.Event {
425
+ case "armed":
426
+ armed[event.Role] = struct{}{}
427
+ case "pending":
428
+ armed[event.Role] = struct{}{}
429
+ attempt := &agentDispatchAttempt{
430
+ Role: event.Role,
431
+ AgentType: event.AgentType,
432
+ ToolUseID: event.ToolUseID,
433
+ WindowID: event.WindowID,
434
+ }
435
+ attemptByTool[event.ToolUseID] = attempt
436
+ attempts = append(attempts, attempt)
437
+ case "started":
438
+ attempt := attemptByTool[event.ToolUseID]
439
+ if attempt == nil {
440
+ attempt = &agentDispatchAttempt{
441
+ Role: event.Role,
442
+ AgentType: event.AgentType,
443
+ ToolUseID: event.ToolUseID,
444
+ WindowID: event.WindowID,
445
+ }
446
+ attempts = append(attempts, attempt)
447
+ }
448
+ attempt.Started = true
449
+ attempt.AgentID = event.AgentID
450
+ attemptByAgent[event.AgentID] = attempt
451
+ case "waited":
452
+ if attempt := attemptByAgent[event.AgentID]; attempt != nil {
453
+ attempt.Waited = true
454
+ }
455
+ case "stopped":
456
+ if attempt := attemptByAgent[event.AgentID]; attempt != nil {
457
+ attempt.Stopped = true
458
+ attempt.ResultSHA256 = event.ResultSHA256
459
+ }
460
+ }
461
+ }
462
+ return armed, attempts
463
+ }
464
+
465
+ func durableCodexV2DispatchAttempts(
466
+ root, sessionID, turnID string,
467
+ armed map[string]struct{},
468
+ ) ([]*agentDispatchAttempt, error) {
469
+ parentPath, sessionsDir, err := findCodexParentRollout(sessionID)
470
+ if err != nil || parentPath == "" {
471
+ return nil, err
472
+ }
473
+ projectRoot := filepath.Dir(root)
474
+ spawns, waitLines, err := readCodexV2ParentRollout(parentPath, projectRoot, sessionID, turnID, armed)
475
+ if err != nil {
476
+ return nil, err
477
+ }
478
+ var attempts []*agentDispatchAttempt
479
+ for _, spawn := range spawns {
480
+ if strings.TrimSpace(spawn.Result) == "" || !lineBetween(waitLines, spawn.SpawnLine, spawn.ResultLine) {
481
+ continue
482
+ }
483
+ instructions, err := agentRoleDeveloperInstructions(root, spawn.Role)
484
+ if err != nil {
485
+ return nil, fmt.Errorf("named role %s is not loadable: %w", spawn.Role, err)
486
+ }
487
+ childID, childResult, err := findCodexV2ChildResult(
488
+ sessionsDir, parentPath, projectRoot, sessionID, spawn.AgentPath, spawn.Role, instructions,
489
+ )
490
+ if err != nil {
491
+ return nil, err
492
+ }
493
+ if childID == "" || strings.TrimSpace(childResult) == "" {
494
+ continue
495
+ }
496
+ windowID := ""
497
+ if spawn.Role == "devrites-slice-wright" {
498
+ windowID = currentReconcileWindowID()
499
+ if windowID == "" || !reconcileWindowPredates(spawn.SpawnedAt) {
500
+ continue
501
+ }
502
+ }
503
+ sum := sha256.Sum256([]byte(childResult))
504
+ attempts = append(attempts, &agentDispatchAttempt{
505
+ Role: spawn.Role,
506
+ AgentType: spawn.Role,
507
+ AgentID: childID,
508
+ WindowID: windowID,
509
+ Started: true,
510
+ Stopped: true,
511
+ Waited: true,
512
+ ResultSHA256: hex.EncodeToString(sum[:]),
513
+ })
514
+ }
515
+ return attempts, nil
516
+ }
517
+
518
+ func findCodexParentRollout(sessionID string) (string, string, error) {
519
+ codeHome := strings.TrimSpace(os.Getenv("CODEX_HOME"))
520
+ if codeHome == "" {
521
+ home, err := os.UserHomeDir()
522
+ if err != nil {
523
+ return "", "", nil
524
+ }
525
+ codeHome = filepath.Join(home, ".codex")
526
+ }
527
+ sessionsDir := filepath.Join(codeHome, "sessions")
528
+ wantSuffix := "-" + sessionID + ".jsonl"
529
+ var parentPath string
530
+ err := filepath.WalkDir(sessionsDir, func(path string, entry fs.DirEntry, walkErr error) error {
531
+ if walkErr != nil {
532
+ if os.IsNotExist(walkErr) {
533
+ return nil
534
+ }
535
+ return walkErr
536
+ }
537
+ if entry.IsDir() || !strings.HasSuffix(entry.Name(), wantSuffix) {
538
+ return nil
539
+ }
540
+ parentPath = path
541
+ return fs.SkipAll
542
+ })
543
+ if os.IsNotExist(err) {
544
+ return "", sessionsDir, nil
545
+ }
546
+ return parentPath, sessionsDir, err
547
+ }
548
+
549
+ func readCodexV2ParentRollout(
550
+ path, projectRoot, sessionID, turnID string,
551
+ armed map[string]struct{},
552
+ ) (map[string]*codexV2Spawn, []int, error) {
553
+ file, err := os.Open(path)
554
+ if err != nil {
555
+ return nil, nil, err
556
+ }
557
+ defer file.Close()
558
+
559
+ spawns := map[string]*codexV2Spawn{}
560
+ var waitLines []int
561
+ metaSeen := false
562
+ scanner := bufio.NewScanner(file)
563
+ scanner.Buffer(make([]byte, 64<<10), maxCodexRolloutLine)
564
+ for lineNumber := 1; scanner.Scan(); lineNumber++ {
565
+ line := scanner.Bytes()
566
+ if !bytes.Contains(line, []byte(`"session_meta"`)) &&
567
+ !bytes.Contains(line, []byte(`"spawn_agent"`)) &&
568
+ !bytes.Contains(line, []byte(`"wait_agent"`)) &&
569
+ !bytes.Contains(line, []byte(`"agent_message"`)) {
570
+ continue
571
+ }
572
+ var record codexRolloutRecord
573
+ if err := json.Unmarshal(line, &record); err != nil {
574
+ return nil, nil, fmt.Errorf("invalid Codex parent rollout: %w", err)
575
+ }
576
+ if record.Type == "session_meta" {
577
+ if record.Payload.ID != sessionID || !sameResolvedPath(record.Payload.CWD, projectRoot) {
578
+ return nil, nil, fmt.Errorf("codex parent rollout does not match the current session root")
579
+ }
580
+ metaSeen = true
581
+ continue
582
+ }
583
+ if record.Payload.Metadata.TurnID != turnID {
584
+ continue
585
+ }
586
+ switch {
587
+ case record.Payload.Type == "function_call" && record.Payload.Name == "spawn_agent":
588
+ var args struct {
589
+ AgentType string `json:"agent_type"`
590
+ TaskName string `json:"task_name"`
591
+ ForkTurns string `json:"fork_turns"`
592
+ }
593
+ if json.Unmarshal([]byte(record.Payload.Arguments), &args) != nil {
594
+ continue
595
+ }
596
+ if _, required := armed[args.AgentType]; !required ||
597
+ args.TaskName == "" || strings.Contains(args.TaskName, "/") ||
598
+ args.ForkTurns != "none" {
599
+ continue
600
+ }
601
+ spawnedAt, err := time.Parse(time.RFC3339Nano, record.Timestamp)
602
+ if err != nil {
603
+ return nil, nil, fmt.Errorf("codex spawn timestamp is invalid: %w", err)
604
+ }
605
+ spawns["/root/"+args.TaskName] = &codexV2Spawn{
606
+ Role: args.AgentType,
607
+ AgentPath: "/root/" + args.TaskName,
608
+ SpawnLine: lineNumber,
609
+ SpawnedAt: spawnedAt,
610
+ }
611
+ case record.Payload.Type == "function_call" && record.Payload.Name == "wait_agent":
612
+ waitLines = append(waitLines, lineNumber)
613
+ case record.Payload.Type == "agent_message" &&
614
+ record.Payload.Recipient == "/root":
615
+ spawn := spawns[record.Payload.Author]
616
+ if spawn == nil {
617
+ continue
618
+ }
619
+ spawn.Result = rolloutInputText(record.Payload.Content)
620
+ spawn.ResultLine = lineNumber
621
+ }
622
+ }
623
+ if err := scanner.Err(); err != nil {
624
+ return nil, nil, fmt.Errorf("read Codex parent rollout: %w", err)
625
+ }
626
+ if !metaSeen {
627
+ return nil, nil, fmt.Errorf("codex parent rollout is missing session metadata")
628
+ }
629
+ return spawns, waitLines, nil
630
+ }
631
+
632
+ func findCodexV2ChildResult(
633
+ sessionsDir, parentPath, projectRoot, sessionID, agentPath, role, instructions string,
634
+ ) (string, string, error) {
635
+ var childID, result string
636
+ err := filepath.WalkDir(sessionsDir, func(path string, entry fs.DirEntry, walkErr error) error {
637
+ if walkErr != nil {
638
+ if os.IsNotExist(walkErr) {
639
+ return nil
640
+ }
641
+ return walkErr
642
+ }
643
+ if entry.IsDir() || path == parentPath || filepath.Ext(path) != ".jsonl" {
644
+ return nil
645
+ }
646
+ record, ok, err := firstCodexRolloutRecord(path)
647
+ if err != nil || !ok {
648
+ return err
649
+ }
650
+ if record.Type != "session_meta" ||
651
+ record.Payload.ParentThreadID != sessionID ||
652
+ record.Payload.AgentPath != agentPath ||
653
+ record.Payload.AgentRole != role ||
654
+ !sameResolvedPath(record.Payload.CWD, projectRoot) {
655
+ return nil
656
+ }
657
+ if childID != "" {
658
+ return fmt.Errorf("multiple Codex child rollouts match %s", agentPath)
659
+ }
660
+ childID = record.Payload.ID
661
+ result, err = codexChildResult(path, instructions)
662
+ return err
663
+ })
664
+ return childID, result, err
665
+ }
666
+
667
+ func firstCodexRolloutRecord(path string) (codexRolloutRecord, bool, error) {
668
+ var record codexRolloutRecord
669
+ file, err := os.Open(path)
670
+ if err != nil {
671
+ return record, false, err
672
+ }
673
+ defer file.Close()
674
+ scanner := bufio.NewScanner(file)
675
+ scanner.Buffer(make([]byte, 64<<10), maxCodexRolloutLine)
676
+ if !scanner.Scan() {
677
+ return record, false, scanner.Err()
678
+ }
679
+ if err := json.Unmarshal(scanner.Bytes(), &record); err != nil {
680
+ return record, false, nil
681
+ }
682
+ return record, true, nil
683
+ }
684
+
685
+ func codexChildResult(path, instructions string) (string, error) {
686
+ file, err := os.Open(path)
687
+ if err != nil {
688
+ return "", err
689
+ }
690
+ defer file.Close()
691
+ scanner := bufio.NewScanner(file)
692
+ scanner.Buffer(make([]byte, 64<<10), maxCodexRolloutLine)
693
+ instructionsLoaded := false
694
+ result := ""
695
+ for scanner.Scan() {
696
+ line := scanner.Bytes()
697
+ if !bytes.Contains(line, []byte(`"task_complete"`)) &&
698
+ !bytes.Contains(line, []byte(`"developer"`)) {
699
+ continue
700
+ }
701
+ var record codexRolloutRecord
702
+ if json.Unmarshal(line, &record) != nil {
703
+ continue
704
+ }
705
+ if record.Payload.Type == "message" && record.Payload.Role == "developer" &&
706
+ strings.Contains(rolloutInputText(record.Payload.Content), instructions) {
707
+ instructionsLoaded = true
708
+ }
709
+ if record.Payload.Type == "task_complete" &&
710
+ strings.TrimSpace(record.Payload.LastAgentResult) != "" {
711
+ result = record.Payload.LastAgentResult
712
+ }
713
+ }
714
+ if err := scanner.Err(); err != nil {
715
+ return "", err
716
+ }
717
+ if !instructionsLoaded {
718
+ return "", nil
719
+ }
720
+ return result, nil
721
+ }
722
+
723
+ func rolloutInputText(content []struct {
724
+ Type string `json:"type"`
725
+ Text string `json:"text"`
726
+ }) string {
727
+ var text []string
728
+ for _, item := range content {
729
+ if item.Type == "input_text" && strings.TrimSpace(item.Text) != "" {
730
+ text = append(text, item.Text)
731
+ }
732
+ }
733
+ return strings.Join(text, "\n")
734
+ }
735
+
736
+ func lineBetween(lines []int, after, before int) bool {
737
+ for _, line := range lines {
738
+ if line > after && line < before {
739
+ return true
740
+ }
741
+ }
742
+ return false
743
+ }
744
+
745
+ func sameResolvedPath(left, right string) bool {
746
+ resolve := func(path string) string {
747
+ absolute, err := filepath.Abs(path)
748
+ if err == nil {
749
+ path = absolute
750
+ }
751
+ if resolved, err := filepath.EvalSymlinks(path); err == nil {
752
+ path = resolved
753
+ }
754
+ return filepath.Clean(path)
755
+ }
756
+ return resolve(left) == resolve(right)
757
+ }
758
+
759
+ func reconcileWindowPredates(at time.Time) bool {
760
+ _, _, dir, ok := resolveWorkspace()
761
+ if !ok {
762
+ return false
763
+ }
764
+ for _, name := range []string{".reconcile-base", ".reconcile-allowlist", ".reconcile-devrites"} {
765
+ info, err := os.Stat(filepath.Join(dir, name))
766
+ if err != nil || info.ModTime().After(at) {
767
+ return false
768
+ }
769
+ }
770
+ return true
771
+ }
772
+
773
+ func dispatchAttemptComplete(attempt *agentDispatchAttempt) bool {
774
+ return attempt.Started && attempt.Stopped && attempt.ResultSHA256 != ""
775
+ }
776
+
777
+ func roleSatisfied(role, windowID string, attempts []*agentDispatchAttempt) bool {
778
+ for _, attempt := range attempts {
779
+ if attempt.Role != role || !dispatchAttemptComplete(attempt) {
780
+ continue
781
+ }
782
+ if windowID == "" || attempt.WindowID == windowID {
783
+ return true
784
+ }
785
+ }
786
+ return false
787
+ }
788
+
789
+ func incompleteDispatchReason(armed map[string]struct{}, attempts []*agentDispatchAttempt) string {
790
+ roles := make([]string, 0, len(armed))
791
+ for role := range armed {
792
+ if !roleSatisfied(role, "", attempts) {
793
+ roles = append(roles, role)
794
+ }
795
+ }
796
+ if len(roles) == 0 {
797
+ return ""
798
+ }
799
+ sort.Strings(roles)
800
+ role := roles[0]
801
+ if role == agentDispatchMetadataRole {
802
+ return "DevRites could not determine this skill's required agents because its installed required-agent-roles contract is invalid. Reinstall or repair the DevRites skill pack before continuing."
803
+ }
804
+ return fmt.Sprintf(
805
+ "DevRites dispatch for %s is not complete. On MultiAgent V2 call spawn_agent with agent_type=%s, a unique task_name, and fork_turns=\"none\" so Codex loads .codex/agents/%s.toml natively. On V1 use agent_type=%s with fork_turns=\"none\" and name that role TOML in the message. Wait for the returned child and use its non-empty result. Do not call wait before spawn_agent and do not synthesize the agent result.",
806
+ role, role, role, expectedAgentType(role),
807
+ )
808
+ }
809
+
810
+ func preToolDeny(h harness.Harness, reason string, stdout, stderr io.Writer) int {
811
+ out, err := h.PreToolDeny(reason)
812
+ if err != nil {
813
+ debugf(stderr, "agent-dispatch: %v", err)
814
+ return exitOK
815
+ }
816
+ fmt.Fprintln(stdout, out)
817
+ return exitOK
818
+ }
819
+
820
+ func preToolRewrite(h harness.Harness, toolInput json.RawMessage, message string, stdout, stderr io.Writer) int {
821
+ var updated map[string]any
822
+ if err := json.Unmarshal(toolInput, &updated); err != nil {
823
+ return preToolDeny(h, "DevRites could not bind developer_instructions to the spawn input.", stdout, stderr)
824
+ }
825
+ updated["message"] = message
826
+ out, err := json.Marshal(map[string]any{
827
+ "hookSpecificOutput": map[string]any{
828
+ "hookEventName": "PreToolUse",
829
+ "permissionDecision": "allow",
830
+ "updatedInput": updated,
831
+ },
832
+ })
833
+ if err != nil {
834
+ return preToolDeny(h, "DevRites could not encode the bound spawn input.", stdout, stderr)
835
+ }
836
+ fmt.Fprintln(stdout, string(out))
837
+ return exitOK
838
+ }
839
+
840
+ func stopDispatchBlock(h harness.Harness, reason string, stdout, stderr io.Writer) int {
841
+ out, err := h.StopBlock(reason)
842
+ if err != nil {
843
+ debugf(stderr, "agent-dispatch: %v", err)
844
+ return exitOK
845
+ }
846
+ fmt.Fprintln(stdout, out)
847
+ return exitOK
848
+ }
849
+
850
+ func stopDispatchFailure(reason string, stdout, stderr io.Writer) int {
851
+ out, err := json.Marshal(map[string]any{
852
+ "continue": false,
853
+ "stopReason": reason,
854
+ "systemMessage": reason,
855
+ })
856
+ if err != nil {
857
+ debugf(stderr, "agent-dispatch: %v", err)
858
+ return exitOK
859
+ }
860
+ fmt.Fprintln(stdout, string(out))
861
+ return exitOK
862
+ }
863
+
864
+ func hookAgentDispatch(h harness.Harness, stdin io.Reader, stdout, stderr io.Writer) int {
865
+ if h != harness.Codex {
866
+ _, _ = io.Copy(io.Discard, stdin)
867
+ return exitOK
868
+ }
869
+ in, err := parseAgentDispatchHookInput(stdin)
870
+ if err != nil {
871
+ return exitOK
872
+ }
873
+ root, ok := agentDispatchRoot()
874
+ if !ok || in.SessionID == "" || in.TurnID == "" {
875
+ return exitOK
876
+ }
877
+
878
+ switch in.HookEventName {
879
+ case "UserPromptSubmit":
880
+ roles, rolesErr := requiredRolesInPrompt(root, in.Prompt)
881
+ if rolesErr != nil {
882
+ if err := appendAgentDispatchEvent(root, in.SessionID, agentDispatchEvent{
883
+ Event: "armed",
884
+ TurnID: in.TurnID,
885
+ Role: agentDispatchMetadataRole,
886
+ }); err != nil {
887
+ return stopDispatchBlock(h, "DevRites could not record the invalid agent-role contract: "+err.Error(), stdout, stderr)
888
+ }
889
+ return stopDispatchBlock(h, "DevRites could not read the required agent roles: "+rolesErr.Error(), stdout, stderr)
890
+ }
891
+ for _, role := range roles {
892
+ if err := appendAgentDispatchEvent(root, in.SessionID, agentDispatchEvent{
893
+ Event: "armed",
894
+ TurnID: in.TurnID,
895
+ Role: role,
896
+ }); err != nil {
897
+ return stopDispatchBlock(h, "DevRites could not arm the required agent dispatch: "+err.Error(), stdout, stderr)
898
+ }
899
+ }
900
+ if len(roles) > 0 {
901
+ fmt.Fprintf(stdout, "DevRites: this turn requires a confirmed %s subagent start, wait, and non-empty result; a success phrase is not evidence.", strings.Join(roles, ", "))
902
+ }
903
+ return exitOK
904
+
905
+ case "PreToolUse":
906
+ return hookAgentDispatchPreTool(h, root, in, stdout, stderr)
907
+
908
+ case "SubagentStop":
909
+ if in.AgentID == "" {
910
+ return exitOK
911
+ }
912
+ attempt, found, lookupErr := boundDispatchAttempt(root, in.SessionID, in.AgentID)
913
+ if lookupErr != nil {
914
+ return stopDispatchBlock(h, "DevRites could not identify the stopped subagent: "+lookupErr.Error(), stdout, stderr)
915
+ }
916
+ if !found {
917
+ return exitOK
918
+ }
919
+ resultSum := ""
920
+ if strings.TrimSpace(in.LastAssistantMessage) != "" {
921
+ sum := sha256.Sum256([]byte(in.LastAssistantMessage))
922
+ resultSum = hex.EncodeToString(sum[:])
923
+ }
924
+ if err := appendAgentDispatchEvent(root, in.SessionID, agentDispatchEvent{
925
+ Event: "stopped",
926
+ TurnID: attempt.TurnID,
927
+ AgentID: in.AgentID,
928
+ AgentType: attempt.AgentType,
929
+ ResultSHA256: resultSum,
930
+ }); err != nil {
931
+ return stopDispatchBlock(h, "DevRites could not record the subagent result: "+err.Error(), stdout, stderr)
932
+ }
933
+ return exitOK
934
+
935
+ case "Stop":
936
+ events, err := readAgentDispatchEvents(root, in.SessionID)
937
+ if err != nil {
938
+ return stopDispatchBlock(h, "DevRites could not verify the required agent dispatch: "+err.Error(), stdout, stderr)
939
+ }
940
+ armed, attempts := dispatchTurnState(events, in.TurnID)
941
+ if reason := incompleteDispatchReason(armed, attempts); reason != "" {
942
+ durable, durableErr := durableCodexV2DispatchAttempts(
943
+ root, in.SessionID, in.TurnID, armed,
944
+ )
945
+ if durableErr != nil {
946
+ return stopDispatchBlock(h, "DevRites could not verify the Codex V2 agent dispatch: "+durableErr.Error(), stdout, stderr)
947
+ }
948
+ attempts = append(attempts, durable...)
949
+ }
950
+ if reason := incompleteDispatchReason(armed, attempts); reason != "" {
951
+ if in.StopHookActive {
952
+ return stopDispatchFailure(reason, stdout, stderr)
953
+ }
954
+ return stopDispatchBlock(h, reason, stdout, stderr)
955
+ }
956
+ return exitOK
957
+ }
958
+ return exitOK
959
+ }
960
+
961
+ func hookAgentDispatchPreTool(h harness.Harness, root string, in agentDispatchHookInput, stdout, stderr io.Writer) int {
962
+ events, err := readAgentDispatchEvents(root, in.SessionID)
963
+ if err != nil {
964
+ return preToolDeny(h, "DevRites could not verify agent dispatch state: "+err.Error(), stdout, stderr)
965
+ }
966
+ armed, attempts := dispatchTurnState(events, in.TurnID)
967
+
968
+ if isAgentDispatchTool(in.ToolName) {
969
+ agentType := in.ToolInput.AgentType
970
+ if agentType == "" {
971
+ if len(armed) == 0 {
972
+ return exitOK
973
+ }
974
+ return preToolDeny(h, "DevRites MultiAgent V2 dispatch requires the exact named agent_type=devrites-<role>; omitting agent_type does not load the specialist role contract.", stdout, stderr)
975
+ }
976
+ role := ""
977
+ if strings.HasPrefix(agentType, "devrites-") {
978
+ role = agentType
979
+ } else {
980
+ roles := explicitRolesInText(in.ToolInput.Message)
981
+ if len(roles) == 1 {
982
+ role = roles[0]
983
+ }
984
+ }
985
+ if role == "" {
986
+ if agentType == "explorer" || agentType == "worker" {
987
+ if len(armed) == 0 {
988
+ return exitOK // Unrelated generic subagent in a DevRites repository.
989
+ }
990
+ return preToolDeny(h, "DevRites generic dispatch must name exactly one .codex/agents/devrites-<role>.toml contract.", stdout, stderr)
991
+ }
992
+ return exitOK
993
+ }
994
+ instructions, contractErr := agentRoleDeveloperInstructions(root, role)
995
+ if contractErr != nil {
996
+ return preToolDeny(h, "DevRites role contract .codex/agents/"+role+".toml cannot be loaded: "+contractErr.Error()+".", stdout, stderr)
997
+ }
998
+ if in.ToolInput.ForkTurns != "none" {
999
+ return preToolDeny(h, "DevRites agents require fork_turns=\"none\" so the child starts in the role contract rather than inheriting the root type.", stdout, stderr)
1000
+ }
1001
+ if agentType == "worker" && role != "devrites-slice-wright" {
1002
+ return preToolDeny(h, "Generic worker is reserved for devrites-slice-wright; use generic explorer for read-only DevRites roles.", stdout, stderr)
1003
+ }
1004
+ if agentType == "explorer" && role == "devrites-slice-wright" {
1005
+ return preToolDeny(h, "devrites-slice-wright requires the generic worker identity and exact wright allowlist.", stdout, stderr)
1006
+ }
1007
+ if agentType != "" && agentType != role && agentType != expectedAgentType(role) {
1008
+ return preToolDeny(h, fmt.Sprintf("DevRites role %s requires agent_type=%s or its exposed named role.", role, expectedAgentType(role)), stdout, stderr)
1009
+ }
1010
+ if in.ToolUseID == "" {
1011
+ return preToolDeny(h, "DevRites cannot bind a spawn without tool_use_id.", stdout, stderr)
1012
+ }
1013
+ windowID := currentReconcileWindowID()
1014
+ if role == "devrites-slice-wright" && windowID == "" {
1015
+ return preToolDeny(h, "devrites-slice-wright requires an active reconcile snapshot before spawn_agent.", stdout, stderr)
1016
+ }
1017
+ if err := appendAgentDispatchEvent(root, in.SessionID, agentDispatchEvent{
1018
+ Event: "pending",
1019
+ TurnID: in.TurnID,
1020
+ ToolUseID: in.ToolUseID,
1021
+ AgentType: agentType,
1022
+ Role: role,
1023
+ WindowID: windowID,
1024
+ }); err != nil {
1025
+ return preToolDeny(h, "DevRites could not record the spawn attempt: "+err.Error(), stdout, stderr)
1026
+ }
1027
+ if agentType != role {
1028
+ message := fmt.Sprintf(
1029
+ "DevRites bound developer_instructions for `%s`:\n\n%s\n\n## Unchanged parent packet\n%s",
1030
+ role, instructions, in.ToolInput.Message,
1031
+ )
1032
+ return preToolRewrite(h, in.ToolInputRaw, message, stdout, stderr)
1033
+ }
1034
+ return exitOK
1035
+ }
1036
+
1037
+ switch toolBaseName(in.ToolName) {
1038
+ case "wait", "wait_agent":
1039
+ if len(armed) == 0 {
1040
+ return exitOK
1041
+ }
1042
+ var started []*agentDispatchAttempt
1043
+ for _, attempt := range attempts {
1044
+ if attempt.Started {
1045
+ started = append(started, attempt)
1046
+ }
1047
+ }
1048
+ if len(started) == 0 {
1049
+ return preToolDeny(h, incompleteDispatchReason(armed, attempts), stdout, stderr)
1050
+ }
1051
+ targets := in.ToolInput.ReceiverThreadIDs
1052
+ if len(targets) == 0 {
1053
+ targets = in.ToolInput.IDs
1054
+ }
1055
+ if len(targets) == 0 {
1056
+ targets = in.ToolInput.AgentIDs
1057
+ }
1058
+ if len(targets) > 0 {
1059
+ known := map[string]*agentDispatchAttempt{}
1060
+ for _, attempt := range started {
1061
+ known[attempt.AgentID] = attempt
1062
+ }
1063
+ for _, agentID := range targets {
1064
+ attempt := known[agentID]
1065
+ if attempt == nil {
1066
+ return preToolDeny(h, "DevRites wait target is not a confirmed child from this turn.", stdout, stderr)
1067
+ }
1068
+ if err := appendAgentDispatchEvent(root, in.SessionID, agentDispatchEvent{
1069
+ Event: "waited",
1070
+ TurnID: in.TurnID,
1071
+ AgentID: agentID,
1072
+ }); err != nil {
1073
+ return preToolDeny(h, "DevRites could not record the child wait: "+err.Error(), stdout, stderr)
1074
+ }
1075
+ }
1076
+ return exitOK
1077
+ }
1078
+ for _, attempt := range started {
1079
+ if err := appendAgentDispatchEvent(root, in.SessionID, agentDispatchEvent{
1080
+ Event: "waited",
1081
+ TurnID: in.TurnID,
1082
+ AgentID: attempt.AgentID,
1083
+ }); err != nil {
1084
+ return preToolDeny(h, "DevRites could not record the child wait: "+err.Error(), stdout, stderr)
1085
+ }
1086
+ }
1087
+ return exitOK
1088
+ }
1089
+
1090
+ if isShellTool(in.ToolName) && reconcileTerminalRe.MatchString(in.ToolInput.Command) {
1091
+ windowID := currentReconcileWindowID()
1092
+ if windowID == "" {
1093
+ return exitOK
1094
+ }
1095
+ durable, durableErr := durableCodexV2DispatchAttempts(
1096
+ root, in.SessionID, in.TurnID, armed,
1097
+ )
1098
+ if durableErr != nil {
1099
+ return preToolDeny(h, "DevRites could not verify the Codex V2 wright result: "+durableErr.Error(), stdout, stderr)
1100
+ }
1101
+ attempts = append(attempts, durable...)
1102
+ if !roleSatisfied("devrites-slice-wright", windowID, attempts) {
1103
+ return preToolDeny(h, "DevRites reconcile check/close requires a confirmed, awaited devrites-slice-wright result bound to the active reconcile snapshot.", stdout, stderr)
1104
+ }
1105
+ }
1106
+ return exitOK
1107
+ }
1108
+
1109
+ func bindAgentDispatchStart(root string, in agentDispatchHookInput) (string, bool, error) {
1110
+ events, err := readAgentDispatchEvents(root, in.SessionID)
1111
+ if err != nil {
1112
+ return "", false, err
1113
+ }
1114
+ started := map[string]struct{}{}
1115
+ for _, event := range events {
1116
+ if event.Event == "started" {
1117
+ started[event.ToolUseID] = struct{}{}
1118
+ }
1119
+ }
1120
+ for i := len(events) - 1; i >= 0; i-- {
1121
+ event := events[i]
1122
+ if event.Event != "pending" || event.AgentType != in.AgentType {
1123
+ continue
1124
+ }
1125
+ if _, ok := started[event.ToolUseID]; ok {
1126
+ continue
1127
+ }
1128
+ if err := appendAgentDispatchEvent(root, in.SessionID, agentDispatchEvent{
1129
+ Event: "started",
1130
+ TurnID: event.TurnID,
1131
+ ToolUseID: event.ToolUseID,
1132
+ AgentID: in.AgentID,
1133
+ AgentType: in.AgentType,
1134
+ Role: event.Role,
1135
+ WindowID: event.WindowID,
1136
+ }); err != nil {
1137
+ return "", false, err
1138
+ }
1139
+ return event.Role, true, nil
1140
+ }
1141
+ return "", false, nil
1142
+ }
1143
+
1144
+ type boundAgentDispatchAttempt struct {
1145
+ TurnID string
1146
+ AgentType string
1147
+ Role string
1148
+ }
1149
+
1150
+ func boundDispatchAttempt(root, sessionID, agentID string) (boundAgentDispatchAttempt, bool, error) {
1151
+ events, err := readAgentDispatchEvents(root, sessionID)
1152
+ if err != nil {
1153
+ return boundAgentDispatchAttempt{}, false, err
1154
+ }
1155
+ for i := len(events) - 1; i >= 0; i-- {
1156
+ event := events[i]
1157
+ if event.Event == "started" && event.AgentID == agentID {
1158
+ return boundAgentDispatchAttempt{
1159
+ TurnID: event.TurnID,
1160
+ AgentType: event.AgentType,
1161
+ Role: event.Role,
1162
+ }, true, nil
1163
+ }
1164
+ }
1165
+ return boundAgentDispatchAttempt{}, false, nil
1166
+ }
1167
+
1168
+ func devritesAgentForGuard(h harness.Harness, in harness.GuardInput) devritesAgentKind {
1169
+ kind := devritesAgent(in.AgentType)
1170
+ if h != harness.Codex || kind != devritesAgentNone ||
1171
+ os.Getenv("DEVRITES_CODEX_GENERIC_AGENT_COMPAT") != "1" ||
1172
+ in.SessionID == "" || in.AgentID == "" {
1173
+ return kind
1174
+ }
1175
+ root, ok := agentDispatchRoot()
1176
+ if !ok {
1177
+ return devritesAgentInvalid
1178
+ }
1179
+ attempt, found, err := boundDispatchAttempt(root, in.SessionID, in.AgentID)
1180
+ if err != nil {
1181
+ return devritesAgentInvalid
1182
+ }
1183
+ if !found {
1184
+ return devritesAgentNone
1185
+ }
1186
+ if attempt.Role == "devrites-slice-wright" {
1187
+ return devritesAgentGenericWright
1188
+ }
1189
+ if strings.HasPrefix(attempt.Role, "devrites-") {
1190
+ return devritesAgentReadonly
1191
+ }
1192
+ return devritesAgentInvalid
1193
+ }