devrites 3.2.20 → 3.2.22

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,18 @@
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.22](https://github.com/ViktorsBaikers/DevRites/compare/v3.2.21...v3.2.22) (2026-07-28)
6
+
7
+ ### Fixed
8
+
9
+ * **devrites:** prevent receipt-only agent spawns ([4cf4c19](https://github.com/ViktorsBaikers/DevRites/commit/4cf4c19cf8671b8fdd497b8875c50bd50faf85d9))
10
+
11
+ ## [3.2.21](https://github.com/ViktorsBaikers/DevRites/compare/v3.2.20...v3.2.21) (2026-07-28)
12
+
13
+ ### Fixed
14
+
15
+ * **devrites:** bound Codex dispatch stop retries ([4ef9d5b](https://github.com/ViktorsBaikers/DevRites/commit/4ef9d5baf3ca74b1d494f535e8820447e9c37129))
16
+
5
17
  ## [3.2.20](https://github.com/ViktorsBaikers/DevRites/compare/v3.2.19...v3.2.20) (2026-07-28)
6
18
 
7
19
  ### 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.22`](https://github.com/ViktorsBaikers/DevRites/releases/tag/v3.2.22): 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
  }
@@ -853,6 +853,10 @@ func conditionalDispatchInstruction() string {
853
853
  return "CONDITIONAL DISPATCH RULE — If this skill reaches a child-agent step, call spawn_agent with the exact named agent_type=devrites-<role> specified by the skill, a unique task_name, and fork_turns=\"none\". GPT-5.6 V2 may omit agent_type from the visible tool schema; send agent_type anyway and never use a default child. Wait for the returned child and use its non-empty result."
854
854
  }
855
855
 
856
+ func preDispatchStopInstruction() string {
857
+ return "PRE-DISPATCH STOP RULE — If a deterministic prerequisite forces STOP before any spawn attempt, do not spawn only to satisfy this receipt. Run exactly one successful standalone devrites-engine dispatch-waive <reason> before replying. Use only the matching observed predicate: no-active-workspace, wrong-phase, readiness-failed, no-eligible-work, or human-gate-before-dispatch. The successful PostToolUse receipt is required; never waive after a spawn attempt."
858
+ }
859
+
856
860
  func incompleteDispatchReason(armed map[string]struct{}, attempts []*agentDispatchAttempt) string {
857
861
  roles := make([]string, 0, len(armed))
858
862
  for role := range armed {
@@ -871,7 +875,11 @@ func incompleteDispatchReason(armed map[string]struct{}, attempts []*agentDispat
871
875
  if role == agentDispatchMetadataRole {
872
876
  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."
873
877
  }
874
- return fmt.Sprintf("DevRites dispatch for %s is not complete. %s", role, dispatchInstruction(role))
878
+ reason := fmt.Sprintf("DevRites dispatch for %s is not complete. %s", role, dispatchInstruction(role))
879
+ if len(attempts) == 0 {
880
+ reason += " " + preDispatchStopInstruction()
881
+ }
882
+ return reason
875
883
  }
876
884
 
877
885
  func preToolDeny(h harness.Harness, reason string, stdout, stderr io.Writer) int {
@@ -1022,8 +1030,9 @@ func hookAgentDispatch(h harness.Harness, stdin io.Reader, stdout, stderr io.Wri
1022
1030
  }
1023
1031
  fmt.Fprintf(
1024
1032
  stdout,
1025
- "MANDATORY DISPATCH THIS TURN — At the skill's dispatch step, execute every required child before finishing or claiming completion; a success phrase is not evidence.\n%s",
1033
+ "MANDATORY DISPATCH THIS TURN — At the skill's dispatch step, execute every required child before finishing or claiming completion; a success phrase is not evidence.\n%s\n%s",
1026
1034
  strings.Join(instructions, "\n"),
1035
+ preDispatchStopInstruction(),
1027
1036
  )
1028
1037
  }
1029
1038
  if skillInvoked {
@@ -1105,7 +1114,11 @@ func hookAgentDispatch(h harness.Harness, stdin io.Reader, stdout, stderr io.Wri
1105
1114
  case "Stop":
1106
1115
  events, err := readAgentDispatchEvents(root, in.SessionID)
1107
1116
  if err != nil {
1108
- return stopDispatchBlock(h, "DevRites could not verify the required agent dispatch: "+err.Error(), stdout, stderr)
1117
+ reason := "DevRites could not verify the required agent dispatch: " + err.Error()
1118
+ if in.StopHookActive {
1119
+ return stopDispatchFailure(reason, stdout, stderr)
1120
+ }
1121
+ return stopDispatchBlock(h, reason, stdout, stderr)
1109
1122
  }
1110
1123
  armed, attempts := dispatchTurnState(events, in.TurnID)
1111
1124
  _, guarded := armed[agentDispatchSkillGuard]
@@ -1114,7 +1127,11 @@ func hookAgentDispatch(h harness.Harness, stdin io.Reader, stdout, stderr io.Wri
1114
1127
  root, in.SessionID, in.TurnID, armed,
1115
1128
  )
1116
1129
  if durableErr != nil {
1117
- return stopDispatchBlock(h, "DevRites could not verify the Codex V2 agent dispatch: "+durableErr.Error(), stdout, stderr)
1130
+ reason := "DevRites could not verify the Codex V2 agent dispatch: " + durableErr.Error()
1131
+ if in.StopHookActive {
1132
+ return stopDispatchFailure(reason, stdout, stderr)
1133
+ }
1134
+ return stopDispatchBlock(h, reason, stdout, stderr)
1118
1135
  }
1119
1136
  attempts = append(attempts, durable...)
1120
1137
  }
@@ -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"
@@ -1439,6 +1441,12 @@ func TestCodexAgentDispatchPromptGivesExactNamedCallBeforeCompletion(t *testing.
1439
1441
  ".codex/agents/" + role + ".toml",
1440
1442
  "send agent_type anyway",
1441
1443
  "Wait for the returned child",
1444
+ "PRE-DISPATCH STOP RULE",
1445
+ "do not spawn only to satisfy this receipt",
1446
+ "devrites-engine dispatch-waive <reason>",
1447
+ "readiness-failed",
1448
+ "human-gate-before-dispatch",
1449
+ "never waive after a spawn attempt",
1442
1450
  } {
1443
1451
  if !strings.Contains(out, want) {
1444
1452
  t.Fatalf("initial dispatch guidance omitted %q:\n%s", want, out)
@@ -1690,7 +1698,9 @@ func TestCodexAgentDispatchBlocksFalseWaitAndStop(t *testing.T) {
1690
1698
  if stopDecision.Decision != "block" ||
1691
1699
  !strings.Contains(stopDecision.Reason, "spawn_agent") ||
1692
1700
  !strings.Contains(stopDecision.Reason, "visible tool schema") ||
1693
- !strings.Contains(stopDecision.Reason, "send agent_type anyway") {
1701
+ !strings.Contains(stopDecision.Reason, "send agent_type anyway") ||
1702
+ !strings.Contains(stopDecision.Reason, "do not spawn only to satisfy this receipt") ||
1703
+ !strings.Contains(stopDecision.Reason, "dispatch-waive <reason>") {
1694
1704
  t.Fatalf("false completion not blocked: %#v", stopDecision)
1695
1705
  }
1696
1706
 
@@ -1743,7 +1753,7 @@ func TestCodexAgentDispatchRequiresSuccessfulReasonBoundWaiver(t *testing.T) {
1743
1753
  t.Fatalf("chained waiver was not denied: %s", out)
1744
1754
  }
1745
1755
 
1746
- command := "devrites-engine dispatch-waive wrong-phase"
1756
+ command := "devrites-engine dispatch-waive readiness-failed"
1747
1757
  pre := hookPayload(t, map[string]any{
1748
1758
  "hook_event_name": "PreToolUse",
1749
1759
  "session_id": sessionID,
@@ -1773,7 +1783,7 @@ func TestCodexAgentDispatchRequiresSuccessfulReasonBoundWaiver(t *testing.T) {
1773
1783
  "turn_id": turnID,
1774
1784
  "tool_name": "functions.exec_command",
1775
1785
  "tool_input": map[string]any{"cmd": command},
1776
- "tool_response": "dispatch-waive: accepted wrong-phase",
1786
+ "tool_response": "dispatch-waive: accepted readiness-failed",
1777
1787
  })
1778
1788
  if out, stderr, code := runDevritesIO(t, root, post, nil,
1779
1789
  "hook", "agent-dispatch", "--harness=codex"); code != 0 || strings.TrimSpace(out) != "" {
@@ -1885,6 +1895,7 @@ func TestCodexAgentDispatchConfirmsGenericRoleAndResult(t *testing.T) {
1885
1895
  }
1886
1896
  var blocked struct {
1887
1897
  Decision string `json:"decision"`
1898
+ Reason string `json:"reason"`
1888
1899
  }
1889
1900
  if err := json.Unmarshal([]byte(strings.TrimSpace(out)), &blocked); err != nil {
1890
1901
  t.Fatalf("invalid unawaited Stop response: %v\n%s", err, out)
@@ -1892,6 +1903,9 @@ func TestCodexAgentDispatchConfirmsGenericRoleAndResult(t *testing.T) {
1892
1903
  if blocked.Decision != "block" {
1893
1904
  t.Fatalf("unawaited child result passed Stop: %s", out)
1894
1905
  }
1906
+ if strings.Contains(blocked.Reason, "dispatch-waive") {
1907
+ t.Fatalf("unavailable post-spawn waiver was suggested: %s", out)
1908
+ }
1895
1909
 
1896
1910
  wait := hookPayload(t, map[string]any{
1897
1911
  "hook_event_name": "PreToolUse",
@@ -2142,9 +2156,98 @@ func TestCodexAgentDispatchRejectsConditionalDurableV2DefaultChild(t *testing.T)
2142
2156
  }
2143
2157
  if decision.Decision != "block" ||
2144
2158
  !strings.Contains(decision.Reason, "default or non-DevRites child") ||
2159
+ !strings.Contains(decision.Reason, "cannot be repaired by another dispatch") ||
2160
+ !strings.Contains(decision.Reason, "retry the skill in a fresh turn") ||
2145
2161
  !strings.Contains(decision.Reason, "never use a default child") {
2146
2162
  t.Fatalf("conditional V2 default child passed: %#v", decision)
2147
2163
  }
2164
+
2165
+ stop = hookPayload(t, map[string]any{
2166
+ "hook_event_name": "Stop",
2167
+ "session_id": sessionID,
2168
+ "turn_id": turnID,
2169
+ "stop_hook_active": true,
2170
+ })
2171
+ out, stderr, code = runDevritesIO(t, root, stop,
2172
+ []string{"CODEX_HOME=" + codeHome},
2173
+ "hook", "agent-dispatch", "--harness=codex")
2174
+ if code != 0 {
2175
+ t.Fatalf("conditional default reentry exit=%d stderr=%s", code, stderr)
2176
+ }
2177
+ var terminal struct {
2178
+ Continue bool `json:"continue"`
2179
+ StopReason string `json:"stopReason"`
2180
+ }
2181
+ if err := json.Unmarshal([]byte(strings.TrimSpace(out)), &terminal); err != nil {
2182
+ t.Fatalf("invalid conditional default reentry response: %v\n%s", err, out)
2183
+ }
2184
+ if terminal.Continue ||
2185
+ !strings.Contains(terminal.StopReason, "default or non-DevRites child") ||
2186
+ !strings.Contains(terminal.StopReason, "retry the skill in a fresh turn") {
2187
+ t.Fatalf("conditional V2 default child reentry did not stop: %#v", terminal)
2188
+ }
2189
+ }
2190
+
2191
+ func TestCodexAgentDispatchTerminatesCorruptStateReentry(t *testing.T) {
2192
+ root := newWorkspace(t)
2193
+ sessionID, turnID := "session-corrupt-state", "turn-corrupt-state"
2194
+
2195
+ runDevritesIO(t, root, hookPayload(t, map[string]any{
2196
+ "hook_event_name": "UserPromptSubmit",
2197
+ "session_id": sessionID,
2198
+ "turn_id": turnID,
2199
+ "prompt": "$rite-status",
2200
+ }), nil, "hook", "agent-dispatch", "--harness=codex")
2201
+
2202
+ resolvedRoot, err := filepath.EvalSymlinks(root)
2203
+ if err != nil {
2204
+ t.Fatal(err)
2205
+ }
2206
+ rootSum := sha256.Sum256([]byte(filepath.Clean(resolvedRoot)))
2207
+ sessionSum := sha256.Sum256([]byte(sessionID))
2208
+ statePath := filepath.Join(
2209
+ os.TempDir(),
2210
+ "devrites-agent-dispatch-v1",
2211
+ hex.EncodeToString(rootSum[:]),
2212
+ hex.EncodeToString(sessionSum[:])+".jsonl",
2213
+ )
2214
+ if err := os.MkdirAll(filepath.Dir(statePath), 0o700); err != nil {
2215
+ t.Fatal(err)
2216
+ }
2217
+ if err := os.WriteFile(statePath, []byte("not-json\n"), 0o600); err != nil {
2218
+ t.Fatal(err)
2219
+ }
2220
+
2221
+ stop := func(active bool) string {
2222
+ out, stderr, code := runDevritesIO(t, root, hookPayload(t, map[string]any{
2223
+ "hook_event_name": "Stop",
2224
+ "session_id": sessionID,
2225
+ "turn_id": turnID,
2226
+ "stop_hook_active": active,
2227
+ }), nil, "hook", "agent-dispatch", "--harness=codex")
2228
+ if code != 0 {
2229
+ t.Fatalf("corrupt state stop active=%t exit=%d stderr=%s", active, code, stderr)
2230
+ }
2231
+ return out
2232
+ }
2233
+
2234
+ var blocked struct {
2235
+ Decision string `json:"decision"`
2236
+ }
2237
+ if out := stop(false); json.Unmarshal([]byte(strings.TrimSpace(out)), &blocked) != nil ||
2238
+ blocked.Decision != "block" {
2239
+ t.Fatalf("corrupt state first Stop did not block: %s", out)
2240
+ }
2241
+
2242
+ var terminal struct {
2243
+ Continue bool `json:"continue"`
2244
+ StopReason string `json:"stopReason"`
2245
+ }
2246
+ if out := stop(true); json.Unmarshal([]byte(strings.TrimSpace(out)), &terminal) != nil ||
2247
+ terminal.Continue ||
2248
+ !strings.Contains(terminal.StopReason, "invalid agent dispatch state") {
2249
+ t.Fatalf("corrupt state reentry did not stop: %s", out)
2250
+ }
2148
2251
  }
2149
2252
 
2150
2253
  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.22",
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",