devrites 3.2.22 → 3.2.23

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,12 @@
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.23](https://github.com/ViktorsBaikers/DevRites/compare/v3.2.22...v3.2.23) (2026-07-28)
6
+
7
+ ### Fixed
8
+
9
+ * **devrites:** add retained-window abort recovery ([e47957c](https://github.com/ViktorsBaikers/DevRites/commit/e47957cd759f83e0166985b4b691d84068cc1d93))
10
+
5
11
  ## [3.2.22](https://github.com/ViktorsBaikers/DevRites/compare/v3.2.21...v3.2.22) (2026-07-28)
6
12
 
7
13
  ### 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.22`](https://github.com/ViktorsBaikers/DevRites/releases/tag/v3.2.22): see [`CHANGELOG.md`](CHANGELOG.md) for release notes.
28
+ **Status:** [`v3.2.23`](https://github.com/ViktorsBaikers/DevRites/releases/tag/v3.2.23): see [`CHANGELOG.md`](CHANGELOG.md) for release notes.
29
29
 
30
30
  ## Quick start
31
31
 
package/docs/cli.md CHANGED
@@ -19,7 +19,7 @@ devrites-engine readiness-digest coverage|engineering [slug] # canonical input d
19
19
  devrites-engine clarify-return enter|restore [slug] # durable later-phase clarify cursor
20
20
  devrites-engine recovery route <class> # typed owner/action; JSON recovery-route/v1
21
21
  devrites-engine recovery check|record|clear ... # durable three-failure budget; record/clear accept --class
22
- devrites-engine reconcile snapshot|check|close [slug] # retained writer baseline
22
+ devrites-engine reconcile snapshot|check|restore-check|abort|close [slug] # retained writer baseline and rejected-window recovery
23
23
  devrites-engine test-integrity [slug] # reject weakened tests against that baseline
24
24
  devrites-engine evidence-fresh [slug] # proof freshness gate (exit 0 fresh · 3 stale)
25
25
  devrites-engine check-acceptance <dir> # acceptance gate (exit 0 proven · 1 gap)
@@ -66,7 +66,7 @@ active workspace with the current semantic planning contract.
66
66
  | [`/rite-define`](../pack/.claude/skills/rite-define/SKILL.md) | define → plan | `[slug]` | Authors and approves architecture, plan, vertical `SLICE-###` tasks, and traceability, then leaves the workspace at the `plan` checkpoint for `/rite-vet`. | `spec.md` + `decision-coverage.md` (+ `strategy.md`) + references | `architecture.md`, `plan.md`, `tasks.md`, `traceability.md`, `state.md`, `decisions.md` |
67
67
  | [`/rite-vet`](../pack/.claude/skills/rite-vet/SKILL.md) | vet | `[slug] [--cross-model] [--full]` | **Before build: every feature.** Engineering review of the defined plan: scope challenge (reuse / minimum-diff / complexity smell) + architecture / plan code-quality / test-coverage design / performance, confidence-banded with a quote-the-source verification gate; failure-mode + parallelization map. Hardens `plan.md` / `tasks.md` in place; writes the build-readable `test-plan.md` and semantic, input-digest-bound `READY` verdict; acceptance-changing deltas route via the Spec Drift Guard. Runs on every plan: depth scales to stakes (light pass on simple plans, full on big/risky), never skipped; **always in `/rite-autocomplete`**. Reviewer: `devrites-plan-reviewer` (+ optional `--cross-model`). | `plan.md` + `tasks.md` + `spec.md` (+ `strategy.md`) | `eng-review.md`, `test-plan.md`, `plan.md`, `tasks.md`, `decisions.md`, `state.md` |
68
68
  | [`/rite-plan`](../pack/.claude/skills/rite-plan/SKILL.md) | repair → plan | `[mode]` | Reslice / repair / re-order / split / unblock an active plan and return to the `plan` checkpoint; `revise` is artifact-only and `/rite-vet` is the normal resume. | spec/plan/tasks/state/drift + diff | `plan.md`, `tasks.md`, `state.md`, `decisions.md` |
69
- | [`/rite-build`](../pack/.claude/skills/rite-build/SKILL.md) | build | `[slice]` | Orchestrate **exactly one** vertical slice through the sole wright. The root writes an exact `.wright-allowlist`, retains the original slice baseline through snapshot → reconcile check → test/package integrity → close, and refreshes only the dispatch boundary on bounded retries. Objective failures stay agent-owned; only product/scope/policy choices, irreversible risk, or human-only access/actions become questions. | workspace + diff + `.devrites/CHECKPOINT` | code + `.wright-allowlist`, `recovery-attempts.jsonl`, `state.md`, `evidence.md`, `traceability.md`, `touched-files.md` (+ local `WIP(<slug>)` commit in checkpoint mode) |
69
+ | [`/rite-build`](../pack/.claude/skills/rite-build/SKILL.md) | build | `[slice]` | Orchestrate **exactly one** vertical slice through the sole wright. The root writes an exact `.wright-allowlist`, retains the original slice baseline through snapshot → reconcile check → test/package integrity → close, and aborts rejected windows back to that baseline with a receipt. Objective failures stay agent-owned; only product/scope/policy choices, irreversible risk, or human-only access/actions become questions. | workspace + diff + `.devrites/CHECKPOINT` | code + `.wright-allowlist`, `recovery-attempts.jsonl`, `state.md`, `evidence.md`, `traceability.md`, `touched-files.md` (+ local `WIP(<slug>)` commit in checkpoint mode) |
70
70
  | [`/rite-converge`](../pack/.claude/skills/rite-converge/SKILL.md) | converge | `[slug]` | **Recovery.** Compare live code with clarified intent, append each unmet piece as a traceable `SLICE-###`, and invalidate the old vet verdict so changed work returns through `/rite-vet`. `tasks.md` stays byte-identical when already converged. | clarified spec + plan/tasks + principles + live code | `tasks.md` (appended), `traceability.md`, `state.md`, `eng-review.md` (invalidated), `decisions.md` |
71
71
  | [`/rite-upgrade`](../pack/.claude/skills/rite-upgrade/SKILL.md) | maintenance | `[slug]` | **Conditional recovery.** Bring an active unfinished workspace onto `devrites.readiness-artifacts.v2`. A fresh read-only planner identifies the smallest change; the rite preserves completed source, slices, decisions, and evidence, removes stale engine-proof recipes and machine-local wrappers from active plans, then reruns current readiness gates. Already-current workspaces that pass readiness, completed workspaces, and archives are no-ops. | active workspace + current readiness contract | active unfinished planning artifacts only |
72
72
  | [`/rite-prove`](../pack/.claude/skills/rite-prove/SKILL.md) | prove | `[scope]` | Tests + build + runtime + browser proof of the completed feature. | `traceability.md` + workspace + diff | `evidence.md`, `browser-evidence.md`, `traceability.md`, `state.md` |
@@ -74,6 +74,16 @@ Each dispatch follows the retained-baseline sequence:
74
74
  6. After proof and decision checks pass, `reconcile close` retires the private
75
75
  window; only then does the root write canonical records.
76
76
 
77
+ If the writer result is rejected before a clean check, the root runs the exact
78
+ standalone `reconcile abort` operation. It restores source to the original
79
+ pre-dispatch baseline while preserving pre-snapshot user work and canonical
80
+ `.devrites` evidence, verifies a source-manifest digest, closes the retained
81
+ window, and records a content-addressed `.reconcile-abort-<sha256>.json`
82
+ receipt. The receipt binds the original tree, captured allowlist digest,
83
+ restored path set, and closed-window result. Project-specific frozen hashes are
84
+ verified separately against the restored worktree. `restore-check` remains the
85
+ narrow rollback for source drift introduced after a clean check.
86
+
77
87
  An ordinary `reconcile check` may revalidate an existing retained window
78
88
  without a fresh current-turn wright receipt; it does not retire or widen that
79
89
  window. `reconcile close` still requires the confirmed, awaited wright result
package/docs/skills.md CHANGED
@@ -145,7 +145,7 @@ blocks obvious multi-command `Next:` wording.
145
145
 
146
146
  | Skill | What It Does | Use When |
147
147
  |---|---|---|
148
- | [`rite-build`](../pack/.claude/skills/rite-build/SKILL.md) | Orchestrates **exactly one** vertical slice through the sole wright. The root writes an exact `.wright-allowlist`, retains the original baseline through snapshot → reconcile check → test/package integrity → close, and refreshes only the dispatch boundary on retries. Objective failures use bounded agent-owned recovery; only genuine product/risk/access decisions become human gates. A `Forge: yes` slice still isolates candidates and lands one judged winner. | A plan exists and fresh digest-bound `CLEAR` and `READY` gates plus `test-plan.md` pass. |
148
+ | [`rite-build`](../pack/.claude/skills/rite-build/SKILL.md) | Orchestrates **exactly one** vertical slice through the sole wright. The root writes an exact `.wright-allowlist`, retains the original baseline through snapshot → reconcile check → test/package integrity → close, and aborts rejected windows back to that baseline with a receipt. Objective failures use bounded agent-owned recovery; only genuine product/risk/access decisions become human gates. A `Forge: yes` slice still isolates candidates and lands one judged winner. | A plan exists and fresh digest-bound `CLEAR` and `READY` gates plus `test-plan.md` pass. |
149
149
  | [`devrites-source-driven`](../pack/.claude/skills/devrites-source-driven/SKILL.md) | Consult official docs / source before relying on framework behavior; record the source. | API, config, or framework behavior is assumed rather than known. |
150
150
  | [`devrites-api-interface`](../pack/.claude/skills/devrites-api-interface/SKILL.md) | Design stable API / interface contracts: REST/GraphQL, module boundaries, type contracts, FE/BE split. | A slice crosses a boundary or defines a public interface. |
151
151
  | [`devrites-debug-recovery`](../pack/.claude/skills/devrites-debug-recovery/SKILL.md) | Reproduce → ranked hypotheses → instrument → fix in scope → regression-test. A fingerprinted ledger preserves the three-failure budget across agents and sessions. | Tests, builds, or runtime checks fail. |
@@ -262,10 +262,11 @@ func hookStatusline(stdin io.Reader, stdout, stderr io.Writer) int {
262
262
  // insensitive, and FAIL/PASS scans are line oriented.
263
263
  // pack-scan-ignore: these are the hook's own red/green heuristics, not a payload.
264
264
  var (
265
- redTestCmdRe = regexp.MustCompile(`(?i)(npm|pnpm|yarn|bun)([[:space:]]+run)?[[:space:]]+(test|build|lint|typecheck|check)|jest|vitest|pytest|go[[:space:]]+test|cargo[[:space:]]+(test|build|clippy)|\bmvn\b|gradle|eslint|ruff|mypy|\btsc\b|\bmake[[:space:]]+(test|build|check)`)
266
- redFailRe = regexp.MustCompile(`(?im)(^|[^A-Za-z])FAIL([^A-Za-z]|$)|[1-9][0-9]*[[:space:]]+(failed|failing|failures?|errors?)|not ok|panic:|error TS[0-9]|Traceback|AssertionError|✗|✘|BUILD FAILED|exit code [1-9]`)
267
- redPassRe = regexp.MustCompile(`(?im)PASS(ED)?|0[[:space:]]+(failed|failing|errors?)|all tests passed|BUILD SUCC(ESS|EEDED)|succeeded|no (errors|problems|issues)|(^|[^a-z])ok([^a-z]|$)|✓`)
268
- reconcileRestoreCheckRe = regexp.MustCompile(`^\s*(?:rtk\s+)?(?:[A-Za-z0-9_./-]+/)?devrites-engine\s+reconcile\s+restore-check(?:\s+[A-Za-z0-9._-]+)?\s*$`)
265
+ redTestCmdRe = regexp.MustCompile(`(?i)(npm|pnpm|yarn|bun)([[:space:]]+run)?[[:space:]]+(test|build|lint|typecheck|check)|jest|vitest|pytest|go[[:space:]]+test|cargo[[:space:]]+(test|build|clippy)|\bmvn\b|gradle|eslint|ruff|mypy|\btsc\b|\bmake[[:space:]]+(test|build|check)`)
266
+ redFailRe = regexp.MustCompile(`(?im)(^|[^A-Za-z])FAIL([^A-Za-z]|$)|[1-9][0-9]*[[:space:]]+(failed|failing|failures?|errors?)|not ok|panic:|error TS[0-9]|Traceback|AssertionError|✗|✘|BUILD FAILED|exit code [1-9]`)
267
+ redPassRe = regexp.MustCompile(`(?im)PASS(ED)?|0[[:space:]]+(failed|failing|errors?)|all tests passed|BUILD SUCC(ESS|EEDED)|succeeded|no (errors|problems|issues)|(^|[^a-z])ok([^a-z]|$)|✓`)
268
+ reconcileRootRecoveryRe = regexp.MustCompile(`^\s*(?:rtk\s+)?(?:[A-Za-z0-9_./-]+/)?devrites-engine\s+reconcile\s+(?:restore-check|abort)(?:\s+[A-Za-z0-9._-]+)?\s*$`)
269
+ reconcileRootRecoveryMentionRe = regexp.MustCompile(`\breconcile\s+(?:restore-check|abort)\b`)
269
270
  )
270
271
 
271
272
  const redReasonFmt = "DevRites: tests/build are RED (%s). Fix to green or record the failure + next step in state.md before stopping: the Stop gate blocks an end-of-turn while .red is set."
@@ -1061,8 +1062,8 @@ func classifyRootBuildWindowOperation(root string, data []byte, in harness.Guard
1061
1062
  if isOpaqueExecutionTool(in.ToolName) {
1062
1063
  return true, true, in.ToolName
1063
1064
  }
1064
- if isShellTool(in.ToolName) && strings.Contains(in.Command, "reconcile restore-check") {
1065
- return true, !reconcileRestoreCheckRe.MatchString(in.Command), in.Command
1065
+ if isShellTool(in.ToolName) && reconcileRootRecoveryMentionRe.MatchString(in.Command) {
1066
+ return true, !reconcileRootRecoveryRe.MatchString(in.Command), in.Command
1066
1067
  }
1067
1068
 
1068
1069
  projectDir := filepath.Dir(root)
@@ -9,6 +9,7 @@ import (
9
9
  "os"
10
10
  "path"
11
11
  "path/filepath"
12
+ "regexp"
12
13
  "sort"
13
14
  "strings"
14
15
 
@@ -27,8 +28,26 @@ const (
27
28
  defaultWrightAllowlistName = ".wright-allowlist"
28
29
  wrightAllowlistFileEnv = "DEVRITES_WRIGHT_ALLOWLIST_FILE"
29
30
  reconcileDevritesPathPrefix = ".devrites/"
31
+ reconcileAbortSchema = "devrites.reconcile-abort-receipt.v1"
30
32
  )
31
33
 
34
+ var (
35
+ reconcileAbortReceiptName = regexp.MustCompile(`^\.reconcile-abort-([0-9a-f]{64})\.json$`)
36
+ reconcileObjectID = regexp.MustCompile(`^(?:[0-9a-f]{40}|[0-9a-f]{64})$`)
37
+ reconcileSHA256 = regexp.MustCompile(`^[0-9a-f]{64}$`)
38
+ )
39
+
40
+ type reconcileAbortReceipt struct {
41
+ SchemaVersion string `json:"schemaVersion"`
42
+ Slug string `json:"slug"`
43
+ BaselineTree string `json:"baselineTree"`
44
+ SourceManifestSHA256 string `json:"sourceManifestSHA256"`
45
+ SourceEntryCount int `json:"sourceEntryCount"`
46
+ CapturedAllowlistSHA256 string `json:"capturedAllowlistSHA256"`
47
+ RestoredPaths []string `json:"restoredPaths"`
48
+ WindowClosed bool `json:"windowClosed"`
49
+ }
50
+
32
51
  // Reconcile enforces the source-write boundary around one dispatched
33
52
  // slice-wright. The first `snapshot` captures the dirty worktree, private object
34
53
  // database, exact orchestrator-authored allowlist, and .devrites state. A
@@ -39,7 +58,9 @@ const (
39
58
  // `check` compares the captured state with the current state and retains the
40
59
  // immutable baseline for the later test-integrity gate.
41
60
  // `restore-check` rolls back only source drift introduced after the last clean
42
- // check. `close` revalidates and then removes the private window artifacts.
61
+ // check. `abort` restores the original source baseline, records a
62
+ // content-addressed receipt, and closes a rejected writer window. `close`
63
+ // revalidates and then removes the private window artifacts.
43
64
  //
44
65
  // 0 clean check, snapshot/close completed, or skipped (not a git repo)
45
66
  // 5 VIOLATION: a path changed outside the orchestrator allowlist
@@ -220,6 +241,103 @@ func Reconcile(root string, args []string, stdout, stderr io.Writer) int {
220
241
  fmt.Fprintf(stdout, "reconcile: snapshot captured for %s.\n", slug)
221
242
  return 0
222
243
 
244
+ case "abort":
245
+ baseTree, env, activeObjects, active, err := loadReconcileBaseline(gitRoot, d)
246
+ if err != nil {
247
+ fmt.Fprintf(stderr, "reconcile: invalid snapshot: %v\n", err)
248
+ return 6
249
+ }
250
+ if !active {
251
+ fmt.Fprintln(stderr, `reconcile: no active snapshot to abort.`)
252
+ return 6
253
+ }
254
+ allowed, err := readWrightAllowlist(gitRoot, capturedAllowlist)
255
+ if err != nil {
256
+ fmt.Fprintf(stderr, "reconcile: invalid captured allowlist: %v\n", err)
257
+ return 6
258
+ }
259
+ allowlistBytes := renderWrightAllowlist(allowed)
260
+ allowlistDigest := sha256.Sum256(allowlistBytes)
261
+
262
+ nowTree, err := worktreeTree(gitRoot, activeObjects, root)
263
+ if err != nil {
264
+ fmt.Fprintf(stderr, "reconcile: cannot capture worktree for abort: %v\n", err)
265
+ return 6
266
+ }
267
+ changed, err := changedTreePaths(gitRoot, env, baseTree, nowTree)
268
+ if err != nil {
269
+ fmt.Fprintf(stderr, "reconcile: cannot compare abort baseline: %v\n", err)
270
+ return 6
271
+ }
272
+ changed = sourceTreePaths(changed)
273
+ if err := restoreTreePaths(gitRoot, env, baseTree, changed); err != nil {
274
+ fmt.Fprintf(stderr, "reconcile: cannot restore rejected writer source delta: %v\n", err)
275
+ return 6
276
+ }
277
+
278
+ restoredTree, err := worktreeTree(gitRoot, activeObjects, root)
279
+ if err != nil {
280
+ fmt.Fprintf(stderr, "reconcile: cannot verify aborted worktree: %v\n", err)
281
+ return 6
282
+ }
283
+ remaining, err := changedTreePaths(gitRoot, env, baseTree, restoredTree)
284
+ if err != nil {
285
+ fmt.Fprintf(stderr, "reconcile: cannot verify abort baseline: %v\n", err)
286
+ return 6
287
+ }
288
+ if remaining = sourceTreePaths(remaining); len(remaining) != 0 {
289
+ fmt.Fprintln(stderr, "reconcile: rejected writer source delta was not fully restored; retained window left open.")
290
+ return 6
291
+ }
292
+ manifestDigest, entryCount, err := sourceTreeManifest(gitRoot, env, baseTree)
293
+ if err != nil {
294
+ fmt.Fprintf(stderr, "reconcile: cannot fingerprint restored source baseline: %v\n", err)
295
+ return 6
296
+ }
297
+ restoredDigest, restoredEntryCount, err := sourceTreeManifest(gitRoot, env, restoredTree)
298
+ if err != nil {
299
+ fmt.Fprintf(stderr, "reconcile: cannot fingerprint restored worktree: %v\n", err)
300
+ return 6
301
+ }
302
+ if restoredDigest != manifestDigest || restoredEntryCount != entryCount {
303
+ fmt.Fprintln(stderr, "reconcile: restored source manifest does not match the original baseline; retained window left open.")
304
+ return 6
305
+ }
306
+ receipt := reconcileAbortReceipt{
307
+ SchemaVersion: reconcileAbortSchema,
308
+ Slug: slug,
309
+ BaselineTree: baseTree,
310
+ SourceManifestSHA256: manifestDigest,
311
+ SourceEntryCount: entryCount,
312
+ CapturedAllowlistSHA256: hex.EncodeToString(allowlistDigest[:]),
313
+ RestoredPaths: changed,
314
+ WindowClosed: true,
315
+ }
316
+ receiptData, receiptName, err := renderReconcileAbortReceipt(receipt)
317
+ if err != nil {
318
+ fmt.Fprintf(stderr, "reconcile: cannot render abort receipt: %v\n", err)
319
+ return 6
320
+ }
321
+ receiptPath := filepath.Join(d, receiptName)
322
+ pendingReceipt, err := stageContentAddressedReceipt(receiptPath, receiptData)
323
+ if err != nil {
324
+ fmt.Fprintf(stderr, "reconcile: cannot stage abort receipt: %v\n", err)
325
+ return 6
326
+ }
327
+ if pendingReceipt != "" {
328
+ defer func() { _ = os.Remove(pendingReceipt) }()
329
+ }
330
+ if err := closeWindow(); err != nil {
331
+ fmt.Fprintf(stderr, "reconcile: source baseline restored, but cannot close rejected slice window: %v\n", err)
332
+ return 6
333
+ }
334
+ if err := commitContentAddressedReceipt(receiptPath, pendingReceipt, receiptData); err != nil {
335
+ fmt.Fprintf(stderr, "reconcile: source baseline restored and window closed, but cannot persist abort receipt: %v\n", err)
336
+ return 6
337
+ }
338
+ fmt.Fprintf(stdout, "reconcile: aborted rejected slice window for %s; restored %d source path(s); receipt %s.\n", slug, len(changed), receiptName)
339
+ return 0
340
+
223
341
  case "restore-check":
224
342
  _, env, activeObjects, active, err := loadReconcileBaseline(gitRoot, d)
225
343
  if err != nil {
@@ -374,7 +492,7 @@ func Reconcile(root string, args []string, stdout, stderr io.Writer) int {
374
492
  return 0
375
493
 
376
494
  default:
377
- fmt.Fprintln(stderr, "reconcile: usage: devrites-engine reconcile <snapshot|check|restore-check|close> [slug]")
495
+ fmt.Fprintln(stderr, "reconcile: usage: devrites-engine reconcile <snapshot|check|restore-check|abort|close> [slug]")
378
496
  return 6
379
497
  }
380
498
  }
@@ -654,9 +772,45 @@ func reconcileRootOwnedOperationalFile(filename, rel string, entry os.DirEntry)
654
772
  return false, fmt.Errorf("validate root-owned recovery ledger %s: %w", rel, err)
655
773
  }
656
774
  }
775
+ if len(parts) == 3 && reconcileAbortReceiptName.MatchString(parts[2]) {
776
+ data, err := os.ReadFile(filename)
777
+ if err != nil {
778
+ return false, fmt.Errorf("read reconcile abort receipt %s: %w", rel, err)
779
+ }
780
+ match := reconcileAbortReceiptName.FindStringSubmatch(parts[2])
781
+ digest := sha256.Sum256(data)
782
+ if hex.EncodeToString(digest[:]) != match[1] {
783
+ return false, fmt.Errorf("validate reconcile abort receipt %s: content digest does not match filename", rel)
784
+ }
785
+ var receipt reconcileAbortReceipt
786
+ if err := json.Unmarshal(data, &receipt); err != nil {
787
+ return false, fmt.Errorf("validate reconcile abort receipt %s: %w", rel, err)
788
+ }
789
+ if receipt.SchemaVersion != reconcileAbortSchema || !receipt.WindowClosed ||
790
+ receipt.Slug != parts[1] || !reconcileObjectID.MatchString(receipt.BaselineTree) ||
791
+ !reconcileSHA256.MatchString(receipt.SourceManifestSHA256) ||
792
+ !reconcileSHA256.MatchString(receipt.CapturedAllowlistSHA256) ||
793
+ receipt.SourceEntryCount < 0 || !validReceiptPaths(receipt.RestoredPaths) {
794
+ return false, fmt.Errorf("validate reconcile abort receipt %s: invalid receipt fields", rel)
795
+ }
796
+ }
657
797
  return true, nil
658
798
  }
659
799
 
800
+ func validReceiptPaths(paths []string) bool {
801
+ previous := ""
802
+ for _, receiptPath := range paths {
803
+ if receiptPath == "" || receiptPath == "." || receiptPath == ".devrites" ||
804
+ strings.HasPrefix(receiptPath, reconcileDevritesPathPrefix) ||
805
+ path.Clean(receiptPath) != receiptPath ||
806
+ (previous != "" && receiptPath <= previous) {
807
+ return false
808
+ }
809
+ previous = receiptPath
810
+ }
811
+ return true
812
+ }
813
+
660
814
  func reconcileRootOwnedOperationalPath(rel string) bool {
661
815
  rel = filepath.ToSlash(filepath.Clean(rel))
662
816
  if rel == "timeline.jsonl" {
@@ -679,7 +833,7 @@ func reconcileRootOwnedOperationalPath(rel string) bool {
679
833
  ".wright-scope.log":
680
834
  return true
681
835
  default:
682
- return false
836
+ return reconcileAbortReceiptName.MatchString(parts[2])
683
837
  }
684
838
  }
685
839
 
@@ -796,20 +950,26 @@ func restoreTreeDelta(gitRoot string, env []string, checkedTree, currentTree str
796
950
  if len(changed) == 0 {
797
951
  return nil, nil
798
952
  }
953
+ if err := restoreTreePaths(gitRoot, env, checkedTree, changed); err != nil {
954
+ return nil, err
955
+ }
956
+ return changed, nil
957
+ }
799
958
 
959
+ func restoreTreePaths(gitRoot string, env []string, targetTree string, changed []string) error {
800
960
  materialized, err := os.MkdirTemp("", "devrites-reconcile-restore-*")
801
961
  if err != nil {
802
- return nil, fmt.Errorf("create restore directory: %w", err)
962
+ return fmt.Errorf("create restore directory: %w", err)
803
963
  }
804
964
  defer func() { _ = os.RemoveAll(materialized) }()
805
965
  index := filepath.Join(materialized, "index")
806
966
  restoreEnv := append(append([]string{}, env...), "GIT_INDEX_FILE="+index)
807
- if _, err := reconcileGitOutput(gitRoot, restoreEnv, "read-tree", checkedTree); err != nil {
808
- return nil, err
967
+ if _, err := reconcileGitOutput(gitRoot, restoreEnv, "read-tree", targetTree); err != nil {
968
+ return err
809
969
  }
810
970
  materializedTree := filepath.Join(materialized, "tree")
811
971
  if err := os.MkdirAll(materializedTree, 0o700); err != nil {
812
- return nil, fmt.Errorf("create restore tree: %w", err)
972
+ return fmt.Errorf("create restore tree: %w", err)
813
973
  }
814
974
  gitPrefix := filepath.ToSlash(materializedTree) + "/"
815
975
  if _, err := reconcileGitOutput(
@@ -818,56 +978,159 @@ func restoreTreeDelta(gitRoot string, env []string, checkedTree, currentTree str
818
978
  "-c", "core.autocrlf=false",
819
979
  "checkout-index", "--all", "--force", "--prefix="+gitPrefix,
820
980
  ); err != nil {
821
- return nil, err
981
+ return err
822
982
  }
823
983
 
824
984
  for _, changedPath := range changed {
825
985
  if changedPath == "." || changedPath == ".devrites" ||
826
986
  strings.HasPrefix(changedPath, reconcileDevritesPathPrefix) ||
827
987
  path.Clean(changedPath) != changedPath {
828
- return nil, fmt.Errorf("unsafe restore path %q", changedPath)
988
+ return fmt.Errorf("unsafe restore path %q", changedPath)
829
989
  }
830
990
  target := filepath.Join(gitRoot, filepath.FromSlash(changedPath))
831
991
  targetParent := filepath.Dir(target)
832
992
  if !safepath.WithinResolved(targetParent, gitRoot) {
833
- return nil, fmt.Errorf("restore parent escapes repository through a symlink: %s", changedPath)
993
+ return fmt.Errorf("restore parent escapes repository through a symlink: %s", changedPath)
834
994
  }
835
995
  source := filepath.Join(materializedTree, filepath.FromSlash(changedPath))
836
996
  sourceInfo, sourceErr := os.Lstat(source)
837
997
  if sourceErr != nil && !os.IsNotExist(sourceErr) {
838
- return nil, fmt.Errorf("inspect clean source %s: %w", changedPath, sourceErr)
998
+ return fmt.Errorf("inspect clean source %s: %w", changedPath, sourceErr)
839
999
  }
840
1000
  if err := os.RemoveAll(target); err != nil {
841
- return nil, fmt.Errorf("remove post-check path %s: %w", changedPath, err)
1001
+ return fmt.Errorf("remove source path %s: %w", changedPath, err)
842
1002
  }
843
1003
  if os.IsNotExist(sourceErr) {
844
1004
  continue
845
1005
  }
846
1006
  if err := os.MkdirAll(targetParent, 0o755); err != nil {
847
- return nil, fmt.Errorf("create restore parent for %s: %w", changedPath, err)
1007
+ return fmt.Errorf("create restore parent for %s: %w", changedPath, err)
848
1008
  }
849
1009
  switch {
850
1010
  case sourceInfo.Mode()&os.ModeSymlink != 0:
851
1011
  link, err := os.Readlink(source)
852
1012
  if err != nil {
853
- return nil, fmt.Errorf("read clean symlink %s: %w", changedPath, err)
1013
+ return fmt.Errorf("read clean symlink %s: %w", changedPath, err)
854
1014
  }
855
1015
  if err := os.Symlink(link, target); err != nil {
856
- return nil, fmt.Errorf("restore symlink %s: %w", changedPath, err)
1016
+ return fmt.Errorf("restore symlink %s: %w", changedPath, err)
857
1017
  }
858
1018
  case sourceInfo.Mode().IsRegular():
859
1019
  data, err := os.ReadFile(source)
860
1020
  if err != nil {
861
- return nil, fmt.Errorf("read clean file %s: %w", changedPath, err)
1021
+ return fmt.Errorf("read clean file %s: %w", changedPath, err)
862
1022
  }
863
1023
  if err := os.WriteFile(target, data, sourceInfo.Mode().Perm()); err != nil {
864
- return nil, fmt.Errorf("restore file %s: %w", changedPath, err)
1024
+ return fmt.Errorf("restore file %s: %w", changedPath, err)
865
1025
  }
866
1026
  default:
867
- return nil, fmt.Errorf("unsupported clean tree entry for %s: %s", changedPath, sourceInfo.Mode())
1027
+ return fmt.Errorf("unsupported clean tree entry for %s: %s", changedPath, sourceInfo.Mode())
868
1028
  }
869
1029
  }
870
- return changed, nil
1030
+ return nil
1031
+ }
1032
+
1033
+ func sourceTreePaths(paths []string) []string {
1034
+ source := make([]string, 0, len(paths))
1035
+ for _, changedPath := range paths {
1036
+ if changedPath == ".devrites" || strings.HasPrefix(changedPath, reconcileDevritesPathPrefix) {
1037
+ continue
1038
+ }
1039
+ source = append(source, changedPath)
1040
+ }
1041
+ sort.Strings(source)
1042
+ return source
1043
+ }
1044
+
1045
+ func sourceTreeManifest(gitRoot string, env []string, tree string) (string, int, error) {
1046
+ out, err := reconcileGitOutput(gitRoot, env, "ls-tree", "-r", "-z", "--full-tree", tree)
1047
+ if err != nil {
1048
+ return "", 0, err
1049
+ }
1050
+ hash := sha256.New()
1051
+ count := 0
1052
+ for _, entry := range strings.Split(string(out), "\x00") {
1053
+ if entry == "" {
1054
+ continue
1055
+ }
1056
+ tab := strings.IndexByte(entry, '\t')
1057
+ if tab < 0 {
1058
+ return "", 0, fmt.Errorf("invalid ls-tree entry")
1059
+ }
1060
+ entryPath := entry[tab+1:]
1061
+ if entryPath == ".devrites" || strings.HasPrefix(entryPath, reconcileDevritesPathPrefix) {
1062
+ continue
1063
+ }
1064
+ _, _ = hash.Write([]byte(entry))
1065
+ _, _ = hash.Write([]byte{0})
1066
+ count++
1067
+ }
1068
+ return hex.EncodeToString(hash.Sum(nil)), count, nil
1069
+ }
1070
+
1071
+ func renderReconcileAbortReceipt(receipt reconcileAbortReceipt) ([]byte, string, error) {
1072
+ data, err := json.MarshalIndent(receipt, "", " ")
1073
+ if err != nil {
1074
+ return nil, "", err
1075
+ }
1076
+ data = append(data, '\n')
1077
+ digest := sha256.Sum256(data)
1078
+ name := fmt.Sprintf(".reconcile-abort-%x.json", digest)
1079
+ return data, name, nil
1080
+ }
1081
+
1082
+ func stageContentAddressedReceipt(filename string, data []byte) (string, error) {
1083
+ existing, readErr := os.ReadFile(filename)
1084
+ if readErr == nil {
1085
+ if string(existing) != string(data) {
1086
+ return "", fmt.Errorf("existing content-addressed receipt does not match %s", filepath.Base(filename))
1087
+ }
1088
+ return "", nil
1089
+ }
1090
+ if !os.IsNotExist(readErr) {
1091
+ return "", readErr
1092
+ }
1093
+ pending := strings.TrimSuffix(filename, ".json") + ".pending"
1094
+ file, err := os.OpenFile(pending, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
1095
+ if err != nil {
1096
+ return "", err
1097
+ }
1098
+ if _, err := file.Write(data); err != nil {
1099
+ _ = file.Close()
1100
+ _ = os.Remove(pending)
1101
+ return "", err
1102
+ }
1103
+ if err := file.Sync(); err != nil {
1104
+ _ = file.Close()
1105
+ _ = os.Remove(pending)
1106
+ return "", err
1107
+ }
1108
+ if err := file.Close(); err != nil {
1109
+ _ = os.Remove(pending)
1110
+ return "", err
1111
+ }
1112
+ if err := os.Chmod(pending, 0o444); err != nil {
1113
+ _ = os.Remove(pending)
1114
+ return "", err
1115
+ }
1116
+ return pending, nil
1117
+ }
1118
+
1119
+ func commitContentAddressedReceipt(filename, pending string, data []byte) error {
1120
+ if pending == "" {
1121
+ return nil
1122
+ }
1123
+ if err := os.Rename(pending, filename); err != nil {
1124
+ return err
1125
+ }
1126
+ persisted, err := os.ReadFile(filename)
1127
+ if err != nil {
1128
+ return err
1129
+ }
1130
+ if string(persisted) != string(data) {
1131
+ return fmt.Errorf("persisted abort receipt does not match staged content")
1132
+ }
1133
+ return nil
871
1134
  }
872
1135
 
873
1136
  // worktreeTree captures the working tree (tracked + untracked, .gitignore
@@ -2,6 +2,7 @@ package lib
2
2
 
3
3
  import (
4
4
  "bytes"
5
+ "encoding/json"
5
6
  "os"
6
7
  "os/exec"
7
8
  "path/filepath"
@@ -293,6 +294,132 @@ func TestReconcileRestoreCheckRollsBackOnlyPostCheckDelta(t *testing.T) {
293
294
  }
294
295
  }
295
296
 
297
+ func TestReconcileAbortRestoresOriginalSourceAndClosesWindow(t *testing.T) {
298
+ gitRoot := newGitRepo(t)
299
+ root := filepath.Join(gitRoot, ".devrites")
300
+ if err := os.MkdirAll(filepath.Join(root, "work", "feat"), 0o755); err != nil {
301
+ t.Fatal(err)
302
+ }
303
+ writeWrightAllowlist(t, root, "feat", "seed.go", "user-dirty.go")
304
+ userDirty := "package main\n\nfunc userWorkBeforeDispatch() {}\n"
305
+ writeFile(t, filepath.Join(gitRoot, "user-dirty.go"), userDirty)
306
+
307
+ if code, out := runReconcile(t, root, "snapshot", "feat"); code != 0 {
308
+ t.Fatalf("snapshot = %d, want 0\n%s", code, out)
309
+ }
310
+ writeFile(t, filepath.Join(gitRoot, "seed.go"), "package main\n\nfunc rejectedWriterChange() {}\n")
311
+ if err := os.Remove(filepath.Join(gitRoot, "user-dirty.go")); err != nil {
312
+ t.Fatal(err)
313
+ }
314
+ writeFile(t, filepath.Join(gitRoot, "writer-added.go"), "package main\n")
315
+ evidence := filepath.Join(featureDir(root, "feat"), "evidence.md")
316
+ writeFile(t, evidence, "rejection evidence stays\n")
317
+
318
+ code, out := runReconcile(t, root, "abort", "feat")
319
+ if code != 0 {
320
+ t.Fatalf("abort = %d, want 0\n%s", code, out)
321
+ }
322
+ if !strings.Contains(out, "restored 3 source path(s)") {
323
+ t.Errorf("abort output omitted restored scope:\n%s", out)
324
+ }
325
+ if data, err := os.ReadFile(filepath.Join(gitRoot, "seed.go")); err != nil {
326
+ t.Fatal(err)
327
+ } else if string(data) != "package main\n" {
328
+ t.Fatalf("seed.go = %q, want original source", data)
329
+ }
330
+ if data, err := os.ReadFile(filepath.Join(gitRoot, "user-dirty.go")); err != nil {
331
+ t.Fatal(err)
332
+ } else if string(data) != userDirty {
333
+ t.Fatalf("pre-snapshot user work = %q, want %q", data, userDirty)
334
+ }
335
+ if _, err := os.Stat(filepath.Join(gitRoot, "writer-added.go")); !os.IsNotExist(err) {
336
+ t.Fatalf("writer-added source remains after abort: %v", err)
337
+ }
338
+ if data, err := os.ReadFile(evidence); err != nil {
339
+ t.Fatal(err)
340
+ } else if string(data) != "rejection evidence stays\n" {
341
+ t.Fatalf("abort changed canonical evidence: %q", data)
342
+ }
343
+ feature := featureDir(root, "feat")
344
+ if isFile(filepath.Join(feature, reconcileBaseName)) || isDir(filepath.Join(feature, reconcileObjectsName)) {
345
+ t.Fatal("abort retained the private reconciliation window")
346
+ }
347
+ receipts, err := filepath.Glob(filepath.Join(feature, ".reconcile-abort-*.json"))
348
+ if err != nil {
349
+ t.Fatal(err)
350
+ }
351
+ if len(receipts) != 1 {
352
+ t.Fatalf("abort receipts = %v, want exactly one", receipts)
353
+ }
354
+ data, err := os.ReadFile(receipts[0])
355
+ if err != nil {
356
+ t.Fatal(err)
357
+ }
358
+ var receipt reconcileAbortReceipt
359
+ if err := json.Unmarshal(data, &receipt); err != nil {
360
+ t.Fatal(err)
361
+ }
362
+ if receipt.SchemaVersion != reconcileAbortSchema || receipt.Slug != "feat" ||
363
+ !receipt.WindowClosed || receipt.SourceEntryCount != 2 ||
364
+ len(receipt.RestoredPaths) != 3 {
365
+ t.Fatalf("unexpected abort receipt: %+v", receipt)
366
+ }
367
+ entries, err := os.ReadDir(feature)
368
+ if err != nil {
369
+ t.Fatal(err)
370
+ }
371
+ var receiptEntry os.DirEntry
372
+ for _, entry := range entries {
373
+ if entry.Name() == filepath.Base(receipts[0]) {
374
+ receiptEntry = entry
375
+ break
376
+ }
377
+ }
378
+ if receiptEntry == nil {
379
+ t.Fatal("abort receipt directory entry not found")
380
+ }
381
+ if ok, err := reconcileRootOwnedOperationalFile(receipts[0], "work/feat/"+filepath.Base(receipts[0]), receiptEntry); err != nil || !ok {
382
+ t.Fatalf("valid abort receipt operational=%v err=%v", ok, err)
383
+ }
384
+ if err := os.Chmod(receipts[0], 0o644); err != nil {
385
+ t.Fatal(err)
386
+ }
387
+ if err := os.WriteFile(receipts[0], append(data, 'x'), 0o644); err != nil {
388
+ t.Fatal(err)
389
+ }
390
+ if _, err := reconcileRootOwnedOperationalFile(receipts[0], "work/feat/"+filepath.Base(receipts[0]), receiptEntry); err == nil {
391
+ t.Fatal("tampered content-addressed abort receipt was accepted")
392
+ }
393
+ }
394
+
395
+ func TestReconcileAbortFailsClosedWhenObjectDatabaseIsMissing(t *testing.T) {
396
+ gitRoot := newGitRepo(t)
397
+ root := workspace(t, "feat")
398
+ writeWrightAllowlist(t, root, "feat", "seed.go")
399
+ if code, out := runReconcile(t, root, "snapshot", "feat"); code != 0 {
400
+ t.Fatalf("snapshot = %d, want 0\n%s", code, out)
401
+ }
402
+ rejected := "package main\n\nfunc rejectedWriterChange() {}\n"
403
+ writeFile(t, filepath.Join(gitRoot, "seed.go"), rejected)
404
+ feature := featureDir(root, "feat")
405
+ if err := os.RemoveAll(filepath.Join(feature, reconcileObjectsName)); err != nil {
406
+ t.Fatal(err)
407
+ }
408
+
409
+ code, out := runReconcile(t, root, "abort", "feat")
410
+ if code != 6 {
411
+ t.Fatalf("abort = %d, want 6\n%s", code, out)
412
+ }
413
+ if data, err := os.ReadFile(filepath.Join(gitRoot, "seed.go")); err != nil {
414
+ t.Fatal(err)
415
+ } else if string(data) != rejected {
416
+ t.Fatalf("failed abort mutated source: %q", data)
417
+ }
418
+ if !isFile(filepath.Join(feature, reconcileBaseName)) {
419
+ t.Fatal("failed abort removed its retained baseline marker")
420
+ }
421
+ }
422
+
296
423
  func TestReconcileCheckFlagsUnauthorizedChange(t *testing.T) {
297
424
  gitRoot := newGitRepo(t)
298
425
  root := workspace(t, "feat")
@@ -633,6 +760,7 @@ func TestReconcileRootOwnedOperationalPathsAreExact(t *testing.T) {
633
760
  "timeline.jsonl",
634
761
  "work/feat/.red",
635
762
  "features/feat/events.jsonl",
763
+ "work/feat/.reconcile-abort-0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef.json",
636
764
  } {
637
765
  if !reconcileRootOwnedOperationalPath(path) {
638
766
  t.Errorf("%q should be root-owned operational state", path)
package/engine/main.go CHANGED
@@ -57,7 +57,7 @@ Usage:
57
57
  devrites-engine test-integrity [slug] Gate: no test deleted, skipped, or de-asserted
58
58
  devrites-engine review-integrity [slug] Gate: no adversarial review axis is silent (zero findings, no justification)
59
59
  devrites-engine dispatch-waive <reason> Record a deterministic pre-dispatch STOP reason
60
- devrites-engine reconcile <sub> [slug] A1 gate: snapshot|check|restore-check|close the wright's source writes
60
+ devrites-engine reconcile <sub> [slug] A1 gate: snapshot|check|restore-check|abort|close the wright's source writes
61
61
  devrites-engine resolve <qid> "<ans>" Resolve an open question; keep state.md consistent
62
62
  devrites-engine close-out <slug> Archive a shipped feature and clear ACTIVE
63
63
  devrites-engine archive-search "<nouns>" Find prior shipped specs that overlap the query
@@ -804,6 +804,9 @@ func TestHookWrightScopeRequiresSpawnedCodexWorkerForSourceWrites(t *testing.T)
804
804
  runGuard(`{"tool_name":"js","tool_input":{"code":"writeFile()"}}`, true)
805
805
  runGuard(`{"tool_name":"Bash","tool_input":{"command":"devrites-engine reconcile restore-check auth-tokens"}}`, false)
806
806
  runGuard(`{"tool_name":"Bash","tool_input":{"command":"devrites-engine reconcile restore-check auth-tokens && printf x > src/app.go"}}`, true)
807
+ runGuard(`{"tool_name":"Bash","tool_input":{"command":"rtk devrites-engine reconcile abort auth-tokens"}}`, false)
808
+ runGuard(`{"tool_name":"Bash","tool_input":{"command":"devrites-engine reconcile abort auth-tokens"}}`, false)
809
+ runGuard(`{"tool_name":"Bash","tool_input":{"command":"devrites-engine reconcile abort auth-tokens && printf x > src/app.go"}}`, true)
807
810
  out, errOut, code := runDevritesIO(t, root,
808
811
  `{"tool_name":"Bash","tool_input":{"command":"printf x > src/app.go"}}`,
809
812
  nil, "hook", "wright-scope", "--harness=claude")
@@ -56,9 +56,9 @@ See also [`one-slice-cycle.md`](one-slice-cycle.md).
56
56
  [`wright-dispatch.md`](wright-dispatch.md) end to end; it owns snapshot timing, stuck
57
57
  logging, the exact packet, conditional skill loading, and return checks. Dispatch through
58
58
  named wright → safely enforced generic fresh worker. If neither is available, stop for
59
- HITL; the root never performs wright work. Before any canonical
60
- `.devrites/` mutation, validate the typed result and require a clean immediate
61
- `devrites-engine reconcile check`; exit `5` rejects the writer result.
59
+ HITL; root never writes source. Validate the result before `.devrites`
60
+ writes; run `reconcile check`. Exit `5` rejects: run standalone
61
+ `devrites-engine reconcile abort`, then verify its receipt and frozen hashes.
62
62
  4. **Doubt every stood decision before accepting the slice.** The writer never grades its
63
63
  own decisions: invoke `devrites-doubt` and its fresh reviewer for each entry. Do not enter
64
64
  step 5 until every entry has a recorded `accept` or resolved `reject` verdict. Log each dispatch:
@@ -70,8 +70,8 @@ devrites-engine reconcile check; echo "reconcile rc=$?"
70
70
  devrites-engine test-integrity; echo "test-integrity rc=$?"
71
71
  ```
72
72
 
73
- - Reconcile `5`: reject the result. Preserve pre-snapshot user work; restore only the
74
- unauthorized slice delta; never widen scope from the writer's self-report.
73
+ - Reconcile `5`: reject; run standalone `devrites-engine reconcile abort` to restore
74
+ pre-snapshot source/user work and close with a receipt. Never widen scope.
75
75
  - Test integrity `3`: a test was deleted, muted, focused, or de-asserted. Treat it as a
76
76
  Critical protocol failure and correct it through the wright.
77
77
  - A setup/corrupt-baseline error blocks acceptance; never fall back silently to `HEAD`.
@@ -56,9 +56,9 @@ See also [`one-slice-cycle.md`](one-slice-cycle.md).
56
56
  [`wright-dispatch.md`](wright-dispatch.md) end to end; it owns snapshot timing, stuck
57
57
  logging, the exact packet, conditional skill loading, and return checks. Dispatch through
58
58
  named wright → safely enforced generic fresh worker. If neither is available, stop for
59
- HITL; the root never performs wright work. Before any canonical
60
- `.devrites/` mutation, validate the typed result and require a clean immediate
61
- `devrites-engine reconcile check`; exit `5` rejects the writer result.
59
+ HITL; root never writes source. Validate the result before `.devrites`
60
+ writes; run `reconcile check`. Exit `5` rejects: run standalone
61
+ `devrites-engine reconcile abort`, then verify its receipt and frozen hashes.
62
62
  4. **Doubt every stood decision before accepting the slice.** The writer never grades its
63
63
  own decisions: invoke `devrites-doubt` and its fresh reviewer for each entry. Do not enter
64
64
  step 5 until every entry has a recorded `accept` or resolved `reject` verdict. Log each dispatch:
@@ -70,8 +70,8 @@ devrites-engine reconcile check; echo "reconcile rc=$?"
70
70
  devrites-engine test-integrity; echo "test-integrity rc=$?"
71
71
  ```
72
72
 
73
- - Reconcile `5`: reject the result. Preserve pre-snapshot user work; restore only the
74
- unauthorized slice delta; never widen scope from the writer's self-report.
73
+ - Reconcile `5`: reject; run standalone `devrites-engine reconcile abort` to restore
74
+ pre-snapshot source/user work and close with a receipt. Never widen scope.
75
75
  - Test integrity `3`: a test was deleted, muted, focused, or de-asserted. Treat it as a
76
76
  Critical protocol failure and correct it through the wright.
77
77
  - A setup/corrupt-baseline error blocks acceptance; never fall back silently to `HEAD`.
@@ -56,9 +56,9 @@ See also [`one-slice-cycle.md`](one-slice-cycle.md).
56
56
  [`wright-dispatch.md`](wright-dispatch.md) end to end; it owns snapshot timing, stuck
57
57
  logging, the exact packet, conditional skill loading, and return checks. Dispatch through
58
58
  named wright → safely enforced generic fresh worker. If neither is available, stop for
59
- HITL; the root never performs wright work. Before any canonical
60
- `.devrites/` mutation, validate the typed result and require a clean immediate
61
- `devrites-engine reconcile check`; exit `5` rejects the writer result.
59
+ HITL; root never writes source. Validate the result before `.devrites`
60
+ writes; run `reconcile check`. Exit `5` rejects: run standalone
61
+ `devrites-engine reconcile abort`, then verify its receipt and frozen hashes.
62
62
  4. **Doubt every stood decision before accepting the slice.** The writer never grades its
63
63
  own decisions: invoke `devrites-doubt` and its fresh reviewer for each entry. Do not enter
64
64
  step 5 until every entry has a recorded `accept` or resolved `reject` verdict. Log each dispatch:
@@ -70,8 +70,8 @@ devrites-engine reconcile check; echo "reconcile rc=$?"
70
70
  devrites-engine test-integrity; echo "test-integrity rc=$?"
71
71
  ```
72
72
 
73
- - Reconcile `5`: reject the result. Preserve pre-snapshot user work; restore only the
74
- unauthorized slice delta; never widen scope from the writer's self-report.
73
+ - Reconcile `5`: reject; run standalone `devrites-engine reconcile abort` to restore
74
+ pre-snapshot source/user work and close with a receipt. Never widen scope.
75
75
  - Test integrity `3`: a test was deleted, muted, focused, or de-asserted. Treat it as a
76
76
  Critical protocol failure and correct it through the wright.
77
77
  - A setup/corrupt-baseline error blocks acceptance; never fall back silently to `HEAD`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "devrites",
3
- "version": "3.2.22",
3
+ "version": "3.2.23",
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",