devrites 3.2.16 → 3.2.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +1 -1
  3. package/engine/Makefile +3 -3
  4. package/engine/hooks.go +20 -4
  5. package/engine/hooks_agent_dispatch.go +297 -48
  6. package/engine/hooks_events_test.go +40 -0
  7. package/engine/hooks_workspace.go +26 -6
  8. package/engine/internal/devritespaths/paths.go +80 -0
  9. package/engine/internal/install/install.go +36 -16
  10. package/engine/internal/install/install_test.go +50 -1
  11. package/engine/internal/iohooks/iohooks.go +5 -3
  12. package/engine/internal/iohooks/iohooks_test.go +11 -0
  13. package/engine/internal/lib/adoption.go +36 -11
  14. package/engine/internal/lib/adoption_test.go +30 -0
  15. package/engine/internal/lib/analyze.go +10 -2
  16. package/engine/internal/lib/archivesearch.go +41 -9
  17. package/engine/internal/lib/archivesearch_test.go +28 -0
  18. package/engine/internal/lib/buildreadiness.go +5 -1
  19. package/engine/internal/lib/closeout.go +34 -11
  20. package/engine/internal/lib/closeout_test.go +99 -0
  21. package/engine/internal/lib/context.go +6 -1
  22. package/engine/internal/lib/dispatchwaive.go +31 -0
  23. package/engine/internal/lib/dispatchwaive_test.go +23 -0
  24. package/engine/internal/lib/extensions.go +5 -1
  25. package/engine/internal/lib/learnings.go +8 -2
  26. package/engine/internal/lib/ledger.go +17 -4
  27. package/engine/internal/lib/ledger_test.go +24 -0
  28. package/engine/internal/lib/mutationgate.go +6 -2
  29. package/engine/internal/lib/reconcile.go +201 -64
  30. package/engine/internal/lib/reconcile_test.go +159 -12
  31. package/engine/internal/lib/resolve.go +8 -3
  32. package/engine/internal/lib/resolve_remediation_test.go +14 -0
  33. package/engine/internal/lib/runbook.go +4 -1
  34. package/engine/internal/lib/runbook_context_test.go +43 -0
  35. package/engine/internal/lib/testintegrity.go +4 -4
  36. package/engine/internal/lib/testintegrity_test.go +15 -0
  37. package/engine/internal/lib/tickafk.go +6 -2
  38. package/engine/internal/lib/util.go +128 -23
  39. package/engine/internal/profile/profile.go +27 -13
  40. package/engine/internal/profile/profile_test.go +13 -0
  41. package/engine/main.go +4 -1
  42. package/engine/testdata/golden/TestParityReconcile/close-window.golden +1 -0
  43. package/engine/testdata/golden/TestParityReconcile/not-git.golden +1 -1
  44. package/engine/testdata/golden/TestParityTestIntegrity/not-git.golden +1 -1
  45. package/engine/tests/hook_test.go +353 -17
  46. package/engine/tests/parity_reconcile_test.go +4 -1
  47. package/engine/tests/parity_testintegrity_test.go +1 -1
  48. package/pack/.claude/agents/devrites-code-reviewer.md +3 -0
  49. package/pack/.claude/agents/devrites-devex-reviewer.md +12 -8
  50. package/pack/.claude/agents/devrites-doubt-reviewer.md +3 -0
  51. package/pack/.claude/agents/devrites-forge-judge.md +4 -1
  52. package/pack/.claude/agents/devrites-frontend-reviewer.md +3 -0
  53. package/pack/.claude/agents/devrites-performance-reviewer.md +10 -9
  54. package/pack/.claude/agents/devrites-plan-reviewer.md +3 -0
  55. package/pack/.claude/agents/devrites-proof-runner.md +14 -14
  56. package/pack/.claude/agents/devrites-retrospector.md +3 -0
  57. package/pack/.claude/agents/devrites-security-auditor.md +3 -0
  58. package/pack/.claude/agents/devrites-simplifier-reviewer.md +3 -0
  59. package/pack/.claude/agents/devrites-slice-wright.md +4 -2
  60. package/pack/.claude/agents/devrites-spec-reviewer.md +3 -0
  61. package/pack/.claude/agents/devrites-strategy-reviewer.md +3 -0
  62. package/pack/.claude/agents/devrites-test-analyst.md +3 -0
  63. package/pack/.claude/skills/devrites-lib/reference/parallel-dispatch.md +8 -0
  64. package/pack/.claude/skills/devrites-lib/reference/standards/agents.md +10 -0
  65. package/pack/.claude/skills/devrites-source-driven/SKILL.md +1 -1
  66. package/pack/.claude/skills/rite-build/reference/phase-contract.md +17 -3
  67. package/pack/.claude/skills/rite-build/reference/wright-dispatch.md +3 -1
  68. package/pack/.claude/skills/rite-prove/SKILL.md +25 -14
  69. package/pack/.claude/skills/rite-temper/SKILL.md +1 -1
  70. package/pack/.claude/skills/rite-upgrade/SKILL.md +1 -1
  71. package/pack/.claude/skills/rite-vet/SKILL.md +18 -15
  72. package/pack/generated/claude/agents/devrites-code-reviewer.md +3 -0
  73. package/pack/generated/claude/agents/devrites-devex-reviewer.md +12 -8
  74. package/pack/generated/claude/agents/devrites-doubt-reviewer.md +3 -0
  75. package/pack/generated/claude/agents/devrites-forge-judge.md +4 -1
  76. package/pack/generated/claude/agents/devrites-frontend-reviewer.md +3 -0
  77. package/pack/generated/claude/agents/devrites-performance-reviewer.md +10 -9
  78. package/pack/generated/claude/agents/devrites-plan-reviewer.md +3 -0
  79. package/pack/generated/claude/agents/devrites-proof-runner.md +14 -14
  80. package/pack/generated/claude/agents/devrites-retrospector.md +3 -0
  81. package/pack/generated/claude/agents/devrites-security-auditor.md +3 -0
  82. package/pack/generated/claude/agents/devrites-simplifier-reviewer.md +3 -0
  83. package/pack/generated/claude/agents/devrites-slice-wright.md +4 -2
  84. package/pack/generated/claude/agents/devrites-spec-reviewer.md +3 -0
  85. package/pack/generated/claude/agents/devrites-strategy-reviewer.md +3 -0
  86. package/pack/generated/claude/agents/devrites-test-analyst.md +3 -0
  87. package/pack/generated/claude/skills/devrites-lib/reference/parallel-dispatch.md +8 -0
  88. package/pack/generated/claude/skills/devrites-lib/reference/standards/agents.md +10 -0
  89. package/pack/generated/claude/skills/devrites-source-driven/SKILL.md +1 -1
  90. package/pack/generated/claude/skills/rite-build/reference/phase-contract.md +17 -3
  91. package/pack/generated/claude/skills/rite-build/reference/wright-dispatch.md +3 -1
  92. package/pack/generated/claude/skills/rite-prove/SKILL.md +25 -14
  93. package/pack/generated/claude/skills/rite-temper/SKILL.md +1 -1
  94. package/pack/generated/claude/skills/rite-upgrade/SKILL.md +1 -1
  95. package/pack/generated/claude/skills/rite-vet/SKILL.md +18 -15
  96. package/pack/generated/codex/agents/devrites-code-reviewer.toml +3 -0
  97. package/pack/generated/codex/agents/devrites-devex-reviewer.toml +11 -7
  98. package/pack/generated/codex/agents/devrites-doubt-reviewer.toml +3 -0
  99. package/pack/generated/codex/agents/devrites-forge-judge.toml +4 -1
  100. package/pack/generated/codex/agents/devrites-frontend-reviewer.toml +3 -0
  101. package/pack/generated/codex/agents/devrites-performance-reviewer.toml +9 -8
  102. package/pack/generated/codex/agents/devrites-plan-reviewer.toml +3 -0
  103. package/pack/generated/codex/agents/devrites-proof-runner.toml +13 -13
  104. package/pack/generated/codex/agents/devrites-retrospector.toml +3 -0
  105. package/pack/generated/codex/agents/devrites-security-auditor.toml +3 -0
  106. package/pack/generated/codex/agents/devrites-simplifier-reviewer.toml +3 -0
  107. package/pack/generated/codex/agents/devrites-slice-wright.toml +4 -2
  108. package/pack/generated/codex/agents/devrites-spec-reviewer.toml +3 -0
  109. package/pack/generated/codex/agents/devrites-strategy-reviewer.toml +3 -0
  110. package/pack/generated/codex/agents/devrites-test-analyst.toml +3 -0
  111. package/pack/generated/codex/hooks.json +1 -1
  112. package/pack/generated/codex/skills/devrites-lib/reference/parallel-dispatch.md +8 -0
  113. package/pack/generated/codex/skills/devrites-lib/reference/standards/agents.md +10 -0
  114. package/pack/generated/codex/skills/devrites-source-driven/SKILL.md +1 -1
  115. package/pack/generated/codex/skills/rite-build/reference/phase-contract.md +17 -3
  116. package/pack/generated/codex/skills/rite-build/reference/wright-dispatch.md +3 -1
  117. package/pack/generated/codex/skills/rite-prove/SKILL.md +25 -14
  118. package/pack/generated/codex/skills/rite-temper/SKILL.md +1 -1
  119. package/pack/generated/codex/skills/rite-upgrade/SKILL.md +1 -1
  120. package/pack/generated/codex/skills/rite-vet/SKILL.md +18 -15
  121. package/package.json +1 -1
  122. package/scripts/codex-generate.sh +1 -1
package/CHANGELOG.md CHANGED
@@ -2,6 +2,21 @@
2
2
 
3
3
  All notable changes to DevRites are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and DevRites adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). Releases are generated automatically by [semantic-release](https://semantic-release.gitbook.io/) from Conventional Commits on `main`.
4
4
 
5
+ ## [3.2.18](https://github.com/ViktorsBaikers/DevRites/compare/v3.2.17...v3.2.18) (2026-07-28)
6
+
7
+ ### Fixed
8
+
9
+ * **devrites:** fail closed on filesystem errors ([e0e6c6c](https://github.com/ViktorsBaikers/DevRites/commit/e0e6c6c5c9114fc423cc704b8031d4bcb3728831))
10
+ * **devrites:** validate archive entry types ([02235fb](https://github.com/ViktorsBaikers/DevRites/commit/02235fb078ff4c15bdd36e58c57cc32e6bba8b75))
11
+
12
+ ## [3.2.17](https://github.com/ViktorsBaikers/DevRites/compare/v3.2.16...v3.2.17) (2026-07-27)
13
+
14
+ ### Fixed
15
+
16
+ * **devrites:** harden agent orchestration and proof gates ([0602313](https://github.com/ViktorsBaikers/DevRites/commit/06023130b918117ae00ab0dadcfab6c000443981))
17
+ * **devrites:** normalize restore prefix on Windows ([27c5646](https://github.com/ViktorsBaikers/DevRites/commit/27c5646a1ed344e38a488dd6fc6584b67f1de09e))
18
+ * **devrites:** preserve restore bytes on Windows ([e6c3233](https://github.com/ViktorsBaikers/DevRites/commit/e6c32332e6ea9137644a8a6f42b542240b44c7e1))
19
+
5
20
  ## [3.2.16](https://github.com/ViktorsBaikers/DevRites/compare/v3.2.15...v3.2.16) (2026-07-27)
6
21
 
7
22
  ### Fixed
package/README.md CHANGED
@@ -25,7 +25,7 @@ final commit, push, and tag, and it requires a typed `GO` confirmation.
25
25
  Unattended runs may create local WIP checkpoint commits along the way, but only
26
26
  Ship collapses and pushes them.
27
27
 
28
- **Status:** [`v3.2.16`](https://github.com/ViktorsBaikers/DevRites/releases/tag/v3.2.16): see [`CHANGELOG.md`](CHANGELOG.md) for release notes.
28
+ **Status:** [`v3.2.18`](https://github.com/ViktorsBaikers/DevRites/releases/tag/v3.2.18): see [`CHANGELOG.md`](CHANGELOG.md) for release notes.
29
29
 
30
30
  ## Quick start
31
31
 
package/engine/Makefile CHANGED
@@ -6,9 +6,9 @@ PLATFORMS := darwin/arm64 darwin/amd64 linux/amd64 linux/arm64 windows/amd64
6
6
 
7
7
  .PHONY: build fmt-check test test-race vet quality staticcheck govulncheck gosec cover crosscompile clean
8
8
 
9
- STATICCHECK_VERSION := 2025.1.1
10
- GOVULNCHECK_VERSION := v1.5.0
11
- GOSEC_VERSION := v2.27.1
9
+ STATICCHECK_VERSION := 2026.1
10
+ GOVULNCHECK_VERSION := v1.6.0
11
+ GOSEC_VERSION := v2.28.0
12
12
  STATICCHECK := go run honnef.co/go/tools/cmd/staticcheck@$(STATICCHECK_VERSION)
13
13
  GOVULNCHECK := go run golang.org/x/vuln/cmd/govulncheck@$(GOVULNCHECK_VERSION)
14
14
  GOSEC := go run github.com/securego/gosec/v2/cmd/gosec@$(GOSEC_VERSION)
package/engine/hooks.go CHANGED
@@ -122,7 +122,9 @@ var allowReadonlyCommands = map[string]bool{
122
122
  }
123
123
 
124
124
  var allowReadonlySubcommands = map[string]map[string]bool{
125
+ "conventions": {"orient": true},
125
126
  "footprint": {"render": true, "roster": true},
127
+ "learnings": {"mine": true},
126
128
  "ledger": {"diff": true, "validate": true, "list": true, "show": true},
127
129
  "reviewer-stats": {"report": true},
128
130
  }
@@ -285,6 +287,21 @@ func devritesAgent(payloadAgent string) devritesAgentKind {
285
287
  }
286
288
  }
287
289
 
290
+ func declaredDevritesRole(payloadAgent string) string {
291
+ payloadAgent = strings.TrimSpace(payloadAgent)
292
+ envAgent := strings.TrimSpace(os.Getenv("DEVRITES_ACTIVE_AGENT"))
293
+ if payloadAgent != "" && envAgent != "" && payloadAgent != envAgent {
294
+ return ""
295
+ }
296
+ if payloadAgent == "" {
297
+ payloadAgent = envAgent
298
+ }
299
+ if strings.HasPrefix(payloadAgent, "devrites-") && len(payloadAgent) > len("devrites-") {
300
+ return payloadAgent
301
+ }
302
+ return ""
303
+ }
304
+
288
305
  func toolBaseName(tool string) string {
289
306
  tool = strings.ToLower(strings.TrimSpace(tool))
290
307
  for _, sep := range []string{"::", "__", "/", "."} {
@@ -352,11 +369,10 @@ func safeReadonlyShellSegment(segment string) bool {
352
369
  fields = fields[1:]
353
370
  continue
354
371
  case "command":
355
- if len(fields) == 2 && fields[1] == "-v" {
372
+ if len(fields) == 3 && fields[1] == "-v" {
356
373
  return true
357
374
  }
358
- fields = fields[1:]
359
- continue
375
+ return false
360
376
  case "env":
361
377
  fields = fields[1:]
362
378
  for len(fields) > 0 && (strings.HasPrefix(fields[0], "-") || strings.Contains(fields[0], "=")) {
@@ -467,7 +483,7 @@ func hookReviewerReadonly(h harness.Harness, stdin io.Reader, stdout, stderr io.
467
483
  return exitOK
468
484
  }
469
485
  in := h.ParseGuardInput(bytes.NewReader(data))
470
- kind := devritesAgentForGuard(h, in)
486
+ kind := devritesAgentBindingForGuard(h, in).Kind
471
487
  if kind == devritesAgentNone &&
472
488
  os.Getenv("DEVRITES_CODEX_GENERIC_AGENT_COMPAT") == "1" &&
473
489
  in.AgentType == "" {
@@ -13,6 +13,7 @@ import (
13
13
  "path/filepath"
14
14
  "regexp"
15
15
  "sort"
16
+ "strconv"
16
17
  "strings"
17
18
  "time"
18
19
 
@@ -35,6 +36,7 @@ var (
35
36
  devritesAgentNameRe = regexp.MustCompile(`^devrites-[a-z0-9-]+$`)
36
37
  skillInvocationRe = regexp.MustCompile(`(?i)(?:^|[^a-z0-9_./-])(?:\$|/)([a-z][a-z0-9-]*)\b`)
37
38
  reconcileCloseRe = regexp.MustCompile(`(?m)(?:^|[;&|]\s*)(?:rtk\s+)?(?:[A-Za-z0-9_./-]+/)?devrites-engine\s+reconcile\s+close\b`)
39
+ dispatchWaiveRe = regexp.MustCompile(`^\s*(?:rtk\s+)?(?:[A-Za-z0-9_./-]+/)?devrites-engine\s+dispatch-waive\s+([a-z-]+)\s*$`)
38
40
  )
39
41
 
40
42
  type agentDispatchHookInput struct {
@@ -48,9 +50,11 @@ type agentDispatchHookInput struct {
48
50
  Prompt string `json:"prompt"`
49
51
  StopHookActive bool `json:"stop_hook_active"`
50
52
  LastAssistantMessage string `json:"last_assistant_message"`
53
+ ToolResponse string `json:"-"`
51
54
  ToolInputRaw json.RawMessage `json:"-"`
52
55
  ToolInput struct {
53
56
  Command string `json:"command"`
57
+ Cmd string `json:"cmd"`
54
58
  AgentType string `json:"agent_type"`
55
59
  ForkTurns string `json:"fork_turns"`
56
60
  Message string `json:"message"`
@@ -59,6 +63,7 @@ type agentDispatchHookInput struct {
59
63
  IDs []string `json:"ids"`
60
64
  AgentIDs []string `json:"agent_ids"`
61
65
  Target string `json:"target"`
66
+ TimeoutMS int `json:"timeout_ms"`
62
67
  } `json:"tool_input"`
63
68
  }
64
69
 
@@ -72,6 +77,7 @@ type agentDispatchEvent struct {
72
77
  Role string `json:"role,omitempty"`
73
78
  WindowID string `json:"window_id,omitempty"`
74
79
  ResultSHA256 string `json:"result_sha256,omitempty"`
80
+ Reason string `json:"reason,omitempty"`
75
81
  AtUnixNano int64 `json:"at_unix_nano"`
76
82
  }
77
83
 
@@ -133,12 +139,20 @@ func parseAgentDispatchHookInput(r io.Reader) (agentDispatchHookInput, error) {
133
139
  return in, err
134
140
  }
135
141
  var raw struct {
136
- ToolInput json.RawMessage `json:"tool_input"`
142
+ ToolInput json.RawMessage `json:"tool_input"`
143
+ ToolResponse json.RawMessage `json:"tool_response"`
137
144
  }
138
145
  if err := json.Unmarshal(data, &raw); err != nil {
139
146
  return in, err
140
147
  }
141
148
  in.ToolInputRaw = raw.ToolInput
149
+ if len(raw.ToolResponse) > 0 {
150
+ if raw.ToolResponse[0] == '"' {
151
+ _ = json.Unmarshal(raw.ToolResponse, &in.ToolResponse)
152
+ } else {
153
+ in.ToolResponse = string(raw.ToolResponse)
154
+ }
155
+ }
142
156
  in.HookEventName = strings.TrimSpace(in.HookEventName)
143
157
  in.SessionID = strings.TrimSpace(in.SessionID)
144
158
  in.TurnID = strings.TrimSpace(in.TurnID)
@@ -431,6 +445,8 @@ func dispatchTurnState(events []agentDispatchEvent, turnID string) (map[string]s
431
445
  switch event.Event {
432
446
  case "armed":
433
447
  armed[event.Role] = struct{}{}
448
+ case "waived":
449
+ delete(armed, event.Role)
434
450
  case "pending":
435
451
  armed[event.Role] = struct{}{}
436
452
  attempt := &agentDispatchAttempt{
@@ -1028,6 +1044,21 @@ func hookAgentDispatch(h harness.Harness, stdin io.Reader, stdout, stderr io.Wri
1028
1044
  return exitOK
1029
1045
  }
1030
1046
  armed, attempts := dispatchTurnState(events, in.TurnID)
1047
+ if reason, ok := dispatchWaiverReason(agentDispatchCommand(in)); ok &&
1048
+ strings.Contains(in.ToolResponse, "dispatch-waive: accepted "+reason) {
1049
+ if err := recordDispatchWaiver(root, in, reason, armed, attempts); err != nil {
1050
+ debugf(stderr, "agent-dispatch post-tool waiver: %v", err)
1051
+ }
1052
+ }
1053
+ if isWaitTool(in.ToolName) {
1054
+ if waitToolSucceeded(in.ToolResponse) {
1055
+ if err := recordCompletedWaits(root, in, attempts); err != nil {
1056
+ debugf(stderr, "agent-dispatch post-tool wait: %v", err)
1057
+ }
1058
+ } else if err := recordCancelledWaits(root, in, attempts); err != nil {
1059
+ debugf(stderr, "agent-dispatch post-tool wait cancellation: %v", err)
1060
+ }
1061
+ }
1031
1062
  if err := refreshPendingWrightBoundary(root, in, armed, attempts); err != nil {
1032
1063
  debugf(stderr, "agent-dispatch post-tool wright boundary: %v", err)
1033
1064
  }
@@ -1058,6 +1089,17 @@ func hookAgentDispatch(h harness.Harness, stdin io.Reader, stdout, stderr io.Wri
1058
1089
  }); err != nil {
1059
1090
  return stopDispatchBlock(h, "DevRites could not record the subagent result: "+err.Error(), stdout, stderr)
1060
1091
  }
1092
+ if active, activeErr := activeWaitIntent(root, in.SessionID, attempt.TurnID, in.AgentID, time.Now()); activeErr != nil {
1093
+ return stopDispatchBlock(h, "DevRites could not verify the awaited subagent result: "+activeErr.Error(), stdout, stderr)
1094
+ } else if active {
1095
+ if err := appendAgentDispatchEvent(root, in.SessionID, agentDispatchEvent{
1096
+ Event: "waited",
1097
+ TurnID: attempt.TurnID,
1098
+ AgentID: in.AgentID,
1099
+ }); err != nil {
1100
+ return stopDispatchBlock(h, "DevRites could not record the awaited subagent result: "+err.Error(), stdout, stderr)
1101
+ }
1102
+ }
1061
1103
  return exitOK
1062
1104
 
1063
1105
  case "Stop":
@@ -1094,6 +1136,27 @@ func hookAgentDispatchPreTool(h harness.Harness, root string, in agentDispatchHo
1094
1136
  }
1095
1137
  armed, attempts := dispatchTurnState(events, in.TurnID)
1096
1138
 
1139
+ command := agentDispatchCommand(in)
1140
+ if isShellTool(in.ToolName) && strings.Contains(command, "dispatch-waive") {
1141
+ reason, ok := dispatchWaiverReason(command)
1142
+ if !ok || !lib.ValidDispatchWaiver(reason) {
1143
+ return preToolDeny(h, "DevRites dispatch waiver must be one exact standalone engine command with an approved reason.", stdout, stderr)
1144
+ }
1145
+ if len(attempts) != 0 {
1146
+ return preToolDeny(h, "DevRites dispatch waiver is only valid before any specialist spawn attempt.", stdout, stderr)
1147
+ }
1148
+ required := 0
1149
+ for role := range armed {
1150
+ if strings.HasPrefix(role, "devrites-") && role != agentDispatchSkillGuard && role != agentDispatchMetadataRole {
1151
+ required++
1152
+ }
1153
+ }
1154
+ if required == 0 {
1155
+ return preToolDeny(h, "DevRites dispatch waiver requires an armed mandatory role in this turn.", stdout, stderr)
1156
+ }
1157
+ return exitOK
1158
+ }
1159
+
1097
1160
  if isAgentDispatchTool(in.ToolName) {
1098
1161
  agentType := in.ToolInput.AgentType
1099
1162
  if agentType == "" {
@@ -1206,46 +1269,25 @@ func hookAgentDispatchPreTool(h harness.Harness, root string, in agentDispatchHo
1206
1269
  }
1207
1270
  return preToolDeny(h, reason, stdout, stderr)
1208
1271
  }
1209
- targets := in.ToolInput.ReceiverThreadIDs
1210
- if len(targets) == 0 {
1211
- targets = in.ToolInput.IDs
1212
- }
1213
- if len(targets) == 0 {
1214
- targets = in.ToolInput.AgentIDs
1215
- }
1272
+ targets := waitTargets(in)
1216
1273
  if len(targets) > 0 {
1217
1274
  known := map[string]*agentDispatchAttempt{}
1218
1275
  for _, attempt := range started {
1219
1276
  known[attempt.AgentID] = attempt
1220
1277
  }
1221
1278
  for _, agentID := range targets {
1222
- attempt := known[agentID]
1223
- if attempt == nil {
1279
+ if known[agentID] == nil {
1224
1280
  return preToolDeny(h, "DevRites wait target is not a confirmed child from this turn.", stdout, stderr)
1225
1281
  }
1226
- if err := appendAgentDispatchEvent(root, in.SessionID, agentDispatchEvent{
1227
- Event: "waited",
1228
- TurnID: in.TurnID,
1229
- AgentID: agentID,
1230
- }); err != nil {
1231
- return preToolDeny(h, "DevRites could not record the child wait: "+err.Error(), stdout, stderr)
1232
- }
1233
1282
  }
1234
- return exitOK
1235
1283
  }
1236
- for _, attempt := range started {
1237
- if err := appendAgentDispatchEvent(root, in.SessionID, agentDispatchEvent{
1238
- Event: "waited",
1239
- TurnID: in.TurnID,
1240
- AgentID: attempt.AgentID,
1241
- }); err != nil {
1242
- return preToolDeny(h, "DevRites could not record the child wait: "+err.Error(), stdout, stderr)
1243
- }
1284
+ if err := recordWaitIntents(root, in, started); err != nil {
1285
+ return preToolDeny(h, "DevRites could not record the wait intent: "+err.Error(), stdout, stderr)
1244
1286
  }
1245
1287
  return exitOK
1246
1288
  }
1247
1289
 
1248
- if isShellTool(in.ToolName) && reconcileCloseRe.MatchString(in.ToolInput.Command) {
1290
+ if isShellTool(in.ToolName) && reconcileCloseRe.MatchString(command) {
1249
1291
  windowID := currentReconcileWindowID()
1250
1292
  if windowID == "" {
1251
1293
  return exitOK
@@ -1283,6 +1325,195 @@ func hookAgentDispatchPreTool(h harness.Harness, root string, in agentDispatchHo
1283
1325
  return exitOK
1284
1326
  }
1285
1327
 
1328
+ func isWaitTool(toolName string) bool {
1329
+ switch toolBaseName(toolName) {
1330
+ case "wait", "wait_agent":
1331
+ return true
1332
+ default:
1333
+ return false
1334
+ }
1335
+ }
1336
+
1337
+ func agentDispatchCommand(in agentDispatchHookInput) string {
1338
+ if in.ToolInput.Command != "" {
1339
+ return in.ToolInput.Command
1340
+ }
1341
+ return in.ToolInput.Cmd
1342
+ }
1343
+
1344
+ func dispatchWaiverReason(command string) (string, bool) {
1345
+ match := dispatchWaiveRe.FindStringSubmatch(command)
1346
+ if len(match) != 2 {
1347
+ return "", false
1348
+ }
1349
+ return match[1], true
1350
+ }
1351
+
1352
+ func recordDispatchWaiver(
1353
+ root string,
1354
+ in agentDispatchHookInput,
1355
+ reason string,
1356
+ armed map[string]struct{},
1357
+ attempts []*agentDispatchAttempt,
1358
+ ) error {
1359
+ if len(attempts) != 0 || !lib.ValidDispatchWaiver(reason) {
1360
+ return fmt.Errorf("waiver is not valid after a spawn attempt")
1361
+ }
1362
+ recorded := 0
1363
+ for role := range armed {
1364
+ if !strings.HasPrefix(role, "devrites-") || role == agentDispatchSkillGuard || role == agentDispatchMetadataRole {
1365
+ continue
1366
+ }
1367
+ if err := appendAgentDispatchEvent(root, in.SessionID, agentDispatchEvent{
1368
+ Event: "waived",
1369
+ TurnID: in.TurnID,
1370
+ Role: role,
1371
+ Reason: reason,
1372
+ }); err != nil {
1373
+ return err
1374
+ }
1375
+ recorded++
1376
+ }
1377
+ if recorded == 0 {
1378
+ return fmt.Errorf("no armed mandatory role to waive")
1379
+ }
1380
+ return nil
1381
+ }
1382
+
1383
+ func waitTargets(in agentDispatchHookInput) []string {
1384
+ if len(in.ToolInput.ReceiverThreadIDs) > 0 {
1385
+ return in.ToolInput.ReceiverThreadIDs
1386
+ }
1387
+ if len(in.ToolInput.IDs) > 0 {
1388
+ return in.ToolInput.IDs
1389
+ }
1390
+ return in.ToolInput.AgentIDs
1391
+ }
1392
+
1393
+ func waitToolSucceeded(response string) bool {
1394
+ response = strings.ToLower(strings.TrimSpace(response))
1395
+ if response == "" {
1396
+ return false
1397
+ }
1398
+ for _, failure := range []string{
1399
+ `"timed_out":true`,
1400
+ `"timed_out": true`,
1401
+ "timed out",
1402
+ "timeout summary",
1403
+ "no activity",
1404
+ } {
1405
+ if strings.Contains(response, failure) {
1406
+ return false
1407
+ }
1408
+ }
1409
+ return true
1410
+ }
1411
+
1412
+ func waitTargetIDs(in agentDispatchHookInput, attempts []*agentDispatchAttempt) []string {
1413
+ targets := waitTargets(in)
1414
+ if len(targets) > 0 {
1415
+ return targets
1416
+ }
1417
+ targets = make([]string, 0, len(attempts))
1418
+ for _, attempt := range attempts {
1419
+ if attempt.Started && attempt.AgentID != "" {
1420
+ targets = append(targets, attempt.AgentID)
1421
+ }
1422
+ }
1423
+ return targets
1424
+ }
1425
+
1426
+ func agentWaitTimeout(in agentDispatchHookInput) time.Duration {
1427
+ const defaultTimeout = 30 * time.Second
1428
+ const maxTimeout = time.Hour
1429
+ if in.ToolInput.TimeoutMS <= 0 {
1430
+ return defaultTimeout
1431
+ }
1432
+ timeout := time.Duration(in.ToolInput.TimeoutMS) * time.Millisecond
1433
+ if timeout > maxTimeout {
1434
+ return maxTimeout
1435
+ }
1436
+ return timeout
1437
+ }
1438
+
1439
+ func recordWaitIntents(root string, in agentDispatchHookInput, attempts []*agentDispatchAttempt) error {
1440
+ timeoutMS := strconv.FormatInt(agentWaitTimeout(in).Milliseconds(), 10)
1441
+ for _, agentID := range waitTargetIDs(in, attempts) {
1442
+ if err := appendAgentDispatchEvent(root, in.SessionID, agentDispatchEvent{
1443
+ Event: "waiting",
1444
+ TurnID: in.TurnID,
1445
+ AgentID: agentID,
1446
+ Reason: timeoutMS,
1447
+ }); err != nil {
1448
+ return err
1449
+ }
1450
+ }
1451
+ return nil
1452
+ }
1453
+
1454
+ func recordCancelledWaits(root string, in agentDispatchHookInput, attempts []*agentDispatchAttempt) error {
1455
+ for _, agentID := range waitTargetIDs(in, attempts) {
1456
+ if err := appendAgentDispatchEvent(root, in.SessionID, agentDispatchEvent{
1457
+ Event: "wait-cancelled",
1458
+ TurnID: in.TurnID,
1459
+ AgentID: agentID,
1460
+ }); err != nil {
1461
+ return err
1462
+ }
1463
+ }
1464
+ return nil
1465
+ }
1466
+
1467
+ func activeWaitIntent(root, sessionID, turnID, agentID string, now time.Time) (bool, error) {
1468
+ events, err := readAgentDispatchEvents(root, sessionID)
1469
+ if err != nil {
1470
+ return false, err
1471
+ }
1472
+ for i := len(events) - 1; i >= 0; i-- {
1473
+ event := events[i]
1474
+ if event.TurnID != turnID || event.AgentID != agentID {
1475
+ continue
1476
+ }
1477
+ switch event.Event {
1478
+ case "waited", "wait-cancelled":
1479
+ return false, nil
1480
+ case "waiting":
1481
+ timeoutMS, err := strconv.ParseInt(event.Reason, 10, 64)
1482
+ if err != nil || timeoutMS <= 0 {
1483
+ return false, nil
1484
+ }
1485
+ deadline := time.Unix(0, event.AtUnixNano).Add(time.Duration(timeoutMS) * time.Millisecond)
1486
+ return !now.After(deadline), nil
1487
+ }
1488
+ }
1489
+ return false, nil
1490
+ }
1491
+
1492
+ func recordCompletedWaits(root string, in agentDispatchHookInput, attempts []*agentDispatchAttempt) error {
1493
+ targetSet := map[string]struct{}{}
1494
+ for _, agentID := range waitTargets(in) {
1495
+ targetSet[agentID] = struct{}{}
1496
+ }
1497
+ for _, attempt := range attempts {
1498
+ if !attempt.Started || attempt.Waited {
1499
+ continue
1500
+ }
1501
+ if len(targetSet) > 0 {
1502
+ if _, ok := targetSet[attempt.AgentID]; !ok {
1503
+ continue
1504
+ }
1505
+ }
1506
+ if err := appendAgentDispatchEvent(root, in.SessionID, agentDispatchEvent{
1507
+ Event: "waited",
1508
+ TurnID: in.TurnID,
1509
+ AgentID: attempt.AgentID,
1510
+ }); err != nil {
1511
+ return err
1512
+ }
1513
+ }
1514
+ return nil
1515
+ }
1516
+
1286
1517
  func bindAgentDispatchStart(root string, in agentDispatchHookInput) (string, bool, error) {
1287
1518
  events, err := readAgentDispatchEvents(root, in.SessionID)
1288
1519
  if err != nil {
@@ -1294,6 +1525,7 @@ func bindAgentDispatchStart(root string, in agentDispatchHookInput) (string, boo
1294
1525
  started[event.ToolUseID] = struct{}{}
1295
1526
  }
1296
1527
  }
1528
+ var candidates []agentDispatchEvent
1297
1529
  for i := len(events) - 1; i >= 0; i-- {
1298
1530
  event := events[i]
1299
1531
  if event.Event != "pending" || event.AgentType != in.AgentType {
@@ -1302,20 +1534,30 @@ func bindAgentDispatchStart(root string, in agentDispatchHookInput) (string, boo
1302
1534
  if _, ok := started[event.ToolUseID]; ok {
1303
1535
  continue
1304
1536
  }
1305
- if err := appendAgentDispatchEvent(root, in.SessionID, agentDispatchEvent{
1306
- Event: "started",
1307
- TurnID: event.TurnID,
1308
- ToolUseID: event.ToolUseID,
1309
- AgentID: in.AgentID,
1310
- AgentType: in.AgentType,
1311
- Role: event.Role,
1312
- WindowID: event.WindowID,
1313
- }); err != nil {
1314
- return "", false, err
1537
+ if strings.HasPrefix(in.AgentType, "devrites-") && event.Role != in.AgentType {
1538
+ continue
1315
1539
  }
1316
- return event.Role, true, nil
1540
+ candidates = append(candidates, event)
1541
+ }
1542
+ if len(candidates) == 0 {
1543
+ return "", false, nil
1544
+ }
1545
+ if len(candidates) != 1 {
1546
+ return "", false, fmt.Errorf("ambiguous subagent start: %d pending %s dispatches lack a parent tool-use binding", len(candidates), in.AgentType)
1317
1547
  }
1318
- return "", false, nil
1548
+ event := candidates[0]
1549
+ if err := appendAgentDispatchEvent(root, in.SessionID, agentDispatchEvent{
1550
+ Event: "started",
1551
+ TurnID: event.TurnID,
1552
+ ToolUseID: event.ToolUseID,
1553
+ AgentID: in.AgentID,
1554
+ AgentType: in.AgentType,
1555
+ Role: event.Role,
1556
+ WindowID: event.WindowID,
1557
+ }); err != nil {
1558
+ return "", false, err
1559
+ }
1560
+ return event.Role, true, nil
1319
1561
  }
1320
1562
 
1321
1563
  type boundAgentDispatchAttempt struct {
@@ -1342,29 +1584,36 @@ func boundDispatchAttempt(root, sessionID, agentID string) (boundAgentDispatchAt
1342
1584
  return boundAgentDispatchAttempt{}, false, nil
1343
1585
  }
1344
1586
 
1345
- func devritesAgentForGuard(h harness.Harness, in harness.GuardInput) devritesAgentKind {
1587
+ type devritesAgentGuardBinding struct {
1588
+ Kind devritesAgentKind
1589
+ Role string
1590
+ }
1591
+
1592
+ func devritesAgentBindingForGuard(h harness.Harness, in harness.GuardInput) devritesAgentGuardBinding {
1346
1593
  kind := devritesAgent(in.AgentType)
1347
- if h != harness.Codex || kind != devritesAgentNone ||
1594
+ binding := devritesAgentGuardBinding{Kind: kind, Role: declaredDevritesRole(in.AgentType)}
1595
+ generic := in.AgentType == "" || in.AgentType == "explorer" || in.AgentType == "worker"
1596
+ if h != harness.Codex || !generic ||
1348
1597
  os.Getenv("DEVRITES_CODEX_GENERIC_AGENT_COMPAT") != "1" ||
1349
1598
  in.SessionID == "" || in.AgentID == "" {
1350
- return kind
1599
+ return binding
1351
1600
  }
1352
1601
  root, ok := agentDispatchRoot()
1353
1602
  if !ok {
1354
- return devritesAgentInvalid
1603
+ return devritesAgentGuardBinding{Kind: devritesAgentInvalid}
1355
1604
  }
1356
1605
  attempt, found, err := boundDispatchAttempt(root, in.SessionID, in.AgentID)
1357
1606
  if err != nil {
1358
- return devritesAgentInvalid
1607
+ return devritesAgentGuardBinding{Kind: devritesAgentInvalid}
1359
1608
  }
1360
1609
  if !found {
1361
- return devritesAgentNone
1610
+ return binding
1362
1611
  }
1363
1612
  if attempt.Role == "devrites-slice-wright" {
1364
- return devritesAgentGenericWright
1613
+ return devritesAgentGuardBinding{Kind: devritesAgentGenericWright, Role: attempt.Role}
1365
1614
  }
1366
1615
  if strings.HasPrefix(attempt.Role, "devrites-") {
1367
- return devritesAgentReadonly
1616
+ return devritesAgentGuardBinding{Kind: devritesAgentReadonly, Role: attempt.Role}
1368
1617
  }
1369
- return devritesAgentInvalid
1618
+ return devritesAgentGuardBinding{Kind: devritesAgentInvalid}
1370
1619
  }
@@ -7,6 +7,7 @@ import (
7
7
  "path/filepath"
8
8
  "strings"
9
9
  "testing"
10
+ "time"
10
11
 
11
12
  "github.com/devrites/devrites/internal/harness"
12
13
  "github.com/devrites/devrites/internal/lib"
@@ -81,6 +82,45 @@ func TestStopGateRecordsTypedDecisionForBothHosts(t *testing.T) {
81
82
  }
82
83
  }
83
84
 
85
+ func TestWaitIntentCoversSubagentStopBeforePostToolUse(t *testing.T) {
86
+ root := filepath.Join(t.TempDir(), ".devrites")
87
+ in := agentDispatchHookInput{SessionID: "session", TurnID: "turn"}
88
+ in.ToolInput.IDs = []string{"agent-1"}
89
+ in.ToolInput.TimeoutMS = 1_000
90
+ attempts := []*agentDispatchAttempt{{
91
+ AgentID: "agent-1",
92
+ Started: true,
93
+ }}
94
+
95
+ if err := recordWaitIntents(root, in, attempts); err != nil {
96
+ t.Fatal(err)
97
+ }
98
+ active, err := activeWaitIntent(root, in.SessionID, in.TurnID, "agent-1", time.Now())
99
+ if err != nil {
100
+ t.Fatal(err)
101
+ }
102
+ if !active {
103
+ t.Fatal("fresh wait intent is inactive")
104
+ }
105
+ active, err = activeWaitIntent(root, in.SessionID, in.TurnID, "agent-1", time.Now().Add(2*time.Second))
106
+ if err != nil {
107
+ t.Fatal(err)
108
+ }
109
+ if active {
110
+ t.Fatal("expired wait intent remained active")
111
+ }
112
+ if err := recordCancelledWaits(root, in, attempts); err != nil {
113
+ t.Fatal(err)
114
+ }
115
+ active, err = activeWaitIntent(root, in.SessionID, in.TurnID, "agent-1", time.Now())
116
+ if err != nil {
117
+ t.Fatal(err)
118
+ }
119
+ if active {
120
+ t.Fatal("cancelled wait intent remained active")
121
+ }
122
+ }
123
+
84
124
  func TestStopGateMalformedInputRecordsUnavailableWithoutBlocking(t *testing.T) {
85
125
  root := makeHookWorkspace(t)
86
126
  if code := hookStopGate(harness.Claude, strings.NewReader(`{"stop_hook_active":"false"}`), &bytes.Buffer{}, &bytes.Buffer{}); code != 0 {