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 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.6`](https://github.com/ViktorsBaikers/DevRites/releases/tag/v3.2.6): see [`CHANGELOG.md`](CHANGELOG.md) for release notes.
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
 
@@ -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
- windowID := ""
523
+ attempt.AgentID = childID
524
+ attempt.Stopped = true
525
+ attempt.Waited = true
508
526
  if spawn.Role == "devrites-slice-wright" {
509
- windowID = currentReconcileWindowID()
510
- if windowID == "" || !reconcileWindowPredates(spawn.SpawnedAt) {
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
- attempts = append(attempts, &agentDispatchAttempt{
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
- if _, required := armed[args.AgentType]; !required ||
608
- args.TaskName == "" || strings.Contains(args.TaskName, "/") ||
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
- fmt.Fprintf(stdout, "DevRites: this turn requires a confirmed %s subagent start, wait, and non-empty result; a success phrase is not evidence.", strings.Join(roles, ", "))
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
- if reason := incompleteDispatchReason(armed, attempts); reason != "" {
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
- if len(armed) == 0 {
983
- return exitOK
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
- return preToolDeny(h, "DevRites MultiAgent V2 dispatch requires the exact named agent_type=devrites-<role>; omitting agent_type does not load the specialist role contract.", stdout, stderr)
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 len(armed) == 0 {
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
- return preToolDeny(h, incompleteDispatchReason(armed, attempts), stdout, stderr)
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.6",
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",