devrites 3.2.22 → 3.2.24

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.24](https://github.com/ViktorsBaikers/DevRites/compare/v3.2.23...v3.2.24) (2026-07-28)
6
+
7
+ ### Fixed
8
+
9
+ * **devrites:** close review safety gaps ([33e736a](https://github.com/ViktorsBaikers/DevRites/commit/33e736a3d9d69fd166827235ed1ce2310aeca75a))
10
+
11
+ ## [3.2.23](https://github.com/ViktorsBaikers/DevRites/compare/v3.2.22...v3.2.23) (2026-07-28)
12
+
13
+ ### Fixed
14
+
15
+ * **devrites:** add retained-window abort recovery ([e47957c](https://github.com/ViktorsBaikers/DevRites/commit/e47957cd759f83e0166985b4b691d84068cc1d93))
16
+
5
17
  ## [3.2.22](https://github.com/ViktorsBaikers/DevRites/compare/v3.2.21...v3.2.22) (2026-07-28)
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.22`](https://github.com/ViktorsBaikers/DevRites/releases/tag/v3.2.22): see [`CHANGELOG.md`](CHANGELOG.md) for release notes.
28
+ **Status:** [`v3.2.24`](https://github.com/ViktorsBaikers/DevRites/releases/tag/v3.2.24): 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. |
package/engine/hooks.go CHANGED
@@ -389,18 +389,37 @@ func safeReadonlyShellSegment(segment string) bool {
389
389
  base := strings.ToLower(filepath.Base(strings.Trim(fields[0], `"'`)))
390
390
  args := fields[1:]
391
391
  switch base {
392
- case "true", "false", "pwd", "ls", "cat", "head", "tail", "less", "more",
393
- "grep", "rg", "wc", "sort", "uniq", "cut", "tr", "stat", "file",
394
- "readlink", "realpath", "basename", "dirname", "cmp", "diff", "jq", "yq",
395
- "tree", "du", "df", "printenv", "which", "date", "uname", "id", "whoami",
392
+ case "true", "false", "pwd", "ls", "cat", "head", "tail", "more",
393
+ "grep", "wc", "uniq", "cut", "tr", "stat", "file",
394
+ "readlink", "realpath", "basename", "dirname", "cmp", "jq",
395
+ "du", "df", "printenv", "which", "date", "uname", "id", "whoami",
396
396
  "ps", "echo", "printf", "test", "[", "cd", "sha256sum", "shasum":
397
397
  return true
398
+ case "less":
399
+ return !hasShellOption(args, "oO", "--log-file")
400
+ case "rg":
401
+ return !hasShellOption(args, "", "--pre", "--hostname-bin")
402
+ case "sort":
403
+ return !hasShellOption(args, "o", "--output", "--compress-program")
404
+ case "diff":
405
+ return !hasShellOption(args, "", "--output")
406
+ case "yq":
407
+ return !hasShellOption(args, "i", "--inplace", "--in-place")
408
+ case "tree":
409
+ return !hasShellOption(args, "o", "--output")
398
410
  case "find":
399
- return !hasAnyArg(args, "-delete", "-exec", "-execdir", "-ok", "-okdir", "-fprint", "-fprintf")
411
+ return !hasArgPrefix(args, "-delete") &&
412
+ !hasArgPrefix(args, "-exec") &&
413
+ !hasArgPrefix(args, "-ok") &&
414
+ !hasArgPrefix(args, "-fprint") &&
415
+ !hasArgPrefix(args, "-fprintf") &&
416
+ !hasArgPrefix(args, "-fls")
400
417
  case "sed":
401
- return !hasArgPrefix(args, "-i")
418
+ return !hasShellOption(args, "i", "--in-place", "--inplace")
402
419
  case "git":
403
- return len(args) > 0 && hasAnyArg(args[:1], "diff", "status", "show", "log", "rev-parse", "ls-files", "grep", "blame")
420
+ return len(args) > 0 &&
421
+ hasAnyArg(args[:1], "diff", "status", "show", "log", "rev-parse", "ls-files", "grep", "blame") &&
422
+ !hasShellOption(args[1:], "", "--output", "--ext-diff", "--textconv", "--open-files-in-pager")
404
423
  case "go":
405
424
  if len(args) == 0 || !hasAnyArg(args[:1], "test", "vet", "list", "version", "env") {
406
425
  return false
@@ -453,6 +472,7 @@ func safePackageProof(args []string) bool {
453
472
 
454
473
  func hasAnyArg(args []string, wants ...string) bool {
455
474
  for _, arg := range args {
475
+ arg = strings.Trim(arg, `"'`)
456
476
  for _, want := range wants {
457
477
  if arg == want {
458
478
  return true
@@ -464,6 +484,7 @@ func hasAnyArg(args []string, wants ...string) bool {
464
484
 
465
485
  func hasArgPrefix(args []string, prefix string) bool {
466
486
  for _, arg := range args {
487
+ arg = strings.Trim(arg, `"'`)
467
488
  if strings.HasPrefix(arg, prefix) {
468
489
  return true
469
490
  }
@@ -471,6 +492,27 @@ func hasArgPrefix(args []string, prefix string) bool {
471
492
  return false
472
493
  }
473
494
 
495
+ func hasShellOption(args []string, shortOptions string, longOptions ...string) bool {
496
+ for _, arg := range args {
497
+ arg = strings.Trim(arg, `"'`)
498
+ if arg == "--" {
499
+ return false
500
+ }
501
+ if strings.HasPrefix(arg, "--") {
502
+ for _, option := range longOptions {
503
+ if arg == option || strings.HasPrefix(arg, option+"=") {
504
+ return true
505
+ }
506
+ }
507
+ continue
508
+ }
509
+ if strings.HasPrefix(arg, "-") && strings.ContainsAny(strings.TrimPrefix(arg, "-"), shortOptions) {
510
+ return true
511
+ }
512
+ }
513
+ return false
514
+ }
515
+
474
516
  // hookReviewerReadonly keeps every DevRites leaf except slice-wright read only.
475
517
  // It always enforces declared DevRites runs. Undeclared runs keep the legacy
476
518
  // observe mode.
@@ -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 := commitContentAddressedReceipt(receiptPath, pendingReceipt, receiptData); err != nil {
331
+ fmt.Fprintf(stderr, "reconcile: source baseline restored, but cannot persist abort receipt; retained window left open: %v\n", err)
332
+ return 6
333
+ }
334
+ if err := closeWindow(); err != nil {
335
+ fmt.Fprintf(stderr, "reconcile: source baseline restored and receipt persisted, but cannot close rejected slice window: %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,78 +950,251 @@ 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
- index := filepath.Join(materialized, "index")
806
- restoreEnv := append(append([]string{}, env...), "GIT_INDEX_FILE="+index)
807
- if _, err := reconcileGitOutput(gitRoot, restoreEnv, "read-tree", checkedTree); err != nil {
808
- return nil, err
809
- }
810
965
  materializedTree := filepath.Join(materialized, "tree")
811
966
  if err := os.MkdirAll(materializedTree, 0o700); err != nil {
812
- return nil, fmt.Errorf("create restore tree: %w", err)
813
- }
814
- gitPrefix := filepath.ToSlash(materializedTree) + "/"
815
- if _, err := reconcileGitOutput(
816
- gitRoot,
817
- restoreEnv,
818
- "-c", "core.autocrlf=false",
819
- "checkout-index", "--all", "--force", "--prefix="+gitPrefix,
820
- ); err != nil {
821
- return nil, err
967
+ return fmt.Errorf("create restore tree: %w", err)
822
968
  }
823
969
 
970
+ type restoreEntry struct {
971
+ path, target, parent, source string
972
+ mode os.FileMode
973
+ symlink, exists bool
974
+ }
975
+ entries := make([]restoreEntry, 0, len(changed))
824
976
  for _, changedPath := range changed {
825
977
  if changedPath == "." || changedPath == ".devrites" ||
826
978
  strings.HasPrefix(changedPath, reconcileDevritesPathPrefix) ||
827
979
  path.Clean(changedPath) != changedPath {
828
- return nil, fmt.Errorf("unsafe restore path %q", changedPath)
980
+ return fmt.Errorf("unsafe restore path %q", changedPath)
829
981
  }
830
982
  target := filepath.Join(gitRoot, filepath.FromSlash(changedPath))
831
983
  targetParent := filepath.Dir(target)
832
984
  if !safepath.WithinResolved(targetParent, gitRoot) {
833
- return nil, fmt.Errorf("restore parent escapes repository through a symlink: %s", changedPath)
985
+ return fmt.Errorf("restore parent escapes repository through a symlink: %s", changedPath)
834
986
  }
835
- source := filepath.Join(materializedTree, filepath.FromSlash(changedPath))
836
- sourceInfo, sourceErr := os.Lstat(source)
837
- if sourceErr != nil && !os.IsNotExist(sourceErr) {
838
- return nil, fmt.Errorf("inspect clean source %s: %w", changedPath, sourceErr)
987
+ if targetInfo, targetErr := os.Lstat(target); targetErr == nil {
988
+ if targetInfo.IsDir() {
989
+ return fmt.Errorf("refusing to remove live directory at restore path %s", changedPath)
990
+ }
991
+ } else if !os.IsNotExist(targetErr) {
992
+ return fmt.Errorf("inspect restore target %s: %w", changedPath, targetErr)
839
993
  }
840
- if err := os.RemoveAll(target); err != nil {
841
- return nil, fmt.Errorf("remove post-check path %s: %w", changedPath, err)
994
+
995
+ treeEntry, err := reconcileGitOutput(
996
+ gitRoot, env, "ls-tree", "-z", targetTree, "--", ":(top,literal)"+changedPath,
997
+ )
998
+ if err != nil {
999
+ return err
842
1000
  }
843
- if os.IsNotExist(sourceErr) {
1001
+ if len(treeEntry) == 0 {
1002
+ entries = append(entries, restoreEntry{path: changedPath, target: target, parent: targetParent})
844
1003
  continue
845
1004
  }
846
- if err := os.MkdirAll(targetParent, 0o755); err != nil {
847
- return nil, fmt.Errorf("create restore parent for %s: %w", changedPath, err)
1005
+ record := strings.TrimSuffix(string(treeEntry), "\x00")
1006
+ if strings.Contains(record, "\x00") {
1007
+ return fmt.Errorf("ambiguous tree entry for %s", changedPath)
1008
+ }
1009
+ header, entryPath, ok := strings.Cut(record, "\t")
1010
+ fields := strings.Fields(header)
1011
+ if !ok || len(fields) != 3 || entryPath != changedPath {
1012
+ return fmt.Errorf("invalid tree entry for %s", changedPath)
1013
+ }
1014
+ if fields[0] == "160000" || fields[1] == "commit" {
1015
+ return fmt.Errorf("cannot safely restore changed submodule worktree %s", changedPath)
1016
+ }
1017
+ entry := restoreEntry{
1018
+ path: changedPath,
1019
+ target: target,
1020
+ parent: targetParent,
1021
+ source: filepath.Join(materializedTree, filepath.FromSlash(changedPath)),
1022
+ exists: true,
1023
+ symlink: fields[0] == "120000",
1024
+ }
1025
+ switch fields[0] {
1026
+ case "100644":
1027
+ entry.mode = 0o644
1028
+ case "100755":
1029
+ entry.mode = 0o755
1030
+ case "120000":
1031
+ default:
1032
+ return fmt.Errorf("unsupported tree mode %s for %s", fields[0], changedPath)
848
1033
  }
849
- switch {
850
- case sourceInfo.Mode()&os.ModeSymlink != 0:
851
- link, err := os.Readlink(source)
852
- if err != nil {
853
- return nil, fmt.Errorf("read clean symlink %s: %w", changedPath, err)
854
- }
855
- if err := os.Symlink(link, target); err != nil {
856
- return nil, fmt.Errorf("restore symlink %s: %w", changedPath, err)
857
- }
858
- case sourceInfo.Mode().IsRegular():
859
- data, err := os.ReadFile(source)
1034
+ if err := os.MkdirAll(filepath.Dir(entry.source), 0o700); err != nil {
1035
+ return fmt.Errorf("create materialized parent for %s: %w", changedPath, err)
1036
+ }
1037
+ source, err := os.OpenFile(entry.source, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
1038
+ if err != nil {
1039
+ return fmt.Errorf("create materialized source %s: %w", changedPath, err)
1040
+ }
1041
+ writeErr := runGitCommandToWriter(gitRoot, env, source, "cat-file", "blob", fields[2])
1042
+ closeErr := source.Close()
1043
+ if writeErr != nil {
1044
+ return fmt.Errorf("materialize source %s: %w", changedPath, writeErr)
1045
+ }
1046
+ if closeErr != nil {
1047
+ return fmt.Errorf("close materialized source %s: %w", changedPath, closeErr)
1048
+ }
1049
+ entries = append(entries, entry)
1050
+ }
1051
+
1052
+ for _, entry := range entries {
1053
+ if err := os.RemoveAll(entry.target); err != nil {
1054
+ return fmt.Errorf("remove source path %s: %w", entry.path, err)
1055
+ }
1056
+ if !entry.exists {
1057
+ continue
1058
+ }
1059
+ if err := os.MkdirAll(entry.parent, 0o755); err != nil {
1060
+ return fmt.Errorf("create restore parent for %s: %w", entry.path, err)
1061
+ }
1062
+ if entry.symlink {
1063
+ link, err := os.ReadFile(entry.source)
860
1064
  if err != nil {
861
- return nil, fmt.Errorf("read clean file %s: %w", changedPath, err)
1065
+ return fmt.Errorf("read clean symlink %s: %w", entry.path, err)
862
1066
  }
863
- if err := os.WriteFile(target, data, sourceInfo.Mode().Perm()); err != nil {
864
- return nil, fmt.Errorf("restore file %s: %w", changedPath, err)
1067
+ if err := os.Symlink(string(link), entry.target); err != nil {
1068
+ return fmt.Errorf("restore symlink %s: %w", entry.path, err)
865
1069
  }
866
- default:
867
- return nil, fmt.Errorf("unsupported clean tree entry for %s: %s", changedPath, sourceInfo.Mode())
1070
+ continue
1071
+ }
1072
+ source, err := os.Open(entry.source)
1073
+ if err != nil {
1074
+ return fmt.Errorf("open clean file %s: %w", entry.path, err)
1075
+ }
1076
+ target, err := os.OpenFile(entry.target, os.O_WRONLY|os.O_CREATE|os.O_EXCL, entry.mode)
1077
+ if err != nil {
1078
+ _ = source.Close()
1079
+ return fmt.Errorf("create restored file %s: %w", entry.path, err)
1080
+ }
1081
+ _, copyErr := io.Copy(target, source)
1082
+ sourceCloseErr := source.Close()
1083
+ targetCloseErr := target.Close()
1084
+ if copyErr != nil {
1085
+ return fmt.Errorf("restore file %s: %w", entry.path, copyErr)
1086
+ }
1087
+ if sourceCloseErr != nil {
1088
+ return fmt.Errorf("close clean file %s: %w", entry.path, sourceCloseErr)
1089
+ }
1090
+ if targetCloseErr != nil {
1091
+ return fmt.Errorf("close restored file %s: %w", entry.path, targetCloseErr)
868
1092
  }
869
1093
  }
870
- return changed, nil
1094
+ return nil
1095
+ }
1096
+
1097
+ func sourceTreePaths(paths []string) []string {
1098
+ source := make([]string, 0, len(paths))
1099
+ for _, changedPath := range paths {
1100
+ if changedPath == ".devrites" || strings.HasPrefix(changedPath, reconcileDevritesPathPrefix) {
1101
+ continue
1102
+ }
1103
+ source = append(source, changedPath)
1104
+ }
1105
+ sort.Strings(source)
1106
+ return source
1107
+ }
1108
+
1109
+ func sourceTreeManifest(gitRoot string, env []string, tree string) (string, int, error) {
1110
+ out, err := reconcileGitOutput(gitRoot, env, "ls-tree", "-r", "-z", "--full-tree", tree)
1111
+ if err != nil {
1112
+ return "", 0, err
1113
+ }
1114
+ hash := sha256.New()
1115
+ count := 0
1116
+ for _, entry := range strings.Split(string(out), "\x00") {
1117
+ if entry == "" {
1118
+ continue
1119
+ }
1120
+ tab := strings.IndexByte(entry, '\t')
1121
+ if tab < 0 {
1122
+ return "", 0, fmt.Errorf("invalid ls-tree entry")
1123
+ }
1124
+ entryPath := entry[tab+1:]
1125
+ if entryPath == ".devrites" || strings.HasPrefix(entryPath, reconcileDevritesPathPrefix) {
1126
+ continue
1127
+ }
1128
+ _, _ = hash.Write([]byte(entry))
1129
+ _, _ = hash.Write([]byte{0})
1130
+ count++
1131
+ }
1132
+ return hex.EncodeToString(hash.Sum(nil)), count, nil
1133
+ }
1134
+
1135
+ func renderReconcileAbortReceipt(receipt reconcileAbortReceipt) ([]byte, string, error) {
1136
+ data, err := json.MarshalIndent(receipt, "", " ")
1137
+ if err != nil {
1138
+ return nil, "", err
1139
+ }
1140
+ data = append(data, '\n')
1141
+ digest := sha256.Sum256(data)
1142
+ name := fmt.Sprintf(".reconcile-abort-%x.json", digest)
1143
+ return data, name, nil
1144
+ }
1145
+
1146
+ func stageContentAddressedReceipt(filename string, data []byte) (string, error) {
1147
+ existing, readErr := os.ReadFile(filename)
1148
+ if readErr == nil {
1149
+ if string(existing) != string(data) {
1150
+ return "", fmt.Errorf("existing content-addressed receipt does not match %s", filepath.Base(filename))
1151
+ }
1152
+ return "", nil
1153
+ }
1154
+ if !os.IsNotExist(readErr) {
1155
+ return "", readErr
1156
+ }
1157
+ pending := strings.TrimSuffix(filename, ".json") + ".pending"
1158
+ file, err := os.OpenFile(pending, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
1159
+ if err != nil {
1160
+ return "", err
1161
+ }
1162
+ if _, err := file.Write(data); err != nil {
1163
+ _ = file.Close()
1164
+ _ = os.Remove(pending)
1165
+ return "", err
1166
+ }
1167
+ if err := file.Sync(); err != nil {
1168
+ _ = file.Close()
1169
+ _ = os.Remove(pending)
1170
+ return "", err
1171
+ }
1172
+ if err := file.Close(); err != nil {
1173
+ _ = os.Remove(pending)
1174
+ return "", err
1175
+ }
1176
+ if err := os.Chmod(pending, 0o444); err != nil {
1177
+ _ = os.Remove(pending)
1178
+ return "", err
1179
+ }
1180
+ return pending, nil
1181
+ }
1182
+
1183
+ func commitContentAddressedReceipt(filename, pending string, data []byte) error {
1184
+ if pending == "" {
1185
+ return nil
1186
+ }
1187
+ if err := os.Rename(pending, filename); err != nil {
1188
+ return err
1189
+ }
1190
+ persisted, err := os.ReadFile(filename)
1191
+ if err != nil {
1192
+ return err
1193
+ }
1194
+ if string(persisted) != string(data) {
1195
+ return fmt.Errorf("persisted abort receipt does not match staged content")
1196
+ }
1197
+ return nil
871
1198
  }
872
1199
 
873
1200
  // worktreeTree captures the working tree (tracked + untracked, .gitignore
@@ -930,6 +1257,9 @@ func worktreeTree(gitRoot, objectDir string, excludedRoots ...string) (string, e
930
1257
  return "", fmt.Errorf("exclude %s from worktree snapshot: %w", rel, err)
931
1258
  }
932
1259
  }
1260
+ if err := rewriteIndexWithRawWorktreeBytes(gitRoot, env); err != nil {
1261
+ return "", err
1262
+ }
933
1263
 
934
1264
  out, err := runGitCommand(gitRoot, env, "write-tree")
935
1265
  if err != nil {
@@ -942,6 +1272,83 @@ func worktreeTree(gitRoot, objectDir string, excludedRoots ...string) (string, e
942
1272
  return tree, nil
943
1273
  }
944
1274
 
1275
+ func rewriteIndexWithRawWorktreeBytes(gitRoot string, env []string) error {
1276
+ out, err := runGitCommand(gitRoot, env, "ls-files", "--stage", "-z")
1277
+ if err != nil {
1278
+ return fmt.Errorf("list worktree snapshot entries: %w", err)
1279
+ }
1280
+ type rawEntry struct {
1281
+ mode, path string
1282
+ }
1283
+ var batch, individual []rawEntry
1284
+ for _, record := range strings.Split(string(out), "\x00") {
1285
+ if record == "" {
1286
+ continue
1287
+ }
1288
+ header, filename, ok := strings.Cut(record, "\t")
1289
+ fields := strings.Fields(header)
1290
+ if !ok || len(fields) != 3 || fields[2] != "0" {
1291
+ return fmt.Errorf("invalid worktree snapshot index entry")
1292
+ }
1293
+ if fields[0] != "100644" && fields[0] != "100755" {
1294
+ continue
1295
+ }
1296
+ entry := rawEntry{mode: fields[0], path: filename}
1297
+ if strings.ContainsRune(filename, '\n') {
1298
+ individual = append(individual, entry)
1299
+ } else {
1300
+ batch = append(batch, entry)
1301
+ }
1302
+ }
1303
+
1304
+ var indexInfo strings.Builder
1305
+ if len(batch) > 0 {
1306
+ var paths strings.Builder
1307
+ for _, entry := range batch {
1308
+ paths.WriteString(entry.path)
1309
+ paths.WriteByte('\n')
1310
+ }
1311
+ hashes, err := runGitCommandInput(
1312
+ gitRoot, env, []byte(paths.String()),
1313
+ "hash-object", "-w", "--no-filters", "--stdin-paths",
1314
+ )
1315
+ if err != nil {
1316
+ return fmt.Errorf("hash raw worktree files: %w", err)
1317
+ }
1318
+ objectIDs := strings.Fields(string(hashes))
1319
+ if len(objectIDs) != len(batch) {
1320
+ return fmt.Errorf("hash raw worktree files: got %d object ids for %d paths", len(objectIDs), len(batch))
1321
+ }
1322
+ for i, entry := range batch {
1323
+ if !reconcileObjectID.MatchString(objectIDs[i]) {
1324
+ return fmt.Errorf("hash raw worktree file %s: invalid object id", entry.path)
1325
+ }
1326
+ fmt.Fprintf(&indexInfo, "%s %s\t%s\x00", entry.mode, objectIDs[i], entry.path)
1327
+ }
1328
+ }
1329
+ for _, entry := range individual {
1330
+ hash, err := runGitCommand(gitRoot, env, "hash-object", "-w", "--no-filters", "--", entry.path)
1331
+ if err != nil {
1332
+ return fmt.Errorf("hash raw worktree file %s: %w", entry.path, err)
1333
+ }
1334
+ objectID := strings.TrimSpace(string(hash))
1335
+ if !reconcileObjectID.MatchString(objectID) {
1336
+ return fmt.Errorf("hash raw worktree file %s: invalid object id", entry.path)
1337
+ }
1338
+ fmt.Fprintf(&indexInfo, "%s %s\t%s\x00", entry.mode, objectID, entry.path)
1339
+ }
1340
+ if indexInfo.Len() == 0 {
1341
+ return nil
1342
+ }
1343
+ if _, err := runGitCommandInput(
1344
+ gitRoot, env, []byte(indexInfo.String()),
1345
+ "update-index", "-z", "--index-info",
1346
+ ); err != nil {
1347
+ return fmt.Errorf("record raw worktree files: %w", err)
1348
+ }
1349
+ return nil
1350
+ }
1351
+
945
1352
  func reconcileGitEnv(gitRoot, objectDir string) ([]string, error) {
946
1353
  out, err := runGitCommand(gitRoot, nil, "rev-parse", "--git-common-dir")
947
1354
  if err != nil {
@@ -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,206 @@ 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 TestReconcileAbortRestoresOriginalCRLFBytes(t *testing.T) {
396
+ gitRoot := newGitRepo(t)
397
+ root := workspace(t, "feat")
398
+ writeWrightAllowlist(t, root, "feat", "seed.go")
399
+ if out, err := exec.Command("git", "-C", gitRoot, "config", "core.autocrlf", "true").CombinedOutput(); err != nil {
400
+ t.Fatalf("configure core.autocrlf: %v\n%s", err, out)
401
+ }
402
+ baseline := []byte("package main\r\n\r\nfunc userWork() {}\r\n")
403
+ if err := os.WriteFile(filepath.Join(gitRoot, "seed.go"), baseline, 0o644); err != nil {
404
+ t.Fatal(err)
405
+ }
406
+
407
+ if code, out := runReconcile(t, root, "snapshot", "feat"); code != 0 {
408
+ t.Fatalf("snapshot = %d, want 0\n%s", code, out)
409
+ }
410
+ writeFile(t, filepath.Join(gitRoot, "seed.go"), "package main\n\nfunc rejectedWriterChange() {}\n")
411
+
412
+ if code, out := runReconcile(t, root, "abort", "feat"); code != 0 {
413
+ t.Fatalf("abort = %d, want 0\n%s", code, out)
414
+ }
415
+ restored, err := os.ReadFile(filepath.Join(gitRoot, "seed.go"))
416
+ if err != nil {
417
+ t.Fatal(err)
418
+ }
419
+ if !bytes.Equal(restored, baseline) {
420
+ t.Fatalf("restored bytes = %q, want original bytes %q", restored, baseline)
421
+ }
422
+ }
423
+
424
+ func TestReconcileAbortPreservesChangedSubmoduleWorktree(t *testing.T) {
425
+ submoduleOrigin := newGitRepo(t)
426
+ gitRoot := newGitRepo(t)
427
+ git := func(dir string, args ...string) string {
428
+ t.Helper()
429
+ cmd := exec.Command("git", append([]string{"-C", dir}, args...)...)
430
+ out, err := cmd.CombinedOutput()
431
+ if err != nil {
432
+ t.Fatalf("git %v: %v\n%s", args, err, out)
433
+ }
434
+ return strings.TrimSpace(string(out))
435
+ }
436
+ git(gitRoot, "-c", "protocol.file.allow=always", "submodule", "add", "-q", submoduleOrigin, "sub")
437
+ commitAll(t, gitRoot, "add submodule")
438
+
439
+ root := workspace(t, "feat")
440
+ writeWrightAllowlist(t, root, "feat")
441
+ if code, out := runReconcile(t, root, "snapshot", "feat"); code != 0 {
442
+ t.Fatalf("snapshot = %d, want 0\n%s", code, out)
443
+ }
444
+
445
+ writeFile(t, filepath.Join(submoduleOrigin, "seed.go"), "package changed\n")
446
+ commitAll(t, submoduleOrigin, "advance submodule")
447
+ advanced := git(submoduleOrigin, "rev-parse", "HEAD")
448
+ git(filepath.Join(gitRoot, "sub"), "-c", "protocol.file.allow=always", "fetch", "-q", "origin")
449
+ git(filepath.Join(gitRoot, "sub"), "checkout", "-q", advanced)
450
+ local := []byte("uncommitted submodule work\n")
451
+ if err := os.WriteFile(filepath.Join(gitRoot, "sub", "local.txt"), local, 0o644); err != nil {
452
+ t.Fatal(err)
453
+ }
454
+
455
+ code, out := runReconcile(t, root, "abort", "feat")
456
+ if code != 6 {
457
+ t.Fatalf("abort = %d, want fail-closed 6 for a changed submodule\n%s", code, out)
458
+ }
459
+ if restored, err := os.ReadFile(filepath.Join(gitRoot, "sub", "local.txt")); err != nil {
460
+ t.Fatalf("abort removed the live submodule worktree: %v\n%s", err, out)
461
+ } else if !bytes.Equal(restored, local) {
462
+ t.Fatalf("abort changed uncommitted submodule bytes: %q", restored)
463
+ }
464
+ if !isFile(filepath.Join(featureDir(root, "feat"), reconcileBaseName)) {
465
+ t.Fatal("failed abort removed its retained baseline marker")
466
+ }
467
+ }
468
+
469
+ func TestReconcileAbortFailsClosedWhenObjectDatabaseIsMissing(t *testing.T) {
470
+ gitRoot := newGitRepo(t)
471
+ root := workspace(t, "feat")
472
+ writeWrightAllowlist(t, root, "feat", "seed.go")
473
+ if code, out := runReconcile(t, root, "snapshot", "feat"); code != 0 {
474
+ t.Fatalf("snapshot = %d, want 0\n%s", code, out)
475
+ }
476
+ rejected := "package main\n\nfunc rejectedWriterChange() {}\n"
477
+ writeFile(t, filepath.Join(gitRoot, "seed.go"), rejected)
478
+ feature := featureDir(root, "feat")
479
+ if err := os.RemoveAll(filepath.Join(feature, reconcileObjectsName)); err != nil {
480
+ t.Fatal(err)
481
+ }
482
+
483
+ code, out := runReconcile(t, root, "abort", "feat")
484
+ if code != 6 {
485
+ t.Fatalf("abort = %d, want 6\n%s", code, out)
486
+ }
487
+ if data, err := os.ReadFile(filepath.Join(gitRoot, "seed.go")); err != nil {
488
+ t.Fatal(err)
489
+ } else if string(data) != rejected {
490
+ t.Fatalf("failed abort mutated source: %q", data)
491
+ }
492
+ if !isFile(filepath.Join(feature, reconcileBaseName)) {
493
+ t.Fatal("failed abort removed its retained baseline marker")
494
+ }
495
+ }
496
+
296
497
  func TestReconcileCheckFlagsUnauthorizedChange(t *testing.T) {
297
498
  gitRoot := newGitRepo(t)
298
499
  root := workspace(t, "feat")
@@ -633,6 +834,7 @@ func TestReconcileRootOwnedOperationalPathsAreExact(t *testing.T) {
633
834
  "timeline.jsonl",
634
835
  "work/feat/.red",
635
836
  "features/feat/events.jsonl",
837
+ "work/feat/.reconcile-abort-0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef.json",
636
838
  } {
637
839
  if !reconcileRootOwnedOperationalPath(path) {
638
840
  t.Errorf("%q should be root-owned operational state", path)
@@ -5,6 +5,7 @@ import (
5
5
  "context"
6
6
  "errors"
7
7
  "fmt"
8
+ "io"
8
9
  "os"
9
10
  "os/exec"
10
11
  "path/filepath"
@@ -57,11 +58,27 @@ func (e *gitCommandError) Error() string {
57
58
  func (e *gitCommandError) Unwrap() error { return e.err }
58
59
 
59
60
  func runGitCommand(dir string, env []string, args ...string) ([]byte, error) {
61
+ return runGitCommandIO(dir, env, nil, nil, args...)
62
+ }
63
+
64
+ func runGitCommandInput(dir string, env []string, input []byte, args ...string) ([]byte, error) {
65
+ return runGitCommandIO(dir, env, input, nil, args...)
66
+ }
67
+
68
+ func runGitCommandToWriter(dir string, env []string, stdout io.Writer, args ...string) error {
69
+ _, err := runGitCommandIO(dir, env, nil, stdout, args...)
70
+ return err
71
+ }
72
+
73
+ func runGitCommandIO(dir string, env []string, input []byte, stdout io.Writer, args ...string) ([]byte, error) {
60
74
  ctx, cancel := context.WithTimeout(context.Background(), gitCommandTimeout)
61
75
  defer cancel()
62
76
  commandArgs := append([]string{"-C", dir}, args...)
63
77
  cmd := exec.CommandContext(ctx, "git", commandArgs...)
64
78
  cmd.WaitDelay = 2 * time.Second
79
+ if input != nil {
80
+ cmd.Stdin = bytes.NewReader(input)
81
+ }
65
82
  if env == nil {
66
83
  env = os.Environ()
67
84
  } else {
@@ -76,7 +93,11 @@ func runGitCommand(dir string, env []string, args ...string) ([]byte, error) {
76
93
  "LC_ALL=C",
77
94
  )
78
95
  var output cappedGitOutput
79
- cmd.Stdout = &output
96
+ if stdout == nil {
97
+ cmd.Stdout = &output
98
+ } else {
99
+ cmd.Stdout = stdout
100
+ }
80
101
  cmd.Stderr = &output
81
102
  err := cmd.Run()
82
103
  if err == nil {
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
@@ -293,6 +293,33 @@ func TestHookReviewerReadonlyAllowsSafeBash(t *testing.T) {
293
293
  }
294
294
  }
295
295
 
296
+ func TestHookReviewerReadonlyRejectsWriteCapableInspectionFlags(t *testing.T) {
297
+ root := newWorkspace(t)
298
+ env := []string{
299
+ "DEVRITES_AGENT_RUN=1",
300
+ "DEVRITES_ACTIVE_AGENT=devrites-code-reviewer",
301
+ }
302
+ for _, command := range []string{
303
+ "sed --in-place=.bak s/a/b/ src/app.go",
304
+ "sort -o src/app.go input.txt",
305
+ "find . -fls src/index.txt",
306
+ "git diff --output=src/diff.txt",
307
+ } {
308
+ in := hookPayload(t, map[string]any{
309
+ "tool_name": "Bash",
310
+ "tool_input": map[string]any{"command": command},
311
+ })
312
+ out, errOut, code := runDevritesIO(t, root, in, env,
313
+ "hook", "reviewer-readonly", "--harness=codex")
314
+ if code != 0 {
315
+ t.Fatalf("%q exit=%d stderr=%q", command, code, errOut)
316
+ }
317
+ if decision, _ := parsePermissionDecision(t, out); decision != "deny" {
318
+ t.Errorf("write-capable inspection command was not denied: %q", command)
319
+ }
320
+ }
321
+ }
322
+
296
323
  func TestHookReviewerReadonlyNonBashIsSilent(t *testing.T) {
297
324
  root := newWorkspace(t)
298
325
  in := `{"tool_name":"Read","tool_input":{"file_path":"secret.txt"}}`
@@ -804,6 +831,9 @@ func TestHookWrightScopeRequiresSpawnedCodexWorkerForSourceWrites(t *testing.T)
804
831
  runGuard(`{"tool_name":"js","tool_input":{"code":"writeFile()"}}`, true)
805
832
  runGuard(`{"tool_name":"Bash","tool_input":{"command":"devrites-engine reconcile restore-check auth-tokens"}}`, false)
806
833
  runGuard(`{"tool_name":"Bash","tool_input":{"command":"devrites-engine reconcile restore-check auth-tokens && printf x > src/app.go"}}`, true)
834
+ runGuard(`{"tool_name":"Bash","tool_input":{"command":"rtk devrites-engine reconcile abort auth-tokens"}}`, false)
835
+ runGuard(`{"tool_name":"Bash","tool_input":{"command":"devrites-engine reconcile abort auth-tokens"}}`, false)
836
+ runGuard(`{"tool_name":"Bash","tool_input":{"command":"devrites-engine reconcile abort auth-tokens && printf x > src/app.go"}}`, true)
807
837
  out, errOut, code := runDevritesIO(t, root,
808
838
  `{"tool_name":"Bash","tool_input":{"command":"printf x > src/app.go"}}`,
809
839
  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.24",
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",