devrites 3.2.9 → 3.2.11

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.11](https://github.com/ViktorsBaikers/DevRites/compare/v3.2.10...v3.2.11) (2026-07-26)
6
+
7
+ ### Fixed
8
+
9
+ * **devrites:** bind canonical state at wright start ([b58a531](https://github.com/ViktorsBaikers/DevRites/commit/b58a531749b94f734b7abf7469656ad543a0cfa0))
10
+
11
+ ## [3.2.10](https://github.com/ViktorsBaikers/DevRites/compare/v3.2.9...v3.2.10) (2026-07-26)
12
+
13
+ ### Fixed
14
+
15
+ * **devrites:** allow external dispatch scratch ([fdaac3f](https://github.com/ViktorsBaikers/DevRites/commit/fdaac3f9aaf19af897c6d3d70ce01fc046a60d3e))
16
+
5
17
  ## [3.2.9](https://github.com/ViktorsBaikers/DevRites/compare/v3.2.8...v3.2.9) (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.9`](https://github.com/ViktorsBaikers/DevRites/releases/tag/v3.2.9): see [`CHANGELOG.md`](CHANGELOG.md) for release notes.
28
+ **Status:** [`v3.2.11`](https://github.com/ViktorsBaikers/DevRites/releases/tag/v3.2.11): see [`CHANGELOG.md`](CHANGELOG.md) for release notes.
29
29
 
30
30
  ## Quick start
31
31
 
@@ -62,11 +62,16 @@ Each dispatch follows the retained-baseline sequence:
62
62
 
63
63
  1. `reconcile snapshot` captures the original dirty-tree baseline, private Git
64
64
  objects, exact allowlist, and canonical-state fingerprint.
65
- 2. The sole wright returns code/tests; the root validates its typed identity and
65
+ 2. The confirmed wright start captures a second canonical-state fingerprint.
66
+ This preserves the original source baseline while separating root-owned
67
+ recovery records that predate the writer from canonical mutations made while
68
+ the writer is active.
69
+ 3. The sole wright returns code/tests; the root validates its typed identity and
66
70
  exact changed-file set.
67
- 3. `reconcile check` rejects anything outside the root allowlist.
68
- 4. `test-integrity` runs against the retained baseline.
69
- 5. After proof and decision checks pass, `reconcile close` retires the private
71
+ 4. `reconcile check` rejects anything outside the root allowlist and any
72
+ canonical-state change after the wright start.
73
+ 5. `test-integrity` runs against the retained baseline.
74
+ 6. After proof and decision checks pass, `reconcile close` retires the private
70
75
  window; only then does the root write canonical records.
71
76
 
72
77
  An ordinary `reconcile check` may revalidate an existing retained window
@@ -75,7 +80,9 @@ window. `reconcile close` still requires the confirmed, awaited wright result
75
80
  bound to the active snapshot. While the window is armed, the root guard permits
76
81
  inspectable writes only under `.devrites` or at an absolute scratch path outside
77
82
  the project; source-tree writes, relative escapes, and opaque execution remain
78
- blocked.
83
+ blocked. The standalone A1 hook and wright-scope hook share this classification;
84
+ Codex `cmd` and Claude `command` payloads may create a literal external packet
85
+ with a patch, heredoc, or copy and may hash it without gaining source authority.
79
86
 
80
87
  The canonical-state fingerprint excludes root-owned operational records:
81
88
  `timeline.jsonl`, feature `events.jsonl`, a valid `recovery-attempts.jsonl`,
package/engine/hooks.go CHANGED
@@ -377,7 +377,7 @@ func safeReadonlyShellSegment(segment string) bool {
377
377
  "grep", "rg", "wc", "sort", "uniq", "cut", "tr", "stat", "file",
378
378
  "readlink", "realpath", "basename", "dirname", "cmp", "diff", "jq", "yq",
379
379
  "tree", "du", "df", "printenv", "which", "date", "uname", "id", "whoami",
380
- "ps", "echo", "printf", "test", "[", "cd":
380
+ "ps", "echo", "printf", "test", "[", "cd", "sha256sum", "shasum":
381
381
  return true
382
382
  case "find":
383
383
  return !hasAnyArg(args, "-delete", "-exec", "-execdir", "-ok", "-okdir", "-fprint", "-fprintf")
@@ -546,6 +546,7 @@ func hookSubagentOrient(h harness.Harness, stdin io.Reader, stdout, stderr io.Wr
546
546
  }
547
547
  agentType := h.SubagentAgentType(bytes.NewReader(data))
548
548
  context := strings.ReplaceAll(subagentOrientContext, "\r\n", "\n")
549
+ confirmedRole := ""
549
550
  if h == harness.Codex {
550
551
  in, parseErr := parseAgentDispatchHookInput(bytes.NewReader(data))
551
552
  root, ok := agentDispatchRoot()
@@ -559,6 +560,7 @@ func hookSubagentOrient(h harness.Harness, stdin io.Reader, stdout, stderr io.Wr
559
560
  if bindErr != nil {
560
561
  context += "\n\n- **Dispatch binding failed.** Return blocked without doing specialist work; the parent must repair the agent-start receipt."
561
562
  } else if bound {
563
+ confirmedRole = role
562
564
  if in.AgentType == role {
563
565
  context += fmt.Sprintf(
564
566
  "\n\n- **Confirmed role.** Your named Codex profile is `%s`; follow its loaded `developer_instructions` and execute only the unchanged packet from the parent.",
@@ -578,6 +580,17 @@ func hookSubagentOrient(h harness.Harness, stdin io.Reader, stdout, stderr io.Wr
578
580
  }
579
581
  } else if !strings.HasPrefix(agentType, "devrites-") {
580
582
  return exitOK // Stay silent without a DevRites agent identity.
583
+ } else {
584
+ confirmedRole = agentType
585
+ }
586
+ if confirmedRole == "devrites-slice-wright" {
587
+ root, slug, _, ok := resolveWorkspace()
588
+ if !ok {
589
+ context += "\n\n- **Wright boundary unavailable.** Return blocked without writing; the parent must restore the active reconcile window."
590
+ } else if err := lib.CaptureReconcileWrightBoundary(root, slug); err != nil {
591
+ debugf(stderr, "subagent-orient wright boundary: %v", err)
592
+ context += "\n\n- **Wright boundary unavailable.** Return blocked without writing; the parent must repair the retained reconcile window."
593
+ }
581
594
  }
582
595
  out, err := h.SubagentStartContext(context)
583
596
  if err != nil {
@@ -400,6 +400,8 @@ func currentReconcileWindowID() string {
400
400
  if !ok {
401
401
  return ""
402
402
  }
403
+ // The wright-start canonical fingerprint is captured after the pending
404
+ // receipt. It narrows the delta but does not change source authorization.
403
405
  names := []string{".reconcile-base", ".reconcile-allowlist", ".reconcile-devrites"}
404
406
  h := sha256.New()
405
407
  for _, name := range names {
@@ -325,6 +325,8 @@ func redwatchSafe(cmd string) string {
325
325
  // node regex the shell guards use.
326
326
  var patchPathRe = regexp.MustCompile(`(?m)^\*\*\* (?:(?:Add|Update|Delete) File|Move to): (.+)$`)
327
327
 
328
+ var shellHeredocStartRe = regexp.MustCompile(`<<(-?)[ \t]*(?:'([A-Za-z_][A-Za-z0-9_]*)'|"([A-Za-z_][A-Za-z0-9_]*)"|([A-Za-z_][A-Za-z0-9_]*))`)
329
+
328
330
  // isEditTool reports whether a tool can write source. toolBaseName also handles
329
331
  // host-qualified names such as functions.apply_patch and MCP symbol editors.
330
332
  func isEditTool(tool string) bool {
@@ -346,6 +348,39 @@ func patchPaths(command string) []string {
346
348
  return paths
347
349
  }
348
350
 
351
+ func stripShellHeredocBodies(command string) (string, bool) {
352
+ lines := strings.Split(strings.ReplaceAll(command, "\r\n", "\n"), "\n")
353
+ out := make([]string, 0, len(lines))
354
+ for i := 0; i < len(lines); i++ {
355
+ line := lines[i]
356
+ out = append(out, line)
357
+ for _, match := range shellHeredocStartRe.FindAllStringSubmatch(line, -1) {
358
+ delimiter := match[2]
359
+ if delimiter == "" {
360
+ delimiter = match[3]
361
+ }
362
+ if delimiter == "" {
363
+ return "", false
364
+ }
365
+ found := false
366
+ for i++; i < len(lines); i++ {
367
+ candidate := lines[i]
368
+ if match[1] == "-" {
369
+ candidate = strings.TrimLeft(candidate, "\t")
370
+ }
371
+ if candidate == delimiter {
372
+ found = true
373
+ break
374
+ }
375
+ }
376
+ if !found {
377
+ return "", false
378
+ }
379
+ }
380
+ }
381
+ return strings.Join(out, "\n"), true
382
+ }
383
+
349
384
  // shellOutputRedirections removes harmless fd redirects and returns every file
350
385
  // target of >, >>, or >|. Dynamic targets are opaque and therefore unsafe.
351
386
  func shellOutputRedirections(command string) (sanitized string, paths []string, opaque bool) {
@@ -471,44 +506,13 @@ func hookA1Guard(h harness.Harness, stdin io.Reader, stdout, stderr io.Writer) i
471
506
  }
472
507
 
473
508
  in := h.ParseGuardInput(bytes.NewReader(data))
474
- if !isEditTool(in.ToolName) {
475
- return exitOK
476
- }
477
509
  // The wright may edit. Claude sends agent_id; Codex sends agent_type.
478
510
  if in.AgentID != "" || in.AgentType != "" {
479
511
  return exitOK
480
512
  }
481
-
482
- projectDir := filepath.Dir(root)
483
- target := ""
484
- if in.ToolName == "apply_patch" {
485
- sourcePatch := false
486
- for _, p := range patchPaths(in.Command) {
487
- abs := p
488
- if !filepath.IsAbs(abs) {
489
- abs = filepath.Join(projectDir, abs)
490
- }
491
- if !underDevrites(abs, root) {
492
- sourcePatch = true
493
- break
494
- }
495
- }
496
- if !sourcePatch {
497
- return exitOK
498
- }
499
- target = "apply_patch"
500
- } else {
501
- if in.FilePath == "" {
502
- return exitOK
503
- }
504
- abs := in.FilePath
505
- if !filepath.IsAbs(abs) {
506
- abs = filepath.Join(projectDir, abs)
507
- }
508
- if underDevrites(abs, root) {
509
- return exitOK // orchestrator editing its own .devrites bookkeeping
510
- }
511
- target = abs
513
+ handled, violation, target := classifyRootBuildWindowOperation(root, data, in)
514
+ if !handled || !violation {
515
+ return exitOK
512
516
  }
513
517
 
514
518
  mode := os.Getenv("DEVRITES_A1_HOOK")
@@ -842,7 +846,11 @@ func uniqueStrings(values []string) []string {
842
846
  }
843
847
 
844
848
  func wrightShellWritePaths(command string) ([]string, bool) {
845
- if strings.TrimSpace(command) == "" || wrightForbiddenShellRe.MatchString(command) {
849
+ patches := patchPaths(command)
850
+ var ok bool
851
+ command, ok = stripShellHeredocBodies(command)
852
+ if !ok || strings.TrimSpace(command) == "" || strings.Contains(command, "$(") ||
853
+ strings.ContainsRune(command, '`') || wrightForbiddenShellRe.MatchString(command) {
846
854
  return nil, true
847
855
  }
848
856
  sanitized, paths, opaque := shellOutputRedirections(command)
@@ -850,7 +858,6 @@ func wrightShellWritePaths(command string) ([]string, bool) {
850
858
  return nil, true
851
859
  }
852
860
 
853
- patches := patchPaths(command)
854
861
  if strings.Contains(command, "apply_patch") {
855
862
  if len(patches) == 0 {
856
863
  return nil, true
@@ -896,6 +903,15 @@ func wrightShellWritePaths(command string) ([]string, bool) {
896
903
  found = nonFlagArgs(args)
897
904
  case "rm", "unlink", "touch", "mkdir", "rmdir", "truncate":
898
905
  found = nonFlagArgs(args)
906
+ case "cp":
907
+ if hasAnyArg(args, "-t", "--target-directory") || hasArgPrefix(args, "--target-directory=") {
908
+ return nil, true
909
+ }
910
+ operands := nonFlagArgs(args)
911
+ if len(operands) < 2 {
912
+ return nil, true
913
+ }
914
+ found = operands[len(operands)-1:]
899
915
  default:
900
916
  return nil, true
901
917
  }
@@ -1014,14 +1030,22 @@ func guardRootBuildWindow(h harness.Harness, data []byte, in harness.GuardInput,
1014
1030
  return exitOK, false
1015
1031
  }
1016
1032
 
1017
- if isAgentDispatchTool(in.ToolName) {
1018
- return exitOK, true
1033
+ handled, violation, _ := classifyRootBuildWindowOperation(root, data, in)
1034
+ if violation {
1035
+ return denyWright(h, stdout, stderr, a1DenyReason, drvreason.HookA1Denied), true
1019
1036
  }
1037
+ return exitOK, handled
1038
+ }
1020
1039
 
1021
- projectDir := filepath.Dir(root)
1040
+ func classifyRootBuildWindowOperation(root string, data []byte, in harness.GuardInput) (handled, violation bool, target string) {
1041
+ if isAgentDispatchTool(in.ToolName) {
1042
+ return true, false, ""
1043
+ }
1022
1044
  if isOpaqueExecutionTool(in.ToolName) {
1023
- return denyWright(h, stdout, stderr, a1DenyReason, drvreason.HookA1Denied), true
1045
+ return true, true, in.ToolName
1024
1046
  }
1047
+
1048
+ projectDir := filepath.Dir(root)
1025
1049
  targets, suspicious := rootMutationTargets(data, in)
1026
1050
  for _, target := range targets {
1027
1051
  absoluteTarget := filepath.IsAbs(target)
@@ -1032,21 +1056,24 @@ func guardRootBuildWindow(h harness.Harness, data []byte, in harness.GuardInput,
1032
1056
  abs = filepath.Clean(abs)
1033
1057
  resolved, err := resolveRootMutationTarget(abs)
1034
1058
  if err != nil {
1035
- return denyWright(h, stdout, stderr, a1DenyReason, drvreason.HookA1Denied), true
1059
+ return true, true, target
1036
1060
  }
1037
1061
  insideProject := pathWithin(resolved, projectDir)
1038
1062
  if (insideProject && !underDevrites(resolved, root)) ||
1039
1063
  (!insideProject && !absoluteTarget) {
1040
- return denyWright(h, stdout, stderr, a1DenyReason, drvreason.HookA1Denied), true
1064
+ return true, true, target
1041
1065
  }
1042
1066
  }
1043
1067
  if suspicious {
1044
- return denyWright(h, stdout, stderr, a1DenyReason, drvreason.HookA1Denied), true
1068
+ return true, true, in.ToolName
1045
1069
  }
1046
1070
  if len(targets) > 0 {
1047
- return exitOK, true
1071
+ return true, false, strings.Join(targets, ",")
1072
+ }
1073
+ if isShellTool(in.ToolName) && safeReadonlyShellCommand(in.Command) {
1074
+ return true, false, ""
1048
1075
  }
1049
- return exitOK, false
1076
+ return false, false, ""
1050
1077
  }
1051
1078
 
1052
1079
  func resolveRootMutationTarget(filename string) (string, error) {
@@ -1074,7 +1101,8 @@ func rootMutationTargets(data []byte, in harness.GuardInput) ([]string, bool) {
1074
1101
  if len(targets) > 0 {
1075
1102
  return targets, false
1076
1103
  }
1077
- return nil, opaque && (reviewerMutateRe.MatchString(in.Command) || opaqueInterpreterRe.MatchString(in.Command))
1104
+ _, completeHeredoc := stripShellHeredocBodies(in.Command)
1105
+ return nil, opaque && (!completeHeredoc || reviewerMutateRe.MatchString(in.Command) || opaqueInterpreterRe.MatchString(in.Command))
1078
1106
  default:
1079
1107
  return nil, false
1080
1108
  }
@@ -214,6 +214,7 @@ func (h Harness) ParseGuardInput(r io.Reader) GuardInput {
214
214
  ToolName string `json:"tool_name"`
215
215
  ToolInput struct {
216
216
  Command string `json:"command"`
217
+ Cmd string `json:"cmd"`
217
218
  FilePath string `json:"file_path"`
218
219
  Path string `json:"path"`
219
220
  } `json:"tool_input"`
@@ -237,7 +238,7 @@ func (h Harness) ParseGuardInput(r io.Reader) GuardInput {
237
238
  }
238
239
  return GuardInput{
239
240
  ToolName: raw.ToolName,
240
- Command: raw.ToolInput.Command,
241
+ Command: firstNonEmpty(raw.ToolInput.Command, raw.ToolInput.Cmd),
241
242
  FilePath: firstNonEmpty(raw.ToolInput.FilePath, raw.ToolInput.Path),
242
243
  SessionID: raw.SessionID,
243
244
  AgentType: raw.AgentType,
@@ -113,6 +113,10 @@ func TestHarnessParsersFailOpenAndFallbacks(t *testing.T) {
113
113
  if guard.ToolName != "Edit" || guard.FilePath != "src/app.go" || guard.AgentID != "a1" || guard.ToolResponse != `{"ok":true}` {
114
114
  t.Fatalf("ParseGuardInput = %#v", guard)
115
115
  }
116
+ guard = Codex.ParseGuardInput(strings.NewReader(`{"tool_name":"functions.exec_command","tool_input":{"cmd":"sha256sum /tmp/agent-packet.yaml"}}`))
117
+ if guard.ToolName != "functions.exec_command" || guard.Command != "sha256sum /tmp/agent-packet.yaml" {
118
+ t.Fatalf("ParseGuardInput Codex cmd = %#v", guard)
119
+ }
116
120
 
117
121
  if got := Codex.SubagentAgentType(strings.NewReader(`{"agentType":"executor"}`)); got != "executor" {
118
122
  t.Fatalf("SubagentAgentType fallback = %q, want executor", got)
@@ -23,6 +23,7 @@ const (
23
23
  reconcileCheckedName = ".reconcile-checked"
24
24
  reconcileDevritesName = ".reconcile-devrites"
25
25
  reconcileObjectsName = ".reconcile-objects"
26
+ reconcileWrightStateName = ".reconcile-wright-devrites"
26
27
  defaultWrightAllowlistName = ".wright-allowlist"
27
28
  wrightAllowlistFileEnv = "DEVRITES_WRIGHT_ALLOWLIST_FILE"
28
29
  reconcileDevritesPathPrefix = ".devrites/"
@@ -30,9 +31,11 @@ const (
30
31
 
31
32
  // Reconcile enforces the source-write boundary around one dispatched
32
33
  // slice-wright. The first `snapshot` captures the dirty worktree, private object
33
- // database, exact orchestrator-authored allowlist, and .devrites state. After a
34
- // clean check, another snapshot re-arms only the dispatch state for a retry while
35
- // retaining the original source baseline.
34
+ // database, exact orchestrator-authored allowlist, and .devrites state. A
35
+ // confirmed wright start captures the current canonical state separately, so
36
+ // retained-window recovery records are not attributed to the writer. After a
37
+ // clean check, another snapshot re-arms only the dispatch state for a retry
38
+ // while retaining the original source baseline.
36
39
  // `check` compares the captured state with the current state and retains the
37
40
  // immutable baseline for the later test-integrity gate.
38
41
  // `close` explicitly ends the window and removes its private artifacts.
@@ -62,10 +65,11 @@ func Reconcile(root string, args []string, stdout, stderr io.Writer) int {
62
65
  devritesSnapshot := filepath.Join(d, reconcileDevritesName)
63
66
  objects := filepath.Join(d, reconcileObjectsName)
64
67
  checked := filepath.Join(d, reconcileCheckedName)
68
+ wrightState := filepath.Join(d, reconcileWrightStateName)
65
69
 
66
70
  closeWindow := func() error {
67
71
  var failures []string
68
- for _, privateFile := range []string{base, capturedAllowlist, devritesSnapshot, checked} {
72
+ for _, privateFile := range []string{base, capturedAllowlist, devritesSnapshot, checked, wrightState} {
69
73
  if err := os.Remove(privateFile); err != nil && !os.IsNotExist(err) {
70
74
  failures = append(failures, fmt.Sprintf("%s: %v", filepath.Base(privateFile), err))
71
75
  }
@@ -131,7 +135,7 @@ func Reconcile(root string, args []string, stdout, stderr io.Writer) int {
131
135
  fmt.Fprintf(stderr, "reconcile: cannot refresh wright allowlist: %v\n", err)
132
136
  return 6
133
137
  }
134
- state, err := captureReconcileDevritesState(root, devritesSnapshot, activeObjects, checked)
138
+ state, err := captureReconcileDevritesState(root, devritesSnapshot, activeObjects, checked, wrightState)
135
139
  if err != nil {
136
140
  fmt.Fprintf(stderr, "reconcile: cannot refresh .devrites snapshot: %v\n", err)
137
141
  return 6
@@ -140,9 +144,11 @@ func Reconcile(root string, args []string, stdout, stderr io.Writer) int {
140
144
  fmt.Fprintf(stderr, "reconcile: cannot write refreshed .devrites snapshot: %v\n", err)
141
145
  return 6
142
146
  }
143
- if err := os.Remove(checked); err != nil {
144
- fmt.Fprintf(stderr, "reconcile: cannot arm refreshed slice window: %v\n", err)
145
- return 6
147
+ for _, privateFile := range []string{checked, wrightState} {
148
+ if err := os.Remove(privateFile); err != nil && !os.IsNotExist(err) {
149
+ fmt.Fprintf(stderr, "reconcile: cannot arm refreshed slice window: %v\n", err)
150
+ return 6
151
+ }
146
152
  }
147
153
  fmt.Fprintf(stdout, "reconcile: dispatch snapshot refreshed for %s; original slice baseline retained.\n", slug)
148
154
  return 0
@@ -180,7 +186,7 @@ func Reconcile(root string, args []string, stdout, stderr io.Writer) int {
180
186
  fmt.Fprintf(stderr, "reconcile: cannot capture wright allowlist: %v\n", err)
181
187
  return 6
182
188
  }
183
- state, err := captureReconcileDevritesState(root, devritesSnapshot, objects, checked)
189
+ state, err := captureReconcileDevritesState(root, devritesSnapshot, objects, checked, wrightState)
184
190
  if err != nil {
185
191
  _ = closeWindow()
186
192
  fmt.Fprintf(stderr, "reconcile: cannot snapshot .devrites: %v\n", err)
@@ -210,13 +216,20 @@ func Reconcile(root string, args []string, stdout, stderr io.Writer) int {
210
216
  fmt.Fprintf(stderr, "reconcile: invalid captured allowlist: %v\n", err)
211
217
  return 6
212
218
  }
213
- beforeDevrites, err := readDevritesState(devritesSnapshot)
219
+ beforeDevritesPath := devritesSnapshot
220
+ if _, err := os.Lstat(wrightState); err == nil {
221
+ beforeDevritesPath = wrightState
222
+ } else if !os.IsNotExist(err) {
223
+ fmt.Fprintf(stderr, "reconcile: invalid wright-start .devrites snapshot: %v\n", err)
224
+ return 6
225
+ }
226
+ beforeDevrites, err := readDevritesState(beforeDevritesPath)
214
227
  if err != nil {
215
228
  fmt.Fprintf(stderr, "reconcile: invalid .devrites snapshot: %v\n", err)
216
229
  return 6
217
230
  }
218
231
  beforeDevrites = withoutRootOwnedOperationalState(beforeDevrites)
219
- afterDevrites, err := captureReconcileDevritesState(root, devritesSnapshot, objects, checked)
232
+ afterDevrites, err := captureReconcileDevritesState(root, devritesSnapshot, objects, checked, wrightState)
220
233
  if err != nil {
221
234
  fmt.Fprintf(stderr, "reconcile: cannot compare .devrites: %v\n", err)
222
235
  return 6
@@ -274,6 +287,50 @@ func Reconcile(root string, args []string, stdout, stderr io.Writer) int {
274
287
  }
275
288
  }
276
289
 
290
+ // CaptureReconcileWrightBoundary records canonical state at the confirmed
291
+ // wright start. The original source baseline remains untouched; reconciliation
292
+ // can therefore distinguish root recovery records that predate the writer from
293
+ // canonical mutations made while the writer is active.
294
+ func CaptureReconcileWrightBoundary(root, slug string) error {
295
+ d := featureDir(root, slug)
296
+ if slug == "" || !isDir(d) {
297
+ return fmt.Errorf("no active workspace")
298
+ }
299
+ base := filepath.Join(d, reconcileBaseName)
300
+ capturedAllowlist := filepath.Join(d, reconcileAllowlistName)
301
+ devritesSnapshot := filepath.Join(d, reconcileDevritesName)
302
+ objects := filepath.Join(d, reconcileObjectsName)
303
+ checked := filepath.Join(d, reconcileCheckedName)
304
+ wrightState := filepath.Join(d, reconcileWrightStateName)
305
+
306
+ for _, filename := range []string{base, capturedAllowlist} {
307
+ info, err := os.Lstat(filename)
308
+ if err != nil || !info.Mode().IsRegular() {
309
+ if err == nil {
310
+ err = fmt.Errorf("not a regular file")
311
+ }
312
+ return fmt.Errorf("invalid reconcile window %s: %w", filepath.Base(filename), err)
313
+ }
314
+ }
315
+ if !isDir(objects) {
316
+ return fmt.Errorf("invalid reconcile window %s: not a directory", filepath.Base(objects))
317
+ }
318
+ if _, err := readDevritesState(devritesSnapshot); err != nil {
319
+ return fmt.Errorf("invalid reconcile window %s: %w", filepath.Base(devritesSnapshot), err)
320
+ }
321
+ state, err := captureReconcileDevritesState(
322
+ root,
323
+ devritesSnapshot,
324
+ objects,
325
+ checked,
326
+ wrightState,
327
+ )
328
+ if err != nil {
329
+ return err
330
+ }
331
+ return writeDevritesState(wrightState, state)
332
+ }
333
+
277
334
  func wrightAllowlistPath(gitRoot, workspace string) (string, error) {
278
335
  configured := os.Getenv(wrightAllowlistFileEnv)
279
336
  var filename string
@@ -361,6 +361,49 @@ func TestReconcileRejectsDevritesMutation(t *testing.T) {
361
361
  }
362
362
  }
363
363
 
364
+ func TestReconcileUsesWrightStartAsCanonicalStateBoundary(t *testing.T) {
365
+ gitRoot := newGitRepo(t)
366
+ root := workspace(t, "feat")
367
+ dir := featureDir(root, "feat")
368
+ writeWrightAllowlist(t, root, "feat", "seed.go")
369
+ if code, out := runReconcile(t, root, "snapshot", "feat"); code != 0 {
370
+ t.Fatalf("snapshot = %d, want 0\n%s", code, out)
371
+ }
372
+
373
+ // Root-owned canonical records may legitimately change while a retained
374
+ // source baseline survives technical recovery. The next wright start is the
375
+ // boundary that separates those changes from the writer's delta.
376
+ for _, name := range []string{
377
+ "action.log",
378
+ "browser-evidence.md",
379
+ "decisions.md",
380
+ "evidence.md",
381
+ "footprint.log",
382
+ "state.md",
383
+ "touched-files.md",
384
+ } {
385
+ writeFile(t, filepath.Join(dir, name), "Root-owned recovery record\n")
386
+ }
387
+ if err := CaptureReconcileWrightBoundary(root, "feat"); err != nil {
388
+ t.Fatalf("capture wright boundary: %v", err)
389
+ }
390
+ writeFile(t, filepath.Join(gitRoot, "seed.go"), "package main\n\nfunc repaired() {}\n")
391
+
392
+ if code, out := runReconcile(t, root, "check", "feat"); code != 0 {
393
+ t.Fatalf("check = %d, want 0\n%s", code, out)
394
+ }
395
+
396
+ // A canonical mutation after the wright starts is still a violation.
397
+ writeFile(t, filepath.Join(dir, "state.md"), "Phase: prove\n")
398
+ code, out := runReconcile(t, root, "check", "feat")
399
+ if code != 5 {
400
+ t.Fatalf("check after writer-time canonical mutation = %d, want 5\n%s", code, out)
401
+ }
402
+ if !strings.Contains(out, ".devrites/work/feat/state.md") {
403
+ t.Fatalf("writer-time canonical mutation missing from output:\n%s", out)
404
+ }
405
+ }
406
+
364
407
  func TestReconcileAllowsRootOwnedOperationalFiles(t *testing.T) {
365
408
  newGitRepo(t)
366
409
  root := workspace(t, "feat")
@@ -613,6 +656,7 @@ func TestReconcileRefreshPreservesOriginalBaselineAcrossRetry(t *testing.T) {
613
656
  // between attempts. Refresh must re-arm those boundaries without replacing
614
657
  // the original pre-slice source baseline.
615
658
  writeFile(t, filepath.Join(featureDir(root, "feat"), "recovery-attempts.json"), "{}\n")
659
+ writeFile(t, filepath.Join(featureDir(root, "feat"), reconcileWrightStateName), "stale\n")
616
660
  writeWrightAllowlist(t, root, "feat", "seed.go", "retry.go")
617
661
  if code, out := runReconcile(t, root, "snapshot", "feat"); code != 0 {
618
662
  t.Fatalf("refresh snapshot = %d, want 0\n%s", code, out)
@@ -626,6 +670,9 @@ func TestReconcileRefreshPreservesOriginalBaselineAcrossRetry(t *testing.T) {
626
670
  if !bytes.Equal(originalBase, refreshedBase) {
627
671
  t.Fatalf("refresh replaced original baseline: before=%q after=%q", originalBase, refreshedBase)
628
672
  }
673
+ if isFile(filepath.Join(featureDir(root, "feat"), reconcileWrightStateName)) {
674
+ t.Fatal("refresh retained the prior wright-start boundary")
675
+ }
629
676
 
630
677
  writeFile(t, filepath.Join(gitRoot, "retry.go"), "package main\n")
631
678
  if code, out := runReconcile(t, root, "check", "feat"); code != 0 {
@@ -655,6 +702,7 @@ func TestReconcileCloseClearsPrivateWindowState(t *testing.T) {
655
702
  newGitRepo(t)
656
703
  root := workspace(t, "feat")
657
704
  writeFile(t, filepath.Join(root, "work", "feat", reconcileBaseName), "stale\n")
705
+ writeFile(t, filepath.Join(root, "work", "feat", reconcileWrightStateName), "stale\n")
658
706
  if err := os.MkdirAll(filepath.Join(root, "work", "feat", reconcileObjectsName), 0o755); err != nil {
659
707
  t.Fatal(err)
660
708
  }
@@ -666,7 +714,9 @@ func TestReconcileCloseClearsPrivateWindowState(t *testing.T) {
666
714
  if !strings.Contains(out, "closed slice window") {
667
715
  t.Errorf("expected close message:\n%s", out)
668
716
  }
669
- if isFile(filepath.Join(featureDir(root, "feat"), reconcileBaseName)) || isDir(filepath.Join(featureDir(root, "feat"), reconcileObjectsName)) {
717
+ if isFile(filepath.Join(featureDir(root, "feat"), reconcileBaseName)) ||
718
+ isFile(filepath.Join(featureDir(root, "feat"), reconcileWrightStateName)) ||
719
+ isDir(filepath.Join(featureDir(root, "feat"), reconcileObjectsName)) {
670
720
  t.Fatal("close retained private baseline state")
671
721
  }
672
722
  }
@@ -583,9 +583,11 @@ func TestHookWrightScopeRequiresSpawnedCodexWorkerForSourceWrites(t *testing.T)
583
583
  runGuard(`{"tool_name":"spawn_agent","tool_input":{"agent_type":"worker"}}`, false)
584
584
  runGuard(`{"tool_name":"spawn_agent","tool_input":{"agent_type":"explorer"}}`, false)
585
585
  runGuard(`{"tool_name":"Write","tool_input":{"file_path":".devrites/features/auth-tokens/state.md"}}`, false)
586
+ scratch := t.TempDir()
587
+ packet := filepath.Join(scratch, "agent-packet.yaml")
586
588
  runGuard(hookPayload(t, map[string]any{
587
589
  "tool_name": "Write",
588
- "tool_input": map[string]any{"file_path": filepath.Join(t.TempDir(), "agent-packet.yaml")},
590
+ "tool_input": map[string]any{"file_path": packet},
589
591
  }), false)
590
592
  runGuard(`{"tool_name":"Write","tool_input":{"file_path":"../outside.txt"}}`, true)
591
593
  runGuard(`{"tool_name":"Write","tool_input":{"file_path":"src/app.go"}}`, true)
@@ -649,6 +651,59 @@ func TestHookA1GuardIgnoresLegacyInlineMarker(t *testing.T) {
649
651
  }
650
652
  }
651
653
 
654
+ func TestHookA1GuardAllowsExternalDispatchScratchOnly(t *testing.T) {
655
+ root := newWorkspace(t)
656
+ writeActive(t, root, "auth-tokens")
657
+ workspace := filepath.Join(root, "features", "auth-tokens")
658
+ if err := os.WriteFile(filepath.Join(workspace, ".reconcile-base"), []byte("snapshot\n"), 0o600); err != nil {
659
+ t.Fatal(err)
660
+ }
661
+ scratch := t.TempDir()
662
+ packet := filepath.Join(scratch, "agent-packet.yaml")
663
+ env := []string{"DEVRITES_A1_HOOK=enforce"}
664
+ patch := func(tool, path string, freeform bool) string {
665
+ body := "*** Begin Patch\n*** Add File: " + path + "\n+x\n*** End Patch\n"
666
+ if freeform {
667
+ return hookPayload(t, map[string]any{"tool_name": tool, "tool_input": body})
668
+ }
669
+ return hookPayload(t, map[string]any{"tool_name": tool, "tool_input": map[string]any{"patch": body}})
670
+ }
671
+ shell := func(command string) string {
672
+ return hookPayload(t, map[string]any{
673
+ "tool_name": "functions.exec_command",
674
+ "tool_input": map[string]any{"cmd": command},
675
+ })
676
+ }
677
+
678
+ runGuard := func(input string, deny bool) {
679
+ t.Helper()
680
+ out, errOut, code := runDevritesIO(t, root, input, env,
681
+ "hook", "a1-guard", "--harness=codex")
682
+ if code != 0 {
683
+ t.Fatalf("exit=%d out=%q stderr=%q", code, out, errOut)
684
+ }
685
+ if deny {
686
+ if decision, _ := parsePermissionDecision(t, out); decision != "deny" {
687
+ t.Fatalf("expected deny, got %q", out)
688
+ }
689
+ } else if strings.TrimSpace(out) != "" {
690
+ t.Fatalf("expected allow, got %q", out)
691
+ }
692
+ }
693
+
694
+ runGuard(patch("apply_patch", packet, false), false)
695
+ runGuard(patch("functions.apply_patch", packet, true), false)
696
+ runGuard(shell("cat <<'EOF' > "+packet+"\npacket_version: agent-packet/v1\nEOF"), false)
697
+ runGuard(shell("sha256sum "+packet), false)
698
+ runGuard(shell("cp "+packet+" "+filepath.Join(scratch, "agent-packet.copy.yaml")), false)
699
+ runGuard(patch("apply_patch", "src/app.go", false), true)
700
+ runGuard(patch("functions.apply_patch", "src/app.go", true), true)
701
+ runGuard(shell("cat <<'EOF' > src/app.go\npackage src\nEOF"), true)
702
+ runGuard(shell("cat <<'EOF' > "+packet+"\nunterminated"), true)
703
+ runGuard(shell("cat <<EOF > "+packet+"\n$(cp "+packet+" src/app.go)\nEOF"), true)
704
+ runGuard(shell("cp "+packet+" src/app.go"), true)
705
+ }
706
+
652
707
  func TestHookWrightScopeFailsClosedWhenWorkspaceResolutionFails(t *testing.T) {
653
708
  root := newWorkspace(t)
654
709
  if err := os.Remove(filepath.Join(root, "ACTIVE")); err != nil && !os.IsNotExist(err) {
@@ -871,6 +926,44 @@ func TestHookSubagentOrientSilentForNonDevritesAgent(t *testing.T) {
871
926
  }
872
927
  }
873
928
 
929
+ func TestHookSubagentOrientCapturesWrightCanonicalBoundary(t *testing.T) {
930
+ root := newWorkspace(t)
931
+ writeActive(t, root, "auth-tokens")
932
+ workspace := filepath.Join(root, "features", "auth-tokens")
933
+ if err := os.MkdirAll(filepath.Join(workspace, ".reconcile-objects"), 0o700); err != nil {
934
+ t.Fatal(err)
935
+ }
936
+ for name, body := range map[string]string{
937
+ ".reconcile-base": "base\n",
938
+ ".reconcile-allowlist": "",
939
+ ".reconcile-devrites": "[]\n",
940
+ "state.md": "Phase: build\n",
941
+ "browser-evidence.md": "Prior root evidence\n",
942
+ } {
943
+ if err := os.WriteFile(filepath.Join(workspace, name), []byte(body), 0o600); err != nil {
944
+ t.Fatal(err)
945
+ }
946
+ }
947
+
948
+ in := `{"agent_type":"devrites-slice-wright"}`
949
+ out, errOut, code := runDevritesIO(t, root, in, nil, "hook", "subagent-orient", "--harness=claude")
950
+ if code != 0 {
951
+ t.Fatalf("exit = %d, want 0 (stderr: %s)", code, errOut)
952
+ }
953
+ if strings.Contains(out, "Wright boundary unavailable") {
954
+ t.Fatalf("wright start was blocked: %s", out)
955
+ }
956
+ raw, err := os.ReadFile(filepath.Join(workspace, ".reconcile-wright-devrites"))
957
+ if err != nil {
958
+ t.Fatalf("read wright boundary: %v", err)
959
+ }
960
+ for _, path := range []string{"features/auth-tokens/state.md", "features/auth-tokens/browser-evidence.md"} {
961
+ if !strings.Contains(string(raw), path) {
962
+ t.Errorf("wright boundary omitted %s: %s", path, raw)
963
+ }
964
+ }
965
+ }
966
+
874
967
  func hookPayload(t *testing.T, value map[string]any) string {
875
968
  t.Helper()
876
969
  raw, err := json.Marshal(value)
@@ -1704,11 +1797,18 @@ func TestCodexAgentDispatchGatesReconcileCloseOnBoundWrightResult(t *testing.T)
1704
1797
  root := newWorkspace(t)
1705
1798
  writeActive(t, root, "auth-tokens")
1706
1799
  workspace := filepath.Join(root, "features", "auth-tokens")
1707
- for _, name := range []string{".reconcile-base", ".reconcile-allowlist", ".reconcile-devrites"} {
1708
- if err := os.WriteFile(filepath.Join(workspace, name), []byte(name+"\n"), 0o600); err != nil {
1800
+ for name, body := range map[string]string{
1801
+ ".reconcile-base": ".reconcile-base\n",
1802
+ ".reconcile-allowlist": ".reconcile-allowlist\n",
1803
+ ".reconcile-devrites": "[]\n",
1804
+ } {
1805
+ if err := os.WriteFile(filepath.Join(workspace, name), []byte(body), 0o600); err != nil {
1709
1806
  t.Fatal(err)
1710
1807
  }
1711
1808
  }
1809
+ if err := os.MkdirAll(filepath.Join(workspace, ".reconcile-objects"), 0o700); err != nil {
1810
+ t.Fatal(err)
1811
+ }
1712
1812
  sessionID, turnID := "session-wright", "turn-wright"
1713
1813
  writeCodexAgentContract(t, root, "devrites-slice-wright")
1714
1814
  writeCodexSkillContract(t, root, "rite-build", "devrites-slice-wright")
@@ -1753,7 +1853,7 @@ func TestCodexAgentDispatchGatesReconcileCloseOnBoundWrightResult(t *testing.T)
1753
1853
  "tool_name": "spawn_agent",
1754
1854
  "tool_use_id": "spawn-wright",
1755
1855
  "tool_input": map[string]any{
1756
- "agent_type": "worker",
1856
+ "agent_type": "devrites-slice-wright",
1757
1857
  "fork_turns": "none",
1758
1858
  "message": "Read .codex/agents/devrites-slice-wright.toml.",
1759
1859
  },
@@ -1763,14 +1863,14 @@ func TestCodexAgentDispatchGatesReconcileCloseOnBoundWrightResult(t *testing.T)
1763
1863
  "session_id": sessionID,
1764
1864
  "turn_id": "child-wright",
1765
1865
  "agent_id": "agent-wright",
1766
- "agent_type": "worker",
1866
+ "agent_type": "devrites-slice-wright",
1767
1867
  },
1768
1868
  {
1769
1869
  "hook_event_name": "SubagentStop",
1770
1870
  "session_id": sessionID,
1771
1871
  "turn_id": "child-wright",
1772
1872
  "agent_id": "agent-wright",
1773
- "agent_type": "worker",
1873
+ "agent_type": "devrites-slice-wright",
1774
1874
  "last_assistant_message": "agent-result/v1 complete",
1775
1875
  },
1776
1876
  {
@@ -1790,6 +1890,9 @@ func TestCodexAgentDispatchGatesReconcileCloseOnBoundWrightResult(t *testing.T)
1790
1890
  t.Fatalf("%s exit=%d stderr=%s", hook, code, stderr)
1791
1891
  }
1792
1892
  }
1893
+ if _, err := os.Stat(filepath.Join(workspace, ".reconcile-wright-devrites")); err != nil {
1894
+ t.Fatalf("wright start did not capture canonical boundary: %v", err)
1895
+ }
1793
1896
 
1794
1897
  if out, stderr, code := runDevritesIO(t, root, closeWindow, nil,
1795
1898
  "hook", "agent-dispatch", "--harness=codex"); code != 0 || strings.TrimSpace(out) != "" {
@@ -120,10 +120,12 @@ resume the same slice. Close it before abandoning the slice for a scope/plan tra
120
120
 
121
121
  ## Host dispatch
122
122
 
123
- Use named wright → enforced V1 generic worker from `standards/agents.md`. On Codex
124
- MultiAgent V2, dispatch the exact named `devrites-slice-wright` with a unique
125
- `task_name` and `fork_turns="none"`; its durable child rollout must prove the native
126
- wright role, wait, completion, and non-empty result from the current reconcile window.
123
+ Use named wright → enforced V1 generic worker from `standards/agents.md`. Codex V2
124
+ dispatches the exact named `devrites-slice-wright` with a unique `task_name` and
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
128
+ post-start canonical changes fail.
127
129
  Any generic worker still needs the exact allowlist or an isolated checkout;
128
130
  reconciliation alone is insufficient. If no safe fresh-agent rung is available, stop
129
131
  for HITL. On Codex, `reconcile check` and `reconcile close` require the completed
@@ -120,10 +120,12 @@ resume the same slice. Close it before abandoning the slice for a scope/plan tra
120
120
 
121
121
  ## Host dispatch
122
122
 
123
- Use named wright → enforced V1 generic worker from `standards/agents.md`. On Codex
124
- MultiAgent V2, dispatch the exact named `devrites-slice-wright` with a unique
125
- `task_name` and `fork_turns="none"`; its durable child rollout must prove the native
126
- wright role, wait, completion, and non-empty result from the current reconcile window.
123
+ Use named wright → enforced V1 generic worker from `standards/agents.md`. Codex V2
124
+ dispatches the exact named `devrites-slice-wright` with a unique `task_name` and
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
128
+ post-start canonical changes fail.
127
129
  Any generic worker still needs the exact allowlist or an isolated checkout;
128
130
  reconciliation alone is insufficient. If no safe fresh-agent rung is available, stop
129
131
  for HITL. On Codex, `reconcile check` and `reconcile close` require the completed
@@ -120,10 +120,12 @@ resume the same slice. Close it before abandoning the slice for a scope/plan tra
120
120
 
121
121
  ## Host dispatch
122
122
 
123
- Use named wright → enforced V1 generic worker from `standards/agents.md`. On Codex
124
- MultiAgent V2, dispatch the exact named `devrites-slice-wright` with a unique
125
- `task_name` and `fork_turns="none"`; its durable child rollout must prove the native
126
- wright role, wait, completion, and non-empty result from the current reconcile window.
123
+ Use named wright → enforced V1 generic worker from `standards/agents.md`. Codex V2
124
+ dispatches the exact named `devrites-slice-wright` with a unique `task_name` and
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
128
+ post-start canonical changes fail.
127
129
  Any generic worker still needs the exact allowlist or an isolated checkout;
128
130
  reconciliation alone is insufficient. If no safe fresh-agent rung is available, stop
129
131
  for HITL. On Codex, `reconcile check` and `reconcile close` require the completed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "devrites",
3
- "version": "3.2.9",
3
+ "version": "3.2.11",
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",