devrites 3.2.20 → 3.2.21

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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,12 @@
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.21](https://github.com/ViktorsBaikers/DevRites/compare/v3.2.20...v3.2.21) (2026-07-28)
6
+
7
+ ### Fixed
8
+
9
+ * **devrites:** bound Codex dispatch stop retries ([4ef9d5b](https://github.com/ViktorsBaikers/DevRites/commit/4ef9d5baf3ca74b1d494f535e8820447e9c37129))
10
+
5
11
  ## [3.2.20](https://github.com/ViktorsBaikers/DevRites/compare/v3.2.19...v3.2.20) (2026-07-28)
6
12
 
7
13
  ### 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.20`](https://github.com/ViktorsBaikers/DevRites/releases/tag/v3.2.20): see [`CHANGELOG.md`](CHANGELOG.md) for release notes.
28
+ **Status:** [`v3.2.21`](https://github.com/ViktorsBaikers/DevRites/releases/tag/v3.2.21): see [`CHANGELOG.md`](CHANGELOG.md) for release notes.
29
29
 
30
30
  ## Quick start
31
31
 
@@ -49,6 +49,7 @@ var hookRegistry = map[string]hookDefinition{
49
49
 
50
50
  // Standard hooks add the default gates, guards, sentinels, and caches.
51
51
  "a1-guard": {tier: tierStandard, canBlock: true},
52
+ "agent-dispatch": {tier: tierStandard, canBlock: true},
52
53
  "git-guard": {tier: tierStandard, canBlock: true},
53
54
  "stop-gate": {tier: tierStandard, canBlock: true},
54
55
  "reviewer-readonly": {tier: tierStandard, canBlock: true},
@@ -81,6 +81,12 @@ func TestBlockingHookExitAudit(t *testing.T) {
81
81
  bound: "one block before the host re-enters with stop_hook_active=true",
82
82
  killPath: "DEVRITES_DISABLED_HOOKS=stop-gate or DEVRITES_HOOK_PROFILE=minimal",
83
83
  },
84
+ "agent-dispatch": {
85
+ reentry: "explicit stop_hook_active=true terminates the re-entered Stop with the same failure",
86
+ malformed: "missing or malformed event, session, or turn identity fails open",
87
+ bound: "one block before the host re-enters with stop_hook_active=true",
88
+ killPath: "DEVRITES_DISABLED_HOOKS=agent-dispatch or DEVRITES_HOOK_PROFILE=minimal",
89
+ },
84
90
  "reviewer-readonly": {
85
91
  reentry: "not applicable: each PreToolUse decision applies to one tool request",
86
92
  malformed: "missing or malformed tool identity fails open",
@@ -527,7 +527,7 @@ func durableCodexV2DispatchAttempts(
527
527
  }
528
528
  if !devritesAgentNameRe.MatchString(role) || role == agentDispatchSkillGuard {
529
529
  if strings.TrimSpace(spawn.Result) != "" {
530
- return nil, fmt.Errorf("codex V2 completed a default or non-DevRites child during a DevRites skill turn; %s", conditionalDispatchInstruction())
530
+ return nil, fmt.Errorf("codex V2 completed a default or non-DevRites child during a DevRites skill turn; this turn is tainted and cannot be repaired by another dispatch. Do not use the child result or advance workflow state. End this turn, then retry the skill in a fresh turn; %s", conditionalDispatchInstruction())
531
531
  }
532
532
  continue
533
533
  }
@@ -1105,7 +1105,11 @@ func hookAgentDispatch(h harness.Harness, stdin io.Reader, stdout, stderr io.Wri
1105
1105
  case "Stop":
1106
1106
  events, err := readAgentDispatchEvents(root, in.SessionID)
1107
1107
  if err != nil {
1108
- return stopDispatchBlock(h, "DevRites could not verify the required agent dispatch: "+err.Error(), stdout, stderr)
1108
+ reason := "DevRites could not verify the required agent dispatch: " + err.Error()
1109
+ if in.StopHookActive {
1110
+ return stopDispatchFailure(reason, stdout, stderr)
1111
+ }
1112
+ return stopDispatchBlock(h, reason, stdout, stderr)
1109
1113
  }
1110
1114
  armed, attempts := dispatchTurnState(events, in.TurnID)
1111
1115
  _, guarded := armed[agentDispatchSkillGuard]
@@ -1114,7 +1118,11 @@ func hookAgentDispatch(h harness.Harness, stdin io.Reader, stdout, stderr io.Wri
1114
1118
  root, in.SessionID, in.TurnID, armed,
1115
1119
  )
1116
1120
  if durableErr != nil {
1117
- return stopDispatchBlock(h, "DevRites could not verify the Codex V2 agent dispatch: "+durableErr.Error(), stdout, stderr)
1121
+ reason := "DevRites could not verify the Codex V2 agent dispatch: " + durableErr.Error()
1122
+ if in.StopHookActive {
1123
+ return stopDispatchFailure(reason, stdout, stderr)
1124
+ }
1125
+ return stopDispatchBlock(h, reason, stdout, stderr)
1118
1126
  }
1119
1127
  attempts = append(attempts, durable...)
1120
1128
  }
@@ -6,6 +6,8 @@ package main_test
6
6
 
7
7
  import (
8
8
  "bytes"
9
+ "crypto/sha256"
10
+ "encoding/hex"
9
11
  "encoding/json"
10
12
  "fmt"
11
13
  "os"
@@ -2142,9 +2144,98 @@ func TestCodexAgentDispatchRejectsConditionalDurableV2DefaultChild(t *testing.T)
2142
2144
  }
2143
2145
  if decision.Decision != "block" ||
2144
2146
  !strings.Contains(decision.Reason, "default or non-DevRites child") ||
2147
+ !strings.Contains(decision.Reason, "cannot be repaired by another dispatch") ||
2148
+ !strings.Contains(decision.Reason, "retry the skill in a fresh turn") ||
2145
2149
  !strings.Contains(decision.Reason, "never use a default child") {
2146
2150
  t.Fatalf("conditional V2 default child passed: %#v", decision)
2147
2151
  }
2152
+
2153
+ stop = hookPayload(t, map[string]any{
2154
+ "hook_event_name": "Stop",
2155
+ "session_id": sessionID,
2156
+ "turn_id": turnID,
2157
+ "stop_hook_active": true,
2158
+ })
2159
+ out, stderr, code = runDevritesIO(t, root, stop,
2160
+ []string{"CODEX_HOME=" + codeHome},
2161
+ "hook", "agent-dispatch", "--harness=codex")
2162
+ if code != 0 {
2163
+ t.Fatalf("conditional default reentry exit=%d stderr=%s", code, stderr)
2164
+ }
2165
+ var terminal struct {
2166
+ Continue bool `json:"continue"`
2167
+ StopReason string `json:"stopReason"`
2168
+ }
2169
+ if err := json.Unmarshal([]byte(strings.TrimSpace(out)), &terminal); err != nil {
2170
+ t.Fatalf("invalid conditional default reentry response: %v\n%s", err, out)
2171
+ }
2172
+ if terminal.Continue ||
2173
+ !strings.Contains(terminal.StopReason, "default or non-DevRites child") ||
2174
+ !strings.Contains(terminal.StopReason, "retry the skill in a fresh turn") {
2175
+ t.Fatalf("conditional V2 default child reentry did not stop: %#v", terminal)
2176
+ }
2177
+ }
2178
+
2179
+ func TestCodexAgentDispatchTerminatesCorruptStateReentry(t *testing.T) {
2180
+ root := newWorkspace(t)
2181
+ sessionID, turnID := "session-corrupt-state", "turn-corrupt-state"
2182
+
2183
+ runDevritesIO(t, root, hookPayload(t, map[string]any{
2184
+ "hook_event_name": "UserPromptSubmit",
2185
+ "session_id": sessionID,
2186
+ "turn_id": turnID,
2187
+ "prompt": "$rite-status",
2188
+ }), nil, "hook", "agent-dispatch", "--harness=codex")
2189
+
2190
+ resolvedRoot, err := filepath.EvalSymlinks(root)
2191
+ if err != nil {
2192
+ t.Fatal(err)
2193
+ }
2194
+ rootSum := sha256.Sum256([]byte(filepath.Clean(resolvedRoot)))
2195
+ sessionSum := sha256.Sum256([]byte(sessionID))
2196
+ statePath := filepath.Join(
2197
+ os.TempDir(),
2198
+ "devrites-agent-dispatch-v1",
2199
+ hex.EncodeToString(rootSum[:]),
2200
+ hex.EncodeToString(sessionSum[:])+".jsonl",
2201
+ )
2202
+ if err := os.MkdirAll(filepath.Dir(statePath), 0o700); err != nil {
2203
+ t.Fatal(err)
2204
+ }
2205
+ if err := os.WriteFile(statePath, []byte("not-json\n"), 0o600); err != nil {
2206
+ t.Fatal(err)
2207
+ }
2208
+
2209
+ stop := func(active bool) string {
2210
+ out, stderr, code := runDevritesIO(t, root, hookPayload(t, map[string]any{
2211
+ "hook_event_name": "Stop",
2212
+ "session_id": sessionID,
2213
+ "turn_id": turnID,
2214
+ "stop_hook_active": active,
2215
+ }), nil, "hook", "agent-dispatch", "--harness=codex")
2216
+ if code != 0 {
2217
+ t.Fatalf("corrupt state stop active=%t exit=%d stderr=%s", active, code, stderr)
2218
+ }
2219
+ return out
2220
+ }
2221
+
2222
+ var blocked struct {
2223
+ Decision string `json:"decision"`
2224
+ }
2225
+ if out := stop(false); json.Unmarshal([]byte(strings.TrimSpace(out)), &blocked) != nil ||
2226
+ blocked.Decision != "block" {
2227
+ t.Fatalf("corrupt state first Stop did not block: %s", out)
2228
+ }
2229
+
2230
+ var terminal struct {
2231
+ Continue bool `json:"continue"`
2232
+ StopReason string `json:"stopReason"`
2233
+ }
2234
+ if out := stop(true); json.Unmarshal([]byte(strings.TrimSpace(out)), &terminal) != nil ||
2235
+ terminal.Continue ||
2236
+ !strings.Contains(terminal.StopReason, "invalid agent dispatch state") {
2237
+ t.Fatalf("corrupt state reentry did not stop: %s", out)
2238
+ }
2148
2239
  }
2149
2240
 
2150
2241
  func TestCodexAgentDispatchRejectsIncompleteDurableV2(t *testing.T) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "devrites",
3
- "version": "3.2.20",
3
+ "version": "3.2.21",
4
4
  "description": "DevRites: a disciplined senior-engineer workflow pack for Claude Code and Codex",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "homepage": "https://github.com/ViktorsBaikers/DevRites#readme",