devrites 3.2.6 → 3.2.8
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 +13 -0
- package/README.md +1 -1
- package/docs/orchestration.md +10 -0
- package/engine/hooks_agent_dispatch.go +98 -33
- package/engine/internal/lib/reconcile.go +63 -0
- package/engine/internal/lib/reconcile_test.go +152 -0
- package/engine/tests/hook_test.go +235 -0
- package/pack/.claude/skills/rite-build/reference/wright-dispatch.md +1 -1
- package/pack/generated/claude/skills/rite-build/reference/wright-dispatch.md +1 -1
- package/pack/generated/codex/skills/rite-build/reference/wright-dispatch.md +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,19 @@
|
|
|
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.8](https://github.com/ViktorsBaikers/DevRites/compare/v3.2.7...v3.2.8) (2026-07-26)
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
* **devrites:** prevent reconcile self-invalidation ([161ed18](https://github.com/ViktorsBaikers/DevRites/commit/161ed187e600dfc92623b22db5cd7f0824408d1a))
|
|
10
|
+
|
|
11
|
+
## [3.2.7](https://github.com/ViktorsBaikers/DevRites/compare/v3.2.6...v3.2.7) (2026-07-26)
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
* **agents:** enforce conditional v2 dispatch ([f4169d4](https://github.com/ViktorsBaikers/DevRites/commit/f4169d41c456f352172be8d96afdcdc69800d849))
|
|
16
|
+
* **ci:** satisfy staticcheck error style ([0023265](https://github.com/ViktorsBaikers/DevRites/commit/00232656283647db8d94b2ed61dccb1629091b25))
|
|
17
|
+
|
|
5
18
|
## [3.2.6](https://github.com/ViktorsBaikers/DevRites/compare/v3.2.5...v3.2.6) (2026-07-25)
|
|
6
19
|
|
|
7
20
|
### Changed
|
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.8`](https://github.com/ViktorsBaikers/DevRites/releases/tag/v3.2.8): see [`CHANGELOG.md`](CHANGELOG.md) for release notes.
|
|
29
29
|
|
|
30
30
|
## Quick start
|
|
31
31
|
|
package/docs/orchestration.md
CHANGED
|
@@ -69,6 +69,16 @@ Each dispatch follows the retained-baseline sequence:
|
|
|
69
69
|
5. After proof and decision checks pass, `reconcile close` retires the private
|
|
70
70
|
window; only then does the root write canonical records.
|
|
71
71
|
|
|
72
|
+
The canonical-state fingerprint excludes root-owned operational records:
|
|
73
|
+
`timeline.jsonl`, feature `events.jsonl`, a valid `recovery-attempts.jsonl`,
|
|
74
|
+
the redwatch sentinel, automatic handoff snapshots, and hidden hook observation
|
|
75
|
+
logs. Those records cannot grant source authority or invalidate a writer result
|
|
76
|
+
merely because the engine or a hook wrote them. Unknown or canonical
|
|
77
|
+
`.devrites` paths remain fail-closed, and recovery ledgers must still parse
|
|
78
|
+
before reconciliation can pass. Gate-authoritative operational state such as
|
|
79
|
+
recovery attempts and `.red` remains enforced by its owning gate. Checks also
|
|
80
|
+
normalize these paths out of retained snapshots created by older engines.
|
|
81
|
+
|
|
72
82
|
On retry, the root may add only an accepted still-in-slice path, then runs
|
|
73
83
|
`reconcile snapshot` again. This refreshes the dispatch boundary while
|
|
74
84
|
preserving the original slice baseline. The same objective root cause has a
|
|
@@ -25,6 +25,7 @@ const (
|
|
|
25
25
|
agentDispatchStateVersion = "devrites-agent-dispatch/v1"
|
|
26
26
|
agentDispatchStateDir = "devrites-agent-dispatch-v1"
|
|
27
27
|
agentDispatchMetadataRole = "devrites-skill-contract-error"
|
|
28
|
+
agentDispatchSkillGuard = "devrites-skill-dispatch-guard"
|
|
28
29
|
maxCodexRolloutLine = 8 << 20
|
|
29
30
|
)
|
|
30
31
|
|
|
@@ -309,22 +310,24 @@ func requiredAgentRolesFromSkill(path string) ([]string, error) {
|
|
|
309
310
|
return roles, nil
|
|
310
311
|
}
|
|
311
312
|
|
|
312
|
-
func requiredRolesInPrompt(root, text string) ([]string, error) {
|
|
313
|
+
func requiredRolesInPrompt(root, text string) ([]string, bool, error) {
|
|
313
314
|
seen := map[string]struct{}{}
|
|
315
|
+
skillInvoked := false
|
|
314
316
|
skillsDir := filepath.Join(filepath.Dir(root), ".agents", "skills")
|
|
315
317
|
for _, match := range skillInvocationRe.FindAllStringSubmatch(text, -1) {
|
|
316
318
|
skill := strings.ToLower(match[1])
|
|
317
319
|
path := filepath.Join(skillsDir, skill, "SKILL.md")
|
|
318
320
|
if !safepath.WithinResolved(path, skillsDir) {
|
|
319
|
-
return nil, fmt.Errorf("unsafe skill path for %s", skill)
|
|
321
|
+
return nil, false, fmt.Errorf("unsafe skill path for %s", skill)
|
|
320
322
|
}
|
|
321
323
|
roles, err := requiredAgentRolesFromSkill(path)
|
|
322
324
|
if os.IsNotExist(err) {
|
|
323
325
|
continue
|
|
324
326
|
}
|
|
325
327
|
if err != nil {
|
|
326
|
-
return nil, fmt.Errorf("%s: %w", skill, err)
|
|
328
|
+
return nil, false, fmt.Errorf("%s: %w", skill, err)
|
|
327
329
|
}
|
|
330
|
+
skillInvoked = true
|
|
328
331
|
for _, role := range roles {
|
|
329
332
|
seen[role] = struct{}{}
|
|
330
333
|
}
|
|
@@ -334,7 +337,7 @@ func requiredRolesInPrompt(root, text string) ([]string, error) {
|
|
|
334
337
|
roles = append(roles, role)
|
|
335
338
|
}
|
|
336
339
|
sort.Strings(roles)
|
|
337
|
-
return roles, nil
|
|
340
|
+
return roles, skillInvoked, nil
|
|
338
341
|
}
|
|
339
342
|
|
|
340
343
|
func expectedAgentType(role string) string {
|
|
@@ -488,6 +491,19 @@ func durableCodexV2DispatchAttempts(
|
|
|
488
491
|
}
|
|
489
492
|
var attempts []*agentDispatchAttempt
|
|
490
493
|
for _, spawn := range spawns {
|
|
494
|
+
if !devritesAgentNameRe.MatchString(spawn.Role) || spawn.Role == agentDispatchSkillGuard {
|
|
495
|
+
if strings.TrimSpace(spawn.Result) != "" {
|
|
496
|
+
return nil, fmt.Errorf("codex V2 completed a default or non-DevRites child during a DevRites skill turn; %s", conditionalDispatchInstruction())
|
|
497
|
+
}
|
|
498
|
+
continue
|
|
499
|
+
}
|
|
500
|
+
armed[spawn.Role] = struct{}{}
|
|
501
|
+
attempt := &agentDispatchAttempt{
|
|
502
|
+
Role: spawn.Role,
|
|
503
|
+
AgentType: spawn.Role,
|
|
504
|
+
Started: true,
|
|
505
|
+
}
|
|
506
|
+
attempts = append(attempts, attempt)
|
|
491
507
|
if strings.TrimSpace(spawn.Result) == "" || !lineBetween(waitLines, spawn.SpawnLine, spawn.ResultLine) {
|
|
492
508
|
continue
|
|
493
509
|
}
|
|
@@ -504,24 +520,17 @@ func durableCodexV2DispatchAttempts(
|
|
|
504
520
|
if childID == "" || strings.TrimSpace(childResult) == "" {
|
|
505
521
|
continue
|
|
506
522
|
}
|
|
507
|
-
|
|
523
|
+
attempt.AgentID = childID
|
|
524
|
+
attempt.Stopped = true
|
|
525
|
+
attempt.Waited = true
|
|
508
526
|
if spawn.Role == "devrites-slice-wright" {
|
|
509
|
-
|
|
510
|
-
if
|
|
527
|
+
attempt.WindowID = currentReconcileWindowID()
|
|
528
|
+
if attempt.WindowID == "" || !reconcileWindowPredates(spawn.SpawnedAt) {
|
|
511
529
|
continue
|
|
512
530
|
}
|
|
513
531
|
}
|
|
514
532
|
sum := sha256.Sum256([]byte(childResult))
|
|
515
|
-
|
|
516
|
-
Role: spawn.Role,
|
|
517
|
-
AgentType: spawn.Role,
|
|
518
|
-
AgentID: childID,
|
|
519
|
-
WindowID: windowID,
|
|
520
|
-
Started: true,
|
|
521
|
-
Stopped: true,
|
|
522
|
-
Waited: true,
|
|
523
|
-
ResultSHA256: hex.EncodeToString(sum[:]),
|
|
524
|
-
})
|
|
533
|
+
attempt.ResultSHA256 = hex.EncodeToString(sum[:])
|
|
525
534
|
}
|
|
526
535
|
return attempts, nil
|
|
527
536
|
}
|
|
@@ -570,6 +579,7 @@ func readCodexV2ParentRollout(
|
|
|
570
579
|
spawns := map[string]*codexV2Spawn{}
|
|
571
580
|
var waitLines []int
|
|
572
581
|
metaSeen := false
|
|
582
|
+
_, guarded := armed[agentDispatchSkillGuard]
|
|
573
583
|
scanner := bufio.NewScanner(file)
|
|
574
584
|
scanner.Buffer(make([]byte, 64<<10), maxCodexRolloutLine)
|
|
575
585
|
for lineNumber := 1; scanner.Scan(); lineNumber++ {
|
|
@@ -604,9 +614,9 @@ func readCodexV2ParentRollout(
|
|
|
604
614
|
if json.Unmarshal([]byte(record.Payload.Arguments), &args) != nil {
|
|
605
615
|
continue
|
|
606
616
|
}
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
args.ForkTurns != "none" {
|
|
617
|
+
_, required := armed[args.AgentType]
|
|
618
|
+
if (!required && !guarded) || args.TaskName == "" ||
|
|
619
|
+
strings.Contains(args.TaskName, "/") || args.ForkTurns != "none" {
|
|
610
620
|
continue
|
|
611
621
|
}
|
|
612
622
|
spawnedAt, err := time.Parse(time.RFC3339Nano, record.Timestamp)
|
|
@@ -797,9 +807,23 @@ func roleSatisfied(role, windowID string, attempts []*agentDispatchAttempt) bool
|
|
|
797
807
|
return false
|
|
798
808
|
}
|
|
799
809
|
|
|
810
|
+
func dispatchInstruction(role string) string {
|
|
811
|
+
return fmt.Sprintf(
|
|
812
|
+
"On MultiAgent V2 call spawn_agent with agent_type=%s, a unique task_name, and fork_turns=\"none\" so Codex loads .codex/agents/%s.toml natively. GPT-5.6 V2 may omit agent_type from the visible tool schema even though the runtime accepts it; send agent_type anyway rather than using a default child. On V1 use agent_type=%s with fork_turns=\"none\" and name that role TOML in the message. Wait for the returned child and use its non-empty result. Do not call wait before spawn_agent and do not synthesize the agent result.",
|
|
813
|
+
role, role, expectedAgentType(role),
|
|
814
|
+
)
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
func conditionalDispatchInstruction() string {
|
|
818
|
+
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."
|
|
819
|
+
}
|
|
820
|
+
|
|
800
821
|
func incompleteDispatchReason(armed map[string]struct{}, attempts []*agentDispatchAttempt) string {
|
|
801
822
|
roles := make([]string, 0, len(armed))
|
|
802
823
|
for role := range armed {
|
|
824
|
+
if role == agentDispatchSkillGuard {
|
|
825
|
+
continue
|
|
826
|
+
}
|
|
803
827
|
if !roleSatisfied(role, "", attempts) {
|
|
804
828
|
roles = append(roles, role)
|
|
805
829
|
}
|
|
@@ -812,10 +836,7 @@ func incompleteDispatchReason(armed map[string]struct{}, attempts []*agentDispat
|
|
|
812
836
|
if role == agentDispatchMetadataRole {
|
|
813
837
|
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."
|
|
814
838
|
}
|
|
815
|
-
return fmt.Sprintf(
|
|
816
|
-
"DevRites dispatch for %s is not complete. On MultiAgent V2 call spawn_agent with agent_type=%s, a unique task_name, and fork_turns=\"none\" so Codex loads .codex/agents/%s.toml natively. GPT-5.6 V2 may omit agent_type from the visible tool schema even though the runtime accepts it; send agent_type anyway rather than using a default child. On V1 use agent_type=%s with fork_turns=\"none\" and name that role TOML in the message. Wait for the returned child and use its non-empty result. Do not call wait before spawn_agent and do not synthesize the agent result.",
|
|
817
|
-
role, role, role, expectedAgentType(role),
|
|
818
|
-
)
|
|
839
|
+
return fmt.Sprintf("DevRites dispatch for %s is not complete. %s", role, dispatchInstruction(role))
|
|
819
840
|
}
|
|
820
841
|
|
|
821
842
|
func preToolDeny(h harness.Harness, reason string, stdout, stderr io.Writer) int {
|
|
@@ -888,7 +909,7 @@ func hookAgentDispatch(h harness.Harness, stdin io.Reader, stdout, stderr io.Wri
|
|
|
888
909
|
|
|
889
910
|
switch in.HookEventName {
|
|
890
911
|
case "UserPromptSubmit":
|
|
891
|
-
roles, rolesErr := requiredRolesInPrompt(root, in.Prompt)
|
|
912
|
+
roles, skillInvoked, rolesErr := requiredRolesInPrompt(root, in.Prompt)
|
|
892
913
|
if rolesErr != nil {
|
|
893
914
|
if err := appendAgentDispatchEvent(root, in.SessionID, agentDispatchEvent{
|
|
894
915
|
Event: "armed",
|
|
@@ -899,6 +920,15 @@ func hookAgentDispatch(h harness.Harness, stdin io.Reader, stdout, stderr io.Wri
|
|
|
899
920
|
}
|
|
900
921
|
return stopDispatchBlock(h, "DevRites could not read the required agent roles: "+rolesErr.Error(), stdout, stderr)
|
|
901
922
|
}
|
|
923
|
+
if skillInvoked {
|
|
924
|
+
if err := appendAgentDispatchEvent(root, in.SessionID, agentDispatchEvent{
|
|
925
|
+
Event: "armed",
|
|
926
|
+
TurnID: in.TurnID,
|
|
927
|
+
Role: agentDispatchSkillGuard,
|
|
928
|
+
}); err != nil {
|
|
929
|
+
return stopDispatchBlock(h, "DevRites could not arm the skill dispatch guard: "+err.Error(), stdout, stderr)
|
|
930
|
+
}
|
|
931
|
+
}
|
|
902
932
|
for _, role := range roles {
|
|
903
933
|
if err := appendAgentDispatchEvent(root, in.SessionID, agentDispatchEvent{
|
|
904
934
|
Event: "armed",
|
|
@@ -909,7 +939,21 @@ func hookAgentDispatch(h harness.Harness, stdin io.Reader, stdout, stderr io.Wri
|
|
|
909
939
|
}
|
|
910
940
|
}
|
|
911
941
|
if len(roles) > 0 {
|
|
912
|
-
|
|
942
|
+
instructions := make([]string, 0, len(roles))
|
|
943
|
+
for _, role := range roles {
|
|
944
|
+
instructions = append(instructions, dispatchInstruction(role))
|
|
945
|
+
}
|
|
946
|
+
fmt.Fprintf(
|
|
947
|
+
stdout,
|
|
948
|
+
"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",
|
|
949
|
+
strings.Join(instructions, "\n"),
|
|
950
|
+
)
|
|
951
|
+
}
|
|
952
|
+
if skillInvoked {
|
|
953
|
+
if len(roles) > 0 {
|
|
954
|
+
fmt.Fprintln(stdout)
|
|
955
|
+
}
|
|
956
|
+
fmt.Fprint(stdout, conditionalDispatchInstruction())
|
|
913
957
|
}
|
|
914
958
|
return exitOK
|
|
915
959
|
|
|
@@ -949,7 +993,8 @@ func hookAgentDispatch(h harness.Harness, stdin io.Reader, stdout, stderr io.Wri
|
|
|
949
993
|
return stopDispatchBlock(h, "DevRites could not verify the required agent dispatch: "+err.Error(), stdout, stderr)
|
|
950
994
|
}
|
|
951
995
|
armed, attempts := dispatchTurnState(events, in.TurnID)
|
|
952
|
-
|
|
996
|
+
_, guarded := armed[agentDispatchSkillGuard]
|
|
997
|
+
if reason := incompleteDispatchReason(armed, attempts); reason != "" || guarded && len(attempts) == 0 {
|
|
953
998
|
durable, durableErr := durableCodexV2DispatchAttempts(
|
|
954
999
|
root, in.SessionID, in.TurnID, armed,
|
|
955
1000
|
)
|
|
@@ -979,10 +1024,17 @@ func hookAgentDispatchPreTool(h harness.Harness, root string, in agentDispatchHo
|
|
|
979
1024
|
if isAgentDispatchTool(in.ToolName) {
|
|
980
1025
|
agentType := in.ToolInput.AgentType
|
|
981
1026
|
if agentType == "" {
|
|
982
|
-
|
|
983
|
-
|
|
1027
|
+
roles := explicitRolesInText(in.ToolInput.Message + "\n" + in.ToolInput.TaskName)
|
|
1028
|
+
if len(roles) == 1 {
|
|
1029
|
+
return preToolDeny(h, "DevRites conditional dispatch requires the exact named specialist. "+dispatchInstruction(roles[0]), stdout, stderr)
|
|
1030
|
+
}
|
|
1031
|
+
if reason := incompleteDispatchReason(armed, attempts); reason != "" {
|
|
1032
|
+
return preToolDeny(h, reason, stdout, stderr)
|
|
984
1033
|
}
|
|
985
|
-
|
|
1034
|
+
if _, guarded := armed[agentDispatchSkillGuard]; guarded {
|
|
1035
|
+
return preToolDeny(h, conditionalDispatchInstruction(), stdout, stderr)
|
|
1036
|
+
}
|
|
1037
|
+
return exitOK
|
|
986
1038
|
}
|
|
987
1039
|
role := ""
|
|
988
1040
|
if strings.HasPrefix(agentType, "devrites-") {
|
|
@@ -995,11 +1047,20 @@ func hookAgentDispatchPreTool(h harness.Harness, root string, in agentDispatchHo
|
|
|
995
1047
|
}
|
|
996
1048
|
if role == "" {
|
|
997
1049
|
if agentType == "explorer" || agentType == "worker" {
|
|
998
|
-
if
|
|
1050
|
+
if reason := incompleteDispatchReason(armed, attempts); reason != "" {
|
|
1051
|
+
return preToolDeny(h, reason, stdout, stderr)
|
|
1052
|
+
}
|
|
1053
|
+
if _, guarded := armed[agentDispatchSkillGuard]; !guarded {
|
|
999
1054
|
return exitOK // Unrelated generic subagent in a DevRites repository.
|
|
1000
1055
|
}
|
|
1001
1056
|
return preToolDeny(h, "DevRites generic dispatch must name exactly one .codex/agents/devrites-<role>.toml contract.", stdout, stderr)
|
|
1002
1057
|
}
|
|
1058
|
+
if reason := incompleteDispatchReason(armed, attempts); reason != "" {
|
|
1059
|
+
return preToolDeny(h, reason, stdout, stderr)
|
|
1060
|
+
}
|
|
1061
|
+
if _, guarded := armed[agentDispatchSkillGuard]; guarded {
|
|
1062
|
+
return preToolDeny(h, conditionalDispatchInstruction(), stdout, stderr)
|
|
1063
|
+
}
|
|
1003
1064
|
return exitOK
|
|
1004
1065
|
}
|
|
1005
1066
|
instructions, contractErr := agentRoleDeveloperInstructions(root, role)
|
|
@@ -1016,7 +1077,7 @@ func hookAgentDispatchPreTool(h harness.Harness, root string, in agentDispatchHo
|
|
|
1016
1077
|
return preToolDeny(h, "devrites-slice-wright requires the generic worker identity and exact wright allowlist.", stdout, stderr)
|
|
1017
1078
|
}
|
|
1018
1079
|
if agentType != "" && agentType != role && agentType != expectedAgentType(role) {
|
|
1019
|
-
return preToolDeny(h, fmt.Sprintf("DevRites role %s requires agent_type=%s or its exposed named role.", role, expectedAgentType(role)), stdout, stderr)
|
|
1080
|
+
return preToolDeny(h, fmt.Sprintf("DevRites role %s requires agent_type=%s or its exposed named role. %s", role, expectedAgentType(role), dispatchInstruction(role)), stdout, stderr)
|
|
1020
1081
|
}
|
|
1021
1082
|
if in.ToolUseID == "" {
|
|
1022
1083
|
return preToolDeny(h, "DevRites cannot bind a spawn without tool_use_id.", stdout, stderr)
|
|
@@ -1057,7 +1118,11 @@ func hookAgentDispatchPreTool(h harness.Harness, root string, in agentDispatchHo
|
|
|
1057
1118
|
}
|
|
1058
1119
|
}
|
|
1059
1120
|
if len(started) == 0 {
|
|
1060
|
-
|
|
1121
|
+
reason := incompleteDispatchReason(armed, attempts)
|
|
1122
|
+
if reason == "" {
|
|
1123
|
+
reason = conditionalDispatchInstruction()
|
|
1124
|
+
}
|
|
1125
|
+
return preToolDeny(h, reason, stdout, stderr)
|
|
1061
1126
|
}
|
|
1062
1127
|
targets := in.ToolInput.ReceiverThreadIDs
|
|
1063
1128
|
if len(targets) == 0 {
|
|
@@ -215,6 +215,7 @@ func Reconcile(root string, args []string, stdout, stderr io.Writer) int {
|
|
|
215
215
|
fmt.Fprintf(stderr, "reconcile: invalid .devrites snapshot: %v\n", err)
|
|
216
216
|
return 6
|
|
217
217
|
}
|
|
218
|
+
beforeDevrites = withoutRootOwnedOperationalState(beforeDevrites)
|
|
218
219
|
afterDevrites, err := captureReconcileDevritesState(root, devritesSnapshot, objects, checked)
|
|
219
220
|
if err != nil {
|
|
220
221
|
fmt.Fprintf(stderr, "reconcile: cannot compare .devrites: %v\n", err)
|
|
@@ -465,6 +466,13 @@ func captureDevritesState(root string, ignoredPaths ...string) ([]devritesStateE
|
|
|
465
466
|
}
|
|
466
467
|
return nil
|
|
467
468
|
}
|
|
469
|
+
operational, err := reconcileRootOwnedOperationalFile(filename, rel, entry)
|
|
470
|
+
if err != nil {
|
|
471
|
+
return err
|
|
472
|
+
}
|
|
473
|
+
if operational {
|
|
474
|
+
return nil
|
|
475
|
+
}
|
|
468
476
|
|
|
469
477
|
fingerprint, err := devritesFingerprint(filename, entry)
|
|
470
478
|
if err != nil {
|
|
@@ -483,6 +491,61 @@ func captureDevritesState(root string, ignoredPaths ...string) ([]devritesStateE
|
|
|
483
491
|
return state, nil
|
|
484
492
|
}
|
|
485
493
|
|
|
494
|
+
// reconcileRootOwnedOperationalFile excludes engine/root-owned operational
|
|
495
|
+
// records from the writer delta. Their owning hooks/gates validate them where
|
|
496
|
+
// needed; root activity must not be attributed to the slice writer.
|
|
497
|
+
func reconcileRootOwnedOperationalFile(filename, rel string, entry os.DirEntry) (bool, error) {
|
|
498
|
+
if !entry.Type().IsRegular() || !reconcileRootOwnedOperationalPath(rel) {
|
|
499
|
+
return false, nil
|
|
500
|
+
}
|
|
501
|
+
rel = filepath.ToSlash(filepath.Clean(rel))
|
|
502
|
+
parts := strings.Split(rel, "/")
|
|
503
|
+
if len(parts) == 3 && parts[2] == recoveryAttemptsFile {
|
|
504
|
+
if _, err := readRecoveryAttempts(filename); err != nil {
|
|
505
|
+
return false, fmt.Errorf("validate root-owned recovery ledger %s: %w", rel, err)
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
return true, nil
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
func reconcileRootOwnedOperationalPath(rel string) bool {
|
|
512
|
+
rel = filepath.ToSlash(filepath.Clean(rel))
|
|
513
|
+
if rel == "timeline.jsonl" {
|
|
514
|
+
return true
|
|
515
|
+
}
|
|
516
|
+
parts := strings.Split(rel, "/")
|
|
517
|
+
if len(parts) != 3 ||
|
|
518
|
+
(parts[0] != "work" && parts[0] != "features") ||
|
|
519
|
+
!slugToken.MatchString(parts[1]) {
|
|
520
|
+
return false
|
|
521
|
+
}
|
|
522
|
+
switch parts[2] {
|
|
523
|
+
case "events.jsonl",
|
|
524
|
+
recoveryAttemptsFile,
|
|
525
|
+
".red",
|
|
526
|
+
"handoff.md",
|
|
527
|
+
".a1-guard.log",
|
|
528
|
+
".reviewer-ro.log",
|
|
529
|
+
".stop-gate.log",
|
|
530
|
+
".wright-scope.log":
|
|
531
|
+
return true
|
|
532
|
+
default:
|
|
533
|
+
return false
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
func withoutRootOwnedOperationalState(state []devritesStateEntry) []devritesStateEntry {
|
|
538
|
+
filtered := state[:0]
|
|
539
|
+
for _, item := range state {
|
|
540
|
+
if strings.HasPrefix(item.Fingerprint, "file:") &&
|
|
541
|
+
reconcileRootOwnedOperationalPath(item.Path) {
|
|
542
|
+
continue
|
|
543
|
+
}
|
|
544
|
+
filtered = append(filtered, item)
|
|
545
|
+
}
|
|
546
|
+
return filtered
|
|
547
|
+
}
|
|
548
|
+
|
|
486
549
|
func devritesFingerprint(filename string, entry os.DirEntry) (string, error) {
|
|
487
550
|
info, err := entry.Info()
|
|
488
551
|
if err != nil {
|
|
@@ -361,6 +361,158 @@ func TestReconcileRejectsDevritesMutation(t *testing.T) {
|
|
|
361
361
|
}
|
|
362
362
|
}
|
|
363
363
|
|
|
364
|
+
func TestReconcileAllowsRootOwnedOperationalFiles(t *testing.T) {
|
|
365
|
+
newGitRepo(t)
|
|
366
|
+
root := workspace(t, "feat")
|
|
367
|
+
dir := featureDir(root, "feat")
|
|
368
|
+
writeWrightAllowlist(t, root, "feat", "seed.go")
|
|
369
|
+
if code := Stuck(root, []string{"log", "feat", "dispatch", "SLICE-010"}, &bytes.Buffer{}, &bytes.Buffer{}); code != 0 {
|
|
370
|
+
t.Fatalf("stuck log = %d, want 0", code)
|
|
371
|
+
}
|
|
372
|
+
if code, out := runReconcile(t, root, "snapshot", "feat"); code != 0 {
|
|
373
|
+
t.Fatalf("snapshot = %d, want 0\n%s", code, out)
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
writeFile(t, filepath.Join(root, "timeline.jsonl"), "telemetry\n")
|
|
377
|
+
writeFile(t, filepath.Join(dir, "events.jsonl"), "telemetry\n")
|
|
378
|
+
writeFile(t, filepath.Join(dir, ".a1-guard.log"), "WOULD-BLOCK\n")
|
|
379
|
+
writeFile(t, filepath.Join(dir, ".reviewer-ro.log"), "WOULD-BLOCK\n")
|
|
380
|
+
writeFile(t, filepath.Join(dir, ".stop-gate.log"), "WOULD-BLOCK\n")
|
|
381
|
+
writeFile(t, filepath.Join(dir, ".wright-scope.log"), "WOULD-BLOCK\n")
|
|
382
|
+
writeFile(t, filepath.Join(dir, ".red"), "npm test\n")
|
|
383
|
+
writeFile(t, filepath.Join(dir, "handoff.md"), "## Handoff snapshot\n")
|
|
384
|
+
if code := RecoveryAttempts(
|
|
385
|
+
root,
|
|
386
|
+
[]string{"record", "--class", "proof_tool_defect", "reconcile telemetry", "changed logs", "feat"},
|
|
387
|
+
&bytes.Buffer{},
|
|
388
|
+
&bytes.Buffer{},
|
|
389
|
+
); code != 0 {
|
|
390
|
+
t.Fatalf("recovery record = %d, want 0", code)
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
if code, out := runReconcile(t, root, "check", "feat"); code != 0 {
|
|
394
|
+
t.Fatalf("check = %d, want 0\n%s", code, out)
|
|
395
|
+
}
|
|
396
|
+
if code, out := runReconcile(t, root, "snapshot", "feat"); code != 0 {
|
|
397
|
+
t.Fatalf("refresh = %d, want 0\n%s", code, out)
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
writeFile(t, filepath.Join(dir, ".unknown.log"), "not engine-owned\n")
|
|
401
|
+
code, out := runReconcile(t, root, "check", "feat")
|
|
402
|
+
if code != 5 {
|
|
403
|
+
t.Fatalf("check after canonical mutation = %d, want 5\n%s", code, out)
|
|
404
|
+
}
|
|
405
|
+
if !strings.Contains(out, ".devrites/work/feat/.unknown.log") {
|
|
406
|
+
t.Fatalf("canonical mutation missing from output:\n%s", out)
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
func TestReconcileAcceptsLegacyOperationalFingerprints(t *testing.T) {
|
|
411
|
+
newGitRepo(t)
|
|
412
|
+
root := workspace(t, "feat")
|
|
413
|
+
dir := featureDir(root, "feat")
|
|
414
|
+
writeWrightAllowlist(t, root, "feat", "seed.go")
|
|
415
|
+
writeFile(t, filepath.Join(root, "timeline.jsonl"), "before\n")
|
|
416
|
+
writeFile(t, filepath.Join(dir, "events.jsonl"), "before\n")
|
|
417
|
+
writeFile(t, filepath.Join(dir, ".a1-guard.log"), "before\n")
|
|
418
|
+
writeFile(t, filepath.Join(dir, ".red"), "before\n")
|
|
419
|
+
writeFile(t, filepath.Join(dir, "handoff.md"), "before\n")
|
|
420
|
+
if code := RecoveryAttempts(
|
|
421
|
+
root,
|
|
422
|
+
[]string{"record", "--class", "proof_tool_defect", "reconcile telemetry", "first failure", "feat"},
|
|
423
|
+
&bytes.Buffer{},
|
|
424
|
+
&bytes.Buffer{},
|
|
425
|
+
); code != 0 {
|
|
426
|
+
t.Fatalf("initial recovery record = %d, want 0", code)
|
|
427
|
+
}
|
|
428
|
+
if code, out := runReconcile(t, root, "snapshot", "feat"); code != 0 {
|
|
429
|
+
t.Fatalf("snapshot = %d, want 0\n%s", code, out)
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
snapshotPath := filepath.Join(dir, reconcileDevritesName)
|
|
433
|
+
state, err := readDevritesState(snapshotPath)
|
|
434
|
+
if err != nil {
|
|
435
|
+
t.Fatal(err)
|
|
436
|
+
}
|
|
437
|
+
have := make(map[string]bool, len(state))
|
|
438
|
+
for _, item := range state {
|
|
439
|
+
have[item.Path] = true
|
|
440
|
+
}
|
|
441
|
+
for _, path := range []string{
|
|
442
|
+
"timeline.jsonl",
|
|
443
|
+
"work/feat/events.jsonl",
|
|
444
|
+
"work/feat/.a1-guard.log",
|
|
445
|
+
"work/feat/.red",
|
|
446
|
+
"work/feat/handoff.md",
|
|
447
|
+
"work/feat/recovery-attempts.jsonl",
|
|
448
|
+
} {
|
|
449
|
+
if !have[path] {
|
|
450
|
+
state = append(state, devritesStateEntry{Path: path, Fingerprint: "file:0600:legacy"})
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
if err := writeDevritesState(snapshotPath, state); err != nil {
|
|
454
|
+
t.Fatal(err)
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
writeFile(t, filepath.Join(root, "timeline.jsonl"), "after\n")
|
|
458
|
+
writeFile(t, filepath.Join(dir, "events.jsonl"), "after\n")
|
|
459
|
+
writeFile(t, filepath.Join(dir, ".a1-guard.log"), "after\n")
|
|
460
|
+
writeFile(t, filepath.Join(dir, ".red"), "after\n")
|
|
461
|
+
writeFile(t, filepath.Join(dir, "handoff.md"), "after\n")
|
|
462
|
+
if code := RecoveryAttempts(
|
|
463
|
+
root,
|
|
464
|
+
[]string{"record", "--class", "proof_tool_defect", "reconcile telemetry", "second failure", "feat"},
|
|
465
|
+
&bytes.Buffer{},
|
|
466
|
+
&bytes.Buffer{},
|
|
467
|
+
); code != 0 {
|
|
468
|
+
t.Fatalf("second recovery record = %d, want 0", code)
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
if code, out := runReconcile(t, root, "check", "feat"); code != 0 {
|
|
472
|
+
t.Fatalf("check = %d, want 0\n%s", code, out)
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
func TestReconcileRejectsCorruptRootOwnedRecoveryLedger(t *testing.T) {
|
|
477
|
+
newGitRepo(t)
|
|
478
|
+
root := workspace(t, "feat")
|
|
479
|
+
writeWrightAllowlist(t, root, "feat", "seed.go")
|
|
480
|
+
if code, out := runReconcile(t, root, "snapshot", "feat"); code != 0 {
|
|
481
|
+
t.Fatalf("snapshot = %d, want 0\n%s", code, out)
|
|
482
|
+
}
|
|
483
|
+
writeFile(t, filepath.Join(featureDir(root, "feat"), recoveryAttemptsFile), "{not-json}\n")
|
|
484
|
+
|
|
485
|
+
code, out := runReconcile(t, root, "check", "feat")
|
|
486
|
+
if code != 6 {
|
|
487
|
+
t.Fatalf("check = %d, want 6\n%s", code, out)
|
|
488
|
+
}
|
|
489
|
+
if !strings.Contains(out, "validate root-owned recovery ledger") {
|
|
490
|
+
t.Fatalf("corrupt recovery ledger missing from output:\n%s", out)
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
func TestReconcileRootOwnedOperationalPathsAreExact(t *testing.T) {
|
|
495
|
+
for _, path := range []string{
|
|
496
|
+
"timeline.jsonl",
|
|
497
|
+
"work/feat/.red",
|
|
498
|
+
"features/feat/events.jsonl",
|
|
499
|
+
} {
|
|
500
|
+
if !reconcileRootOwnedOperationalPath(path) {
|
|
501
|
+
t.Errorf("%q should be root-owned operational state", path)
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
for _, path := range []string{
|
|
505
|
+
"work/feat/.unknown.log",
|
|
506
|
+
"work/feat/action.log",
|
|
507
|
+
"work/feat/footprint.log",
|
|
508
|
+
"work/feat/state.md",
|
|
509
|
+
} {
|
|
510
|
+
if reconcileRootOwnedOperationalPath(path) {
|
|
511
|
+
t.Errorf("%q must remain fingerprinted", path)
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
364
516
|
func TestReconcileCheckFailsClosedWhenObjectDatabaseIsMissing(t *testing.T) {
|
|
365
517
|
newGitRepo(t)
|
|
366
518
|
root := workspace(t, "feat")
|
|
@@ -899,6 +899,18 @@ func writeCodexSkillContract(t *testing.T, root, skill, roles string) {
|
|
|
899
899
|
}
|
|
900
900
|
}
|
|
901
901
|
|
|
902
|
+
func writeCodexConditionalSkillContract(t *testing.T, root, skill, role string) {
|
|
903
|
+
t.Helper()
|
|
904
|
+
dir := filepath.Join(filepath.Dir(root), ".agents", "skills", skill)
|
|
905
|
+
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
906
|
+
t.Fatal(err)
|
|
907
|
+
}
|
|
908
|
+
contract := "---\nname: " + skill + "\ndescription: Test skill.\nrequired-agent-roles: none\n---\n\nConditionally dispatch `" + role + "` when needed.\n"
|
|
909
|
+
if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(contract), 0o600); err != nil {
|
|
910
|
+
t.Fatal(err)
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
|
|
902
914
|
func writeCodexV2Rollouts(
|
|
903
915
|
t *testing.T,
|
|
904
916
|
codeHome, projectRoot, sessionID, turnID, role, taskName string,
|
|
@@ -1096,6 +1108,156 @@ func TestCodexAgentDispatchArmsInstalledSkillRequirements(t *testing.T) {
|
|
|
1096
1108
|
}
|
|
1097
1109
|
}
|
|
1098
1110
|
|
|
1111
|
+
func TestCodexAgentDispatchPromptGivesExactNamedCallBeforeCompletion(t *testing.T) {
|
|
1112
|
+
root := newWorkspace(t)
|
|
1113
|
+
role := "devrites-plan-drafter"
|
|
1114
|
+
writeCodexAgentContract(t, root, role)
|
|
1115
|
+
writeCodexSkillContract(t, root, "rite-plan", role)
|
|
1116
|
+
|
|
1117
|
+
out, stderr, code := runDevritesIO(t, root, hookPayload(t, map[string]any{
|
|
1118
|
+
"hook_event_name": "UserPromptSubmit",
|
|
1119
|
+
"session_id": "session-plan-drafter-guidance",
|
|
1120
|
+
"turn_id": "turn-plan-drafter-guidance",
|
|
1121
|
+
"prompt": "$rite-plan repair",
|
|
1122
|
+
}), nil, "hook", "agent-dispatch", "--harness=codex")
|
|
1123
|
+
if code != 0 {
|
|
1124
|
+
t.Fatalf("arm exit=%d stderr=%s", code, stderr)
|
|
1125
|
+
}
|
|
1126
|
+
for _, want := range []string{
|
|
1127
|
+
"MANDATORY DISPATCH THIS TURN",
|
|
1128
|
+
"spawn_agent",
|
|
1129
|
+
"agent_type=" + role,
|
|
1130
|
+
"unique task_name",
|
|
1131
|
+
`fork_turns="none"`,
|
|
1132
|
+
".codex/agents/" + role + ".toml",
|
|
1133
|
+
"send agent_type anyway",
|
|
1134
|
+
"Wait for the returned child",
|
|
1135
|
+
} {
|
|
1136
|
+
if !strings.Contains(out, want) {
|
|
1137
|
+
t.Fatalf("initial dispatch guidance omitted %q:\n%s", want, out)
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
func TestCodexConditionalAgentDispatchRejectsDefaultAndArmsNamedRole(t *testing.T) {
|
|
1143
|
+
root := newWorkspace(t)
|
|
1144
|
+
role := "devrites-proof-runner"
|
|
1145
|
+
writeCodexAgentContract(t, root, role)
|
|
1146
|
+
writeCodexConditionalSkillContract(t, root, "rite-polish", role)
|
|
1147
|
+
sessionID, turnID := "session-conditional-role", "turn-conditional-role"
|
|
1148
|
+
|
|
1149
|
+
out, stderr, code := runDevritesIO(t, root, hookPayload(t, map[string]any{
|
|
1150
|
+
"hook_event_name": "UserPromptSubmit",
|
|
1151
|
+
"session_id": sessionID,
|
|
1152
|
+
"turn_id": turnID,
|
|
1153
|
+
"prompt": "$rite-polish",
|
|
1154
|
+
}), nil, "hook", "agent-dispatch", "--harness=codex")
|
|
1155
|
+
if code != 0 {
|
|
1156
|
+
t.Fatalf("arm exit=%d stderr=%s", code, stderr)
|
|
1157
|
+
}
|
|
1158
|
+
for _, want := range []string{
|
|
1159
|
+
"CONDITIONAL DISPATCH RULE",
|
|
1160
|
+
"exact named agent_type=devrites-<role>",
|
|
1161
|
+
`fork_turns="none"`,
|
|
1162
|
+
"send agent_type anyway",
|
|
1163
|
+
"never use a default child",
|
|
1164
|
+
} {
|
|
1165
|
+
if !strings.Contains(out, want) {
|
|
1166
|
+
t.Fatalf("conditional dispatch guidance omitted %q:\n%s", want, out)
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
for _, agentType := range []string{"", "default"} {
|
|
1171
|
+
out, stderr, code = runDevritesIO(t, root, hookPayload(t, map[string]any{
|
|
1172
|
+
"hook_event_name": "PreToolUse",
|
|
1173
|
+
"session_id": sessionID,
|
|
1174
|
+
"turn_id": turnID,
|
|
1175
|
+
"tool_name": "spawn_agent",
|
|
1176
|
+
"tool_use_id": "default-" + agentType,
|
|
1177
|
+
"tool_input": map[string]any{
|
|
1178
|
+
"agent_type": agentType,
|
|
1179
|
+
"task_name": "conditional_default",
|
|
1180
|
+
"fork_turns": "none",
|
|
1181
|
+
"message": "Run the conditional DevRites check.",
|
|
1182
|
+
},
|
|
1183
|
+
}), nil, "hook", "agent-dispatch", "--harness=codex")
|
|
1184
|
+
if code != 0 {
|
|
1185
|
+
t.Fatalf("default spawn exit=%d stderr=%s", code, stderr)
|
|
1186
|
+
}
|
|
1187
|
+
if decision, reason := parsePermissionDecision(t, out); decision != "deny" ||
|
|
1188
|
+
!strings.Contains(reason, "exact named agent_type=devrites-<role>") {
|
|
1189
|
+
t.Fatalf("conditional default child not denied: decision=%q reason=%q out=%s", decision, reason, out)
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
out, stderr, code = runDevritesIO(t, root, hookPayload(t, map[string]any{
|
|
1194
|
+
"hook_event_name": "PreToolUse",
|
|
1195
|
+
"session_id": sessionID,
|
|
1196
|
+
"turn_id": turnID,
|
|
1197
|
+
"tool_name": "spawn_agent",
|
|
1198
|
+
"tool_use_id": "missing-named-type",
|
|
1199
|
+
"tool_input": map[string]any{
|
|
1200
|
+
"task_name": "conditional_missing_type",
|
|
1201
|
+
"fork_turns": "none",
|
|
1202
|
+
"message": "Read .codex/agents/" + role + ".toml and run the check.",
|
|
1203
|
+
},
|
|
1204
|
+
}), nil, "hook", "agent-dispatch", "--harness=codex")
|
|
1205
|
+
if code != 0 {
|
|
1206
|
+
t.Fatalf("missing type spawn exit=%d stderr=%s", code, stderr)
|
|
1207
|
+
}
|
|
1208
|
+
if decision, reason := parsePermissionDecision(t, out); decision != "deny" ||
|
|
1209
|
+
!strings.Contains(reason, "agent_type="+role) ||
|
|
1210
|
+
!strings.Contains(reason, "send agent_type anyway") {
|
|
1211
|
+
t.Fatalf("missing named type did not get exact retry: decision=%q reason=%q out=%s", decision, reason, out)
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
out, stderr, code = runDevritesIO(t, root, hookPayload(t, map[string]any{
|
|
1215
|
+
"hook_event_name": "PreToolUse",
|
|
1216
|
+
"session_id": sessionID,
|
|
1217
|
+
"turn_id": turnID,
|
|
1218
|
+
"tool_name": "spawn_agent",
|
|
1219
|
+
"tool_use_id": "named-conditional",
|
|
1220
|
+
"tool_input": map[string]any{
|
|
1221
|
+
"agent_type": role,
|
|
1222
|
+
"task_name": "conditional_proof",
|
|
1223
|
+
"fork_turns": "none",
|
|
1224
|
+
"message": "Run the conditional DevRites check.",
|
|
1225
|
+
},
|
|
1226
|
+
}), nil, "hook", "agent-dispatch", "--harness=codex")
|
|
1227
|
+
if code != 0 || strings.TrimSpace(out) != "" {
|
|
1228
|
+
t.Fatalf("named conditional spawn rejected: exit=%d out=%s stderr=%s", code, out, stderr)
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
out, stderr, code = runDevritesIO(t, root, hookPayload(t, map[string]any{
|
|
1232
|
+
"hook_event_name": "Stop",
|
|
1233
|
+
"session_id": sessionID,
|
|
1234
|
+
"turn_id": turnID,
|
|
1235
|
+
}), nil, "hook", "agent-dispatch", "--harness=codex")
|
|
1236
|
+
if code != 0 || !strings.Contains(out, "DevRites dispatch for "+role+" is not complete") {
|
|
1237
|
+
t.Fatalf("named conditional spawn was not armed: exit=%d out=%s stderr=%s", code, out, stderr)
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
func TestCodexAgentDispatchAllowsDefaultChildOutsideSkillTurn(t *testing.T) {
|
|
1242
|
+
root := newWorkspace(t)
|
|
1243
|
+
out, stderr, code := runDevritesIO(t, root, hookPayload(t, map[string]any{
|
|
1244
|
+
"hook_event_name": "PreToolUse",
|
|
1245
|
+
"session_id": "session-unrelated-default",
|
|
1246
|
+
"turn_id": "turn-unrelated-default",
|
|
1247
|
+
"tool_name": "spawn_agent",
|
|
1248
|
+
"tool_use_id": "unrelated-default",
|
|
1249
|
+
"tool_input": map[string]any{
|
|
1250
|
+
"agent_type": "default",
|
|
1251
|
+
"task_name": "unrelated",
|
|
1252
|
+
"fork_turns": "all",
|
|
1253
|
+
"message": "Handle an unrelated bounded task.",
|
|
1254
|
+
},
|
|
1255
|
+
}), nil, "hook", "agent-dispatch", "--harness=codex")
|
|
1256
|
+
if code != 0 || strings.TrimSpace(out) != "" {
|
|
1257
|
+
t.Fatalf("unrelated default child was rejected: exit=%d out=%s stderr=%s", code, out, stderr)
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1099
1261
|
func TestCodexAgentDispatchRejectsInvalidInstalledSkillRequirements(t *testing.T) {
|
|
1100
1262
|
root := newWorkspace(t)
|
|
1101
1263
|
writeCodexSkillContract(t, root, "rite-vet", "root-inline-reviewer")
|
|
@@ -1406,6 +1568,79 @@ func TestCodexAgentDispatchConfirmsDurableV2NamedRole(t *testing.T) {
|
|
|
1406
1568
|
}
|
|
1407
1569
|
}
|
|
1408
1570
|
|
|
1571
|
+
func TestCodexAgentDispatchConfirmsConditionalDurableV2NamedRole(t *testing.T) {
|
|
1572
|
+
root := newWorkspace(t)
|
|
1573
|
+
codeHome := t.TempDir()
|
|
1574
|
+
sessionID, turnID := "session-v2-conditional", "turn-v2-conditional"
|
|
1575
|
+
role := "devrites-security-auditor"
|
|
1576
|
+
writeCodexAgentContract(t, root, role)
|
|
1577
|
+
writeCodexConditionalSkillContract(t, root, "devrites-audit", role)
|
|
1578
|
+
|
|
1579
|
+
runDevritesIO(t, root, hookPayload(t, map[string]any{
|
|
1580
|
+
"hook_event_name": "UserPromptSubmit",
|
|
1581
|
+
"session_id": sessionID,
|
|
1582
|
+
"turn_id": turnID,
|
|
1583
|
+
"prompt": "$devrites-audit security",
|
|
1584
|
+
}), nil, "hook", "agent-dispatch", "--harness=codex")
|
|
1585
|
+
|
|
1586
|
+
writeCodexV2Rollouts(
|
|
1587
|
+
t, codeHome, filepath.Dir(root), sessionID, turnID, role,
|
|
1588
|
+
"devrites_security_auditor_conditional", true, true, true,
|
|
1589
|
+
)
|
|
1590
|
+
stop := hookPayload(t, map[string]any{
|
|
1591
|
+
"hook_event_name": "Stop",
|
|
1592
|
+
"session_id": sessionID,
|
|
1593
|
+
"turn_id": turnID,
|
|
1594
|
+
})
|
|
1595
|
+
if out, stderr, code := runDevritesIO(t, root, stop,
|
|
1596
|
+
[]string{"CODEX_HOME=" + codeHome},
|
|
1597
|
+
"hook", "agent-dispatch", "--harness=codex"); code != 0 || strings.TrimSpace(out) != "" {
|
|
1598
|
+
t.Fatalf("conditional durable v2 stop exit=%d out=%s stderr=%s", code, out, stderr)
|
|
1599
|
+
}
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1602
|
+
func TestCodexAgentDispatchRejectsConditionalDurableV2DefaultChild(t *testing.T) {
|
|
1603
|
+
root := newWorkspace(t)
|
|
1604
|
+
codeHome := t.TempDir()
|
|
1605
|
+
sessionID, turnID := "session-v2-default", "turn-v2-default"
|
|
1606
|
+
writeCodexConditionalSkillContract(t, root, "devrites-audit", "devrites-security-auditor")
|
|
1607
|
+
|
|
1608
|
+
runDevritesIO(t, root, hookPayload(t, map[string]any{
|
|
1609
|
+
"hook_event_name": "UserPromptSubmit",
|
|
1610
|
+
"session_id": sessionID,
|
|
1611
|
+
"turn_id": turnID,
|
|
1612
|
+
"prompt": "$devrites-audit security",
|
|
1613
|
+
}), nil, "hook", "agent-dispatch", "--harness=codex")
|
|
1614
|
+
|
|
1615
|
+
writeCodexV2Rollouts(
|
|
1616
|
+
t, codeHome, filepath.Dir(root), sessionID, turnID, "default",
|
|
1617
|
+
"default_conditional_child", true, true, true,
|
|
1618
|
+
)
|
|
1619
|
+
stop := hookPayload(t, map[string]any{
|
|
1620
|
+
"hook_event_name": "Stop",
|
|
1621
|
+
"session_id": sessionID,
|
|
1622
|
+
"turn_id": turnID,
|
|
1623
|
+
})
|
|
1624
|
+
out, stderr, code := runDevritesIO(t, root, stop,
|
|
1625
|
+
[]string{"CODEX_HOME=" + codeHome},
|
|
1626
|
+
"hook", "agent-dispatch", "--harness=codex")
|
|
1627
|
+
if code != 0 {
|
|
1628
|
+
t.Fatalf("conditional default stop exit=%d stderr=%s", code, stderr)
|
|
1629
|
+
}
|
|
1630
|
+
var decision struct {
|
|
1631
|
+
Decision string `json:"decision"`
|
|
1632
|
+
Reason string `json:"reason"`
|
|
1633
|
+
}
|
|
1634
|
+
if err := json.Unmarshal([]byte(strings.TrimSpace(out)), &decision); err != nil {
|
|
1635
|
+
t.Fatalf("invalid conditional default response: %v\n%s", err, out)
|
|
1636
|
+
}
|
|
1637
|
+
if decision.Decision != "block" ||
|
|
1638
|
+
!strings.Contains(decision.Reason, "default or non-DevRites child") ||
|
|
1639
|
+
!strings.Contains(decision.Reason, "never use a default child") {
|
|
1640
|
+
t.Fatalf("conditional V2 default child passed: %#v", decision)
|
|
1641
|
+
}
|
|
1642
|
+
}
|
|
1643
|
+
|
|
1409
1644
|
func TestCodexAgentDispatchRejectsIncompleteDurableV2(t *testing.T) {
|
|
1410
1645
|
for _, tc := range []struct {
|
|
1411
1646
|
name string
|
|
@@ -26,8 +26,8 @@ only the source-write lifecycle.
|
|
|
26
26
|
4. Immediately before a serial dispatch:
|
|
27
27
|
|
|
28
28
|
```bash
|
|
29
|
-
devrites-engine reconcile snapshot
|
|
30
29
|
devrites-engine stuck log "$(cat .devrites/ACTIVE 2>/dev/null)" dispatch "<slice id>"
|
|
30
|
+
devrites-engine reconcile snapshot
|
|
31
31
|
```
|
|
32
32
|
|
|
33
33
|
A Forge slice runs `forge plan` first, then takes this same snapshot; see
|
|
@@ -26,8 +26,8 @@ only the source-write lifecycle.
|
|
|
26
26
|
4. Immediately before a serial dispatch:
|
|
27
27
|
|
|
28
28
|
```bash
|
|
29
|
-
devrites-engine reconcile snapshot
|
|
30
29
|
devrites-engine stuck log "$(cat .devrites/ACTIVE 2>/dev/null)" dispatch "<slice id>"
|
|
30
|
+
devrites-engine reconcile snapshot
|
|
31
31
|
```
|
|
32
32
|
|
|
33
33
|
A Forge slice runs `forge plan` first, then takes this same snapshot; see
|
|
@@ -26,8 +26,8 @@ only the source-write lifecycle.
|
|
|
26
26
|
4. Immediately before a serial dispatch:
|
|
27
27
|
|
|
28
28
|
```bash
|
|
29
|
-
devrites-engine reconcile snapshot
|
|
30
29
|
devrites-engine stuck log "$(cat .devrites/ACTIVE 2>/dev/null)" dispatch "<slice id>"
|
|
30
|
+
devrites-engine reconcile snapshot
|
|
31
31
|
```
|
|
32
32
|
|
|
33
33
|
A Forge slice runs `forge plan` first, then takes this same snapshot; see
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "devrites",
|
|
3
|
-
"version": "3.2.
|
|
3
|
+
"version": "3.2.8",
|
|
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",
|