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 +12 -0
- package/README.md +1 -1
- package/engine/hookpolicy.go +1 -0
- package/engine/hookpolicy_test.go +6 -0
- package/engine/hooks_agent_dispatch.go +22 -5
- package/engine/tests/hook_test.go +106 -3
- package/package.json +1 -1
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.
|
|
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
|
|
package/engine/hookpolicy.go
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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.
|
|
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",
|