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.
- package/CHANGELOG.md +15 -0
- package/README.md +1 -1
- package/engine/Makefile +3 -3
- package/engine/hooks.go +20 -4
- package/engine/hooks_agent_dispatch.go +297 -48
- package/engine/hooks_events_test.go +40 -0
- package/engine/hooks_workspace.go +26 -6
- package/engine/internal/devritespaths/paths.go +80 -0
- package/engine/internal/install/install.go +36 -16
- package/engine/internal/install/install_test.go +50 -1
- package/engine/internal/iohooks/iohooks.go +5 -3
- package/engine/internal/iohooks/iohooks_test.go +11 -0
- package/engine/internal/lib/adoption.go +36 -11
- package/engine/internal/lib/adoption_test.go +30 -0
- package/engine/internal/lib/analyze.go +10 -2
- package/engine/internal/lib/archivesearch.go +41 -9
- package/engine/internal/lib/archivesearch_test.go +28 -0
- package/engine/internal/lib/buildreadiness.go +5 -1
- package/engine/internal/lib/closeout.go +34 -11
- package/engine/internal/lib/closeout_test.go +99 -0
- package/engine/internal/lib/context.go +6 -1
- package/engine/internal/lib/dispatchwaive.go +31 -0
- package/engine/internal/lib/dispatchwaive_test.go +23 -0
- package/engine/internal/lib/extensions.go +5 -1
- package/engine/internal/lib/learnings.go +8 -2
- package/engine/internal/lib/ledger.go +17 -4
- package/engine/internal/lib/ledger_test.go +24 -0
- package/engine/internal/lib/mutationgate.go +6 -2
- package/engine/internal/lib/reconcile.go +201 -64
- package/engine/internal/lib/reconcile_test.go +159 -12
- package/engine/internal/lib/resolve.go +8 -3
- package/engine/internal/lib/resolve_remediation_test.go +14 -0
- package/engine/internal/lib/runbook.go +4 -1
- package/engine/internal/lib/runbook_context_test.go +43 -0
- package/engine/internal/lib/testintegrity.go +4 -4
- package/engine/internal/lib/testintegrity_test.go +15 -0
- package/engine/internal/lib/tickafk.go +6 -2
- package/engine/internal/lib/util.go +128 -23
- package/engine/internal/profile/profile.go +27 -13
- package/engine/internal/profile/profile_test.go +13 -0
- package/engine/main.go +4 -1
- package/engine/testdata/golden/TestParityReconcile/close-window.golden +1 -0
- package/engine/testdata/golden/TestParityReconcile/not-git.golden +1 -1
- package/engine/testdata/golden/TestParityTestIntegrity/not-git.golden +1 -1
- package/engine/tests/hook_test.go +353 -17
- package/engine/tests/parity_reconcile_test.go +4 -1
- package/engine/tests/parity_testintegrity_test.go +1 -1
- package/pack/.claude/agents/devrites-code-reviewer.md +3 -0
- package/pack/.claude/agents/devrites-devex-reviewer.md +12 -8
- package/pack/.claude/agents/devrites-doubt-reviewer.md +3 -0
- package/pack/.claude/agents/devrites-forge-judge.md +4 -1
- package/pack/.claude/agents/devrites-frontend-reviewer.md +3 -0
- package/pack/.claude/agents/devrites-performance-reviewer.md +10 -9
- package/pack/.claude/agents/devrites-plan-reviewer.md +3 -0
- package/pack/.claude/agents/devrites-proof-runner.md +14 -14
- package/pack/.claude/agents/devrites-retrospector.md +3 -0
- package/pack/.claude/agents/devrites-security-auditor.md +3 -0
- package/pack/.claude/agents/devrites-simplifier-reviewer.md +3 -0
- package/pack/.claude/agents/devrites-slice-wright.md +4 -2
- package/pack/.claude/agents/devrites-spec-reviewer.md +3 -0
- package/pack/.claude/agents/devrites-strategy-reviewer.md +3 -0
- package/pack/.claude/agents/devrites-test-analyst.md +3 -0
- package/pack/.claude/skills/devrites-lib/reference/parallel-dispatch.md +8 -0
- package/pack/.claude/skills/devrites-lib/reference/standards/agents.md +10 -0
- package/pack/.claude/skills/devrites-source-driven/SKILL.md +1 -1
- package/pack/.claude/skills/rite-build/reference/phase-contract.md +17 -3
- package/pack/.claude/skills/rite-build/reference/wright-dispatch.md +3 -1
- package/pack/.claude/skills/rite-prove/SKILL.md +25 -14
- package/pack/.claude/skills/rite-temper/SKILL.md +1 -1
- package/pack/.claude/skills/rite-upgrade/SKILL.md +1 -1
- package/pack/.claude/skills/rite-vet/SKILL.md +18 -15
- package/pack/generated/claude/agents/devrites-code-reviewer.md +3 -0
- package/pack/generated/claude/agents/devrites-devex-reviewer.md +12 -8
- package/pack/generated/claude/agents/devrites-doubt-reviewer.md +3 -0
- package/pack/generated/claude/agents/devrites-forge-judge.md +4 -1
- package/pack/generated/claude/agents/devrites-frontend-reviewer.md +3 -0
- package/pack/generated/claude/agents/devrites-performance-reviewer.md +10 -9
- package/pack/generated/claude/agents/devrites-plan-reviewer.md +3 -0
- package/pack/generated/claude/agents/devrites-proof-runner.md +14 -14
- package/pack/generated/claude/agents/devrites-retrospector.md +3 -0
- package/pack/generated/claude/agents/devrites-security-auditor.md +3 -0
- package/pack/generated/claude/agents/devrites-simplifier-reviewer.md +3 -0
- package/pack/generated/claude/agents/devrites-slice-wright.md +4 -2
- package/pack/generated/claude/agents/devrites-spec-reviewer.md +3 -0
- package/pack/generated/claude/agents/devrites-strategy-reviewer.md +3 -0
- package/pack/generated/claude/agents/devrites-test-analyst.md +3 -0
- package/pack/generated/claude/skills/devrites-lib/reference/parallel-dispatch.md +8 -0
- package/pack/generated/claude/skills/devrites-lib/reference/standards/agents.md +10 -0
- package/pack/generated/claude/skills/devrites-source-driven/SKILL.md +1 -1
- package/pack/generated/claude/skills/rite-build/reference/phase-contract.md +17 -3
- package/pack/generated/claude/skills/rite-build/reference/wright-dispatch.md +3 -1
- package/pack/generated/claude/skills/rite-prove/SKILL.md +25 -14
- package/pack/generated/claude/skills/rite-temper/SKILL.md +1 -1
- package/pack/generated/claude/skills/rite-upgrade/SKILL.md +1 -1
- package/pack/generated/claude/skills/rite-vet/SKILL.md +18 -15
- package/pack/generated/codex/agents/devrites-code-reviewer.toml +3 -0
- package/pack/generated/codex/agents/devrites-devex-reviewer.toml +11 -7
- package/pack/generated/codex/agents/devrites-doubt-reviewer.toml +3 -0
- package/pack/generated/codex/agents/devrites-forge-judge.toml +4 -1
- package/pack/generated/codex/agents/devrites-frontend-reviewer.toml +3 -0
- package/pack/generated/codex/agents/devrites-performance-reviewer.toml +9 -8
- package/pack/generated/codex/agents/devrites-plan-reviewer.toml +3 -0
- package/pack/generated/codex/agents/devrites-proof-runner.toml +13 -13
- package/pack/generated/codex/agents/devrites-retrospector.toml +3 -0
- package/pack/generated/codex/agents/devrites-security-auditor.toml +3 -0
- package/pack/generated/codex/agents/devrites-simplifier-reviewer.toml +3 -0
- package/pack/generated/codex/agents/devrites-slice-wright.toml +4 -2
- package/pack/generated/codex/agents/devrites-spec-reviewer.toml +3 -0
- package/pack/generated/codex/agents/devrites-strategy-reviewer.toml +3 -0
- package/pack/generated/codex/agents/devrites-test-analyst.toml +3 -0
- package/pack/generated/codex/hooks.json +1 -1
- package/pack/generated/codex/skills/devrites-lib/reference/parallel-dispatch.md +8 -0
- package/pack/generated/codex/skills/devrites-lib/reference/standards/agents.md +10 -0
- package/pack/generated/codex/skills/devrites-source-driven/SKILL.md +1 -1
- package/pack/generated/codex/skills/rite-build/reference/phase-contract.md +17 -3
- package/pack/generated/codex/skills/rite-build/reference/wright-dispatch.md +3 -1
- package/pack/generated/codex/skills/rite-prove/SKILL.md +25 -14
- package/pack/generated/codex/skills/rite-temper/SKILL.md +1 -1
- package/pack/generated/codex/skills/rite-upgrade/SKILL.md +1 -1
- package/pack/generated/codex/skills/rite-vet/SKILL.md +18 -15
- package/package.json +1 -1
- 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.
|
|
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 :=
|
|
10
|
-
GOVULNCHECK_VERSION := v1.
|
|
11
|
-
GOSEC_VERSION := v2.
|
|
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) ==
|
|
372
|
+
if len(fields) == 3 && fields[1] == "-v" {
|
|
356
373
|
return true
|
|
357
374
|
}
|
|
358
|
-
|
|
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 :=
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1237
|
-
|
|
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(
|
|
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
|
|
1306
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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 {
|