devrites 3.2.11 → 3.2.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,18 @@
2
2
 
3
3
  All notable changes to DevRites are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and DevRites adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). Releases are generated automatically by [semantic-release](https://semantic-release.gitbook.io/) from Conventional Commits on `main`.
4
4
 
5
+ ## [3.2.13](https://github.com/ViktorsBaikers/DevRites/compare/v3.2.12...v3.2.13) (2026-07-26)
6
+
7
+ ### Fixed
8
+
9
+ * **agents:** retain canonical boundary before spawn ([b290aa9](https://github.com/ViktorsBaikers/DevRites/commit/b290aa97851915afcddd85ff43855fc8e07b6860))
10
+
11
+ ## [3.2.12](https://github.com/ViktorsBaikers/DevRites/compare/v3.2.11...v3.2.12) (2026-07-26)
12
+
13
+ ### Fixed
14
+
15
+ * **agents:** capture wright boundary before spawn ([4eb95f3](https://github.com/ViktorsBaikers/DevRites/commit/4eb95f3f75c150b9c535972db6d84fadf97f1a87))
16
+
5
17
  ## [3.2.11](https://github.com/ViktorsBaikers/DevRites/compare/v3.2.10...v3.2.11) (2026-07-26)
6
18
 
7
19
  ### Fixed
package/README.md CHANGED
@@ -25,7 +25,7 @@ final commit, push, and tag, and it requires a typed `GO` confirmation.
25
25
  Unattended runs may create local WIP checkpoint commits along the way, but only
26
26
  Ship collapses and pushes them.
27
27
 
28
- **Status:** [`v3.2.11`](https://github.com/ViktorsBaikers/DevRites/releases/tag/v3.2.11): see [`CHANGELOG.md`](CHANGELOG.md) for release notes.
28
+ **Status:** [`v3.2.13`](https://github.com/ViktorsBaikers/DevRites/releases/tag/v3.2.13): see [`CHANGELOG.md`](CHANGELOG.md) for release notes.
29
29
 
30
30
  ## Quick start
31
31
 
@@ -17,6 +17,7 @@ import (
17
17
  "time"
18
18
 
19
19
  "github.com/devrites/devrites/internal/harness"
20
+ "github.com/devrites/devrites/internal/lib"
20
21
  "github.com/devrites/devrites/internal/orient"
21
22
  "github.com/devrites/devrites/internal/safepath"
22
23
  )
@@ -400,8 +401,9 @@ func currentReconcileWindowID() string {
400
401
  if !ok {
401
402
  return ""
402
403
  }
403
- // The wright-start canonical fingerprint is captured after the pending
404
- // receipt. It narrows the delta but does not change source authorization.
404
+ // The wright-start canonical fingerprint is captured by the spawn hook
405
+ // before the child starts. It narrows the delta but does not change source
406
+ // authorization.
405
407
  names := []string{".reconcile-base", ".reconcile-allowlist", ".reconcile-devrites"}
406
408
  h := sha256.New()
407
409
  for _, name := range names {
@@ -895,6 +897,41 @@ func stopDispatchFailure(reason string, stdout, stderr io.Writer) int {
895
897
  return exitOK
896
898
  }
897
899
 
900
+ func refreshPendingWrightBoundary(
901
+ root string,
902
+ in agentDispatchHookInput,
903
+ armed map[string]struct{},
904
+ attempts []*agentDispatchAttempt,
905
+ ) error {
906
+ if in.AgentID != "" || in.AgentType != "" || currentReconcileWindowID() == "" {
907
+ return nil
908
+ }
909
+ _, wrightRequired := armed["devrites-slice-wright"]
910
+ _, conditional := armed[agentDispatchSkillGuard]
911
+ if !wrightRequired && !conditional {
912
+ return nil
913
+ }
914
+ for _, attempt := range attempts {
915
+ if attempt.Role == "devrites-slice-wright" {
916
+ return nil
917
+ }
918
+ }
919
+ durable, err := durableCodexV2DispatchAttempts(root, in.SessionID, in.TurnID, armed)
920
+ if err != nil {
921
+ return err
922
+ }
923
+ for _, attempt := range durable {
924
+ if attempt.Role == "devrites-slice-wright" {
925
+ return nil
926
+ }
927
+ }
928
+ workspaceRoot, slug, _, ok := resolveWorkspace()
929
+ if !ok || !sameResolvedPath(workspaceRoot, root) {
930
+ return fmt.Errorf("active reconcile window is unavailable")
931
+ }
932
+ return lib.CaptureReconcileWrightBoundary(root, slug)
933
+ }
934
+
898
935
  func hookAgentDispatch(h harness.Harness, stdin io.Reader, stdout, stderr io.Writer) int {
899
936
  if h != harness.Codex {
900
937
  _, _ = io.Copy(io.Discard, stdin)
@@ -940,6 +977,13 @@ func hookAgentDispatch(h harness.Harness, stdin io.Reader, stdout, stderr io.Wri
940
977
  return stopDispatchBlock(h, "DevRites could not arm the required agent dispatch: "+err.Error(), stdout, stderr)
941
978
  }
942
979
  }
980
+ events, stateErr := readAgentDispatchEvents(root, in.SessionID)
981
+ if stateErr == nil {
982
+ armed, attempts := dispatchTurnState(events, in.TurnID)
983
+ if err := refreshPendingWrightBoundary(root, in, armed, attempts); err != nil {
984
+ debugf(stderr, "agent-dispatch pending wright boundary: %v", err)
985
+ }
986
+ }
943
987
  if len(roles) > 0 {
944
988
  instructions := make([]string, 0, len(roles))
945
989
  for _, role := range roles {
@@ -962,6 +1006,18 @@ func hookAgentDispatch(h harness.Harness, stdin io.Reader, stdout, stderr io.Wri
962
1006
  case "PreToolUse":
963
1007
  return hookAgentDispatchPreTool(h, root, in, stdout, stderr)
964
1008
 
1009
+ case "PostToolUse":
1010
+ events, err := readAgentDispatchEvents(root, in.SessionID)
1011
+ if err != nil {
1012
+ debugf(stderr, "agent-dispatch post-tool state: %v", err)
1013
+ return exitOK
1014
+ }
1015
+ armed, attempts := dispatchTurnState(events, in.TurnID)
1016
+ if err := refreshPendingWrightBoundary(root, in, armed, attempts); err != nil {
1017
+ debugf(stderr, "agent-dispatch post-tool wright boundary: %v", err)
1018
+ }
1019
+ return exitOK
1020
+
965
1021
  case "SubagentStop":
966
1022
  if in.AgentID == "" {
967
1023
  return exitOK
@@ -1088,6 +1144,15 @@ func hookAgentDispatchPreTool(h harness.Harness, root string, in agentDispatchHo
1088
1144
  if role == "devrites-slice-wright" && windowID == "" {
1089
1145
  return preToolDeny(h, "devrites-slice-wright requires an active reconcile snapshot before spawn_agent.", stdout, stderr)
1090
1146
  }
1147
+ if role == "devrites-slice-wright" {
1148
+ workspaceRoot, slug, _, ok := resolveWorkspace()
1149
+ if !ok || !sameResolvedPath(workspaceRoot, root) {
1150
+ return preToolDeny(h, "DevRites could not resolve the active wright reconcile window before spawn_agent.", stdout, stderr)
1151
+ }
1152
+ if err := lib.CaptureReconcileWrightBoundary(root, slug); err != nil {
1153
+ return preToolDeny(h, "DevRites could not capture the wright canonical boundary before spawn_agent: "+err.Error(), stdout, stderr)
1154
+ }
1155
+ }
1091
1156
  if err := appendAgentDispatchEvent(root, in.SessionID, agentDispatchEvent{
1092
1157
  Event: "pending",
1093
1158
  TurnID: in.TurnID,
@@ -5,6 +5,7 @@ package main_test
5
5
  // check stdout and exit status. parity_test.go owns the parity oracle.
6
6
 
7
7
  import (
8
+ "bytes"
8
9
  "encoding/json"
9
10
  "os"
10
11
  "path/filepath"
@@ -1900,6 +1901,174 @@ func TestCodexAgentDispatchGatesReconcileCloseOnBoundWrightResult(t *testing.T)
1900
1901
  }
1901
1902
  }
1902
1903
 
1904
+ func TestCodexAgentDispatchCapturesWrightBoundaryBeforeSpawn(t *testing.T) {
1905
+ root := newWorkspace(t)
1906
+ writeActive(t, root, "auth-tokens")
1907
+ workspace := filepath.Join(root, "features", "auth-tokens")
1908
+ for name, body := range map[string]string{
1909
+ ".reconcile-base": ".reconcile-base\n",
1910
+ ".reconcile-allowlist": ".reconcile-allowlist\n",
1911
+ ".reconcile-devrites": "[]\n",
1912
+ "action.log": "Prior root action\n",
1913
+ "browser-evidence.md": "Prior root evidence\n",
1914
+ "decisions.md": "Prior root decision\n",
1915
+ "evidence.md": "Prior root evidence\n",
1916
+ "footprint.log": "Prior root footprint\n",
1917
+ "state.md": "Phase: build\n",
1918
+ "touched-files.md": "Prior root manifest\n",
1919
+ } {
1920
+ if err := os.WriteFile(filepath.Join(workspace, name), []byte(body), 0o600); err != nil {
1921
+ t.Fatal(err)
1922
+ }
1923
+ }
1924
+ if err := os.MkdirAll(filepath.Join(workspace, ".reconcile-objects"), 0o700); err != nil {
1925
+ t.Fatal(err)
1926
+ }
1927
+
1928
+ sessionID, turnID := "session-wright-spawn", "turn-wright-spawn"
1929
+ writeCodexAgentContract(t, root, "devrites-slice-wright")
1930
+ writeCodexSkillContract(t, root, "rite-build", "devrites-slice-wright")
1931
+ runDevritesIO(t, root, hookPayload(t, map[string]any{
1932
+ "hook_event_name": "UserPromptSubmit",
1933
+ "session_id": sessionID,
1934
+ "turn_id": turnID,
1935
+ "prompt": "$rite-build",
1936
+ }), nil, "hook", "agent-dispatch", "--harness=codex")
1937
+
1938
+ spawn := hookPayload(t, map[string]any{
1939
+ "hook_event_name": "PreToolUse",
1940
+ "session_id": sessionID,
1941
+ "turn_id": turnID,
1942
+ "tool_name": "spawn_agent",
1943
+ "tool_use_id": "spawn-wright",
1944
+ "tool_input": map[string]any{
1945
+ "agent_type": "devrites-slice-wright",
1946
+ "fork_turns": "none",
1947
+ "message": "Read .codex/agents/devrites-slice-wright.toml.",
1948
+ },
1949
+ })
1950
+ if out, stderr, code := runDevritesIO(t, root, spawn, nil,
1951
+ "hook", "agent-dispatch", "--harness=codex"); code != 0 || strings.TrimSpace(out) != "" {
1952
+ t.Fatalf("spawn exit=%d out=%s stderr=%s", code, out, stderr)
1953
+ }
1954
+
1955
+ raw, err := os.ReadFile(filepath.Join(workspace, ".reconcile-wright-devrites"))
1956
+ if err != nil {
1957
+ t.Fatalf("read wright boundary: %v", err)
1958
+ }
1959
+ for _, path := range []string{
1960
+ "features/auth-tokens/action.log",
1961
+ "features/auth-tokens/browser-evidence.md",
1962
+ "features/auth-tokens/decisions.md",
1963
+ "features/auth-tokens/evidence.md",
1964
+ "features/auth-tokens/footprint.log",
1965
+ "features/auth-tokens/state.md",
1966
+ "features/auth-tokens/touched-files.md",
1967
+ } {
1968
+ if !strings.Contains(string(raw), path) {
1969
+ t.Errorf("wright boundary omitted %s: %s", path, raw)
1970
+ }
1971
+ }
1972
+ }
1973
+
1974
+ func TestCodexAgentDispatchRefreshesWrightBoundaryUntilDurableSpawn(t *testing.T) {
1975
+ root := newWorkspace(t)
1976
+ codeHome := t.TempDir()
1977
+ writeActive(t, root, "auth-tokens")
1978
+ workspace := filepath.Join(root, "features", "auth-tokens")
1979
+ for name, body := range map[string]string{
1980
+ ".reconcile-base": ".reconcile-base\n",
1981
+ ".reconcile-allowlist": ".reconcile-allowlist\n",
1982
+ ".reconcile-devrites": "[]\n",
1983
+ "action.log": "Prior root action\n",
1984
+ "browser-evidence.md": "Prior root evidence\n",
1985
+ "decisions.md": "Prior root decision\n",
1986
+ "evidence.md": "Prior root evidence\n",
1987
+ "footprint.log": "Prior root footprint\n",
1988
+ "state.md": "Phase: ready\n",
1989
+ "touched-files.md": "Prior root manifest\n",
1990
+ } {
1991
+ if err := os.WriteFile(filepath.Join(workspace, name), []byte(body), 0o600); err != nil {
1992
+ t.Fatal(err)
1993
+ }
1994
+ }
1995
+ if err := os.MkdirAll(filepath.Join(workspace, ".reconcile-objects"), 0o700); err != nil {
1996
+ t.Fatal(err)
1997
+ }
1998
+
1999
+ sessionID, turnID := "session-wright-durable", "turn-wright-durable"
2000
+ role := "devrites-slice-wright"
2001
+ writeCodexAgentContract(t, root, role)
2002
+ writeCodexSkillContract(t, root, "rite-build", role)
2003
+ env := []string{"CODEX_HOME=" + codeHome}
2004
+ runDevritesIO(t, root, hookPayload(t, map[string]any{
2005
+ "hook_event_name": "UserPromptSubmit",
2006
+ "session_id": sessionID,
2007
+ "turn_id": turnID,
2008
+ "prompt": "$rite-build",
2009
+ }), env, "hook", "agent-dispatch", "--harness=codex")
2010
+
2011
+ wrightState := filepath.Join(workspace, ".reconcile-wright-devrites")
2012
+ initial, err := os.ReadFile(wrightState)
2013
+ if err != nil {
2014
+ t.Fatalf("read initial wright boundary: %v", err)
2015
+ }
2016
+ for _, path := range []string{
2017
+ "features/auth-tokens/action.log",
2018
+ "features/auth-tokens/browser-evidence.md",
2019
+ "features/auth-tokens/decisions.md",
2020
+ "features/auth-tokens/evidence.md",
2021
+ "features/auth-tokens/footprint.log",
2022
+ "features/auth-tokens/state.md",
2023
+ "features/auth-tokens/touched-files.md",
2024
+ } {
2025
+ if !strings.Contains(string(initial), path) {
2026
+ t.Errorf("pending wright boundary omitted %s: %s", path, initial)
2027
+ }
2028
+ }
2029
+ if err := os.WriteFile(filepath.Join(workspace, "state.md"), []byte("Phase: build\n"), 0o600); err != nil {
2030
+ t.Fatal(err)
2031
+ }
2032
+ postTool := hookPayload(t, map[string]any{
2033
+ "hook_event_name": "PostToolUse",
2034
+ "session_id": sessionID,
2035
+ "turn_id": turnID,
2036
+ "tool_name": "apply_patch",
2037
+ "tool_use_id": "root-state-edit",
2038
+ "tool_input": map[string]any{"file_path": "state.md"},
2039
+ })
2040
+ if out, stderr, code := runDevritesIO(t, root, postTool, env,
2041
+ "hook", "agent-dispatch", "--harness=codex"); code != 0 || strings.TrimSpace(out) != "" {
2042
+ t.Fatalf("root post-tool refresh exit=%d out=%s stderr=%s", code, out, stderr)
2043
+ }
2044
+ beforeSpawn, err := os.ReadFile(wrightState)
2045
+ if err != nil {
2046
+ t.Fatalf("read refreshed wright boundary: %v", err)
2047
+ }
2048
+ if bytes.Equal(initial, beforeSpawn) {
2049
+ t.Fatal("root PostToolUse did not refresh the pending wright boundary")
2050
+ }
2051
+
2052
+ writeCodexV2Rollouts(
2053
+ t, codeHome, filepath.Dir(root), sessionID, turnID, role,
2054
+ "devrites_slice_wright_unhooked", true, true, true,
2055
+ )
2056
+ if err := os.WriteFile(filepath.Join(workspace, "state.md"), []byte("Writer mutation\n"), 0o600); err != nil {
2057
+ t.Fatal(err)
2058
+ }
2059
+ if out, stderr, code := runDevritesIO(t, root, postTool, env,
2060
+ "hook", "agent-dispatch", "--harness=codex"); code != 0 || strings.TrimSpace(out) != "" {
2061
+ t.Fatalf("post-spawn PostToolUse exit=%d out=%s stderr=%s", code, out, stderr)
2062
+ }
2063
+ afterSpawn, err := os.ReadFile(wrightState)
2064
+ if err != nil {
2065
+ t.Fatalf("read frozen wright boundary: %v", err)
2066
+ }
2067
+ if !bytes.Equal(beforeSpawn, afterSpawn) {
2068
+ t.Fatal("durable V2 spawn did not freeze the pre-writer canonical boundary")
2069
+ }
2070
+ }
2071
+
1903
2072
  func TestCodexAgentDispatchBindsDurableV2WrightToReconcileWindow(t *testing.T) {
1904
2073
  for _, tc := range []struct {
1905
2074
  name string
@@ -123,8 +123,9 @@ resume the same slice. Close it before abandoning the slice for a scope/plan tra
123
123
  Use named wright → enforced V1 generic worker from `standards/agents.md`. Codex V2
124
124
  dispatches the exact named `devrites-slice-wright` with a unique `task_name` and
125
125
  `fork_turns="none"`; its durable rollout proves the native role, wait, and non-empty
126
- result in the current window. The confirmed start fingerprints canonical state apart
127
- from the retained source baseline: pre-start root recovery records are excluded, while
126
+ result in the current window. Root lifecycle hooks keep the pending canonical
127
+ boundary current until the accepted or durable spawn, then freeze it apart from the
128
+ retained source baseline: pre-start root recovery records are excluded, while
128
129
  post-start canonical changes fail.
129
130
  Any generic worker still needs the exact allowlist or an isolated checkout;
130
131
  reconciliation alone is insufficient. If no safe fresh-agent rung is available, stop
@@ -123,8 +123,9 @@ resume the same slice. Close it before abandoning the slice for a scope/plan tra
123
123
  Use named wright → enforced V1 generic worker from `standards/agents.md`. Codex V2
124
124
  dispatches the exact named `devrites-slice-wright` with a unique `task_name` and
125
125
  `fork_turns="none"`; its durable rollout proves the native role, wait, and non-empty
126
- result in the current window. The confirmed start fingerprints canonical state apart
127
- from the retained source baseline: pre-start root recovery records are excluded, while
126
+ result in the current window. Root lifecycle hooks keep the pending canonical
127
+ boundary current until the accepted or durable spawn, then freeze it apart from the
128
+ retained source baseline: pre-start root recovery records are excluded, while
128
129
  post-start canonical changes fail.
129
130
  Any generic worker still needs the exact allowlist or an isolated checkout;
130
131
  reconciliation alone is insufficient. If no safe fresh-agent rung is available, stop
@@ -57,6 +57,16 @@
57
57
  "statusMessage": "DevRites: checking test/build result"
58
58
  }
59
59
  ]
60
+ },
61
+ {
62
+ "matcher": "Bash|Shell|sh|exec_command|run_command|Edit|Write|MultiEdit|NotebookEdit|apply_patch|exec|js|python|computer|computer_use|write_stdin|run_code|Agent|Task|spawn_agent|delegate|dispatch_agent|create_agent|wait|wait_agent",
63
+ "hooks": [
64
+ {
65
+ "type": "command",
66
+ "command": "command -v devrites-engine >/dev/null 2>&1 || exit 0; cd \"$(git rev-parse --show-toplevel 2>/dev/null || pwd)\" 2>/dev/null || exit 0; exec devrites-engine hook agent-dispatch --harness=codex",
67
+ "statusMessage": "DevRites: retaining the pre-writer canonical boundary"
68
+ }
69
+ ]
60
70
  }
61
71
  ],
62
72
  "PreCompact": [
@@ -123,8 +123,9 @@ resume the same slice. Close it before abandoning the slice for a scope/plan tra
123
123
  Use named wright → enforced V1 generic worker from `standards/agents.md`. Codex V2
124
124
  dispatches the exact named `devrites-slice-wright` with a unique `task_name` and
125
125
  `fork_turns="none"`; its durable rollout proves the native role, wait, and non-empty
126
- result in the current window. The confirmed start fingerprints canonical state apart
127
- from the retained source baseline: pre-start root recovery records are excluded, while
126
+ result in the current window. Root lifecycle hooks keep the pending canonical
127
+ boundary current until the accepted or durable spawn, then freeze it apart from the
128
+ retained source baseline: pre-start root recovery records are excluded, while
128
129
  post-start canonical changes fail.
129
130
  Any generic worker still needs the exact allowlist or an isolated checkout;
130
131
  reconciliation alone is insufficient. If no safe fresh-agent rung is available, stop
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "devrites",
3
- "version": "3.2.11",
3
+ "version": "3.2.13",
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",
@@ -246,6 +246,16 @@ gen_codex_hooks_json() {
246
246
  "statusMessage": "DevRites: checking test/build result"
247
247
  }
248
248
  ]
249
+ },
250
+ {
251
+ "matcher": "Bash|Shell|sh|exec_command|run_command|Edit|Write|MultiEdit|NotebookEdit|apply_patch|exec|js|python|computer|computer_use|write_stdin|run_code|Agent|Task|spawn_agent|delegate|dispatch_agent|create_agent|wait|wait_agent",
252
+ "hooks": [
253
+ {
254
+ "type": "command",
255
+ "command": "command -v devrites-engine >/dev/null 2>&1 || exit 0; cd \"$(git rev-parse --show-toplevel 2>/dev/null || pwd)\" 2>/dev/null || exit 0; exec devrites-engine hook agent-dispatch --harness=codex",
256
+ "statusMessage": "DevRites: retaining the pre-writer canonical boundary"
257
+ }
258
+ ]
249
259
  }
250
260
  ],
251
261
  "PreCompact": [