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 +6 -0
- package/README.md +1 -1
- package/docs/cli.md +1 -1
- package/docs/command-map.md +1 -1
- package/docs/orchestration.md +10 -0
- package/docs/skills.md +1 -1
- package/engine/hooks_workspace.go +7 -6
- package/engine/internal/lib/reconcile.go +282 -19
- package/engine/internal/lib/reconcile_test.go +128 -0
- package/engine/main.go +1 -1
- package/engine/tests/hook_test.go +3 -0
- package/pack/.claude/skills/rite-build/reference/phase-contract.md +3 -3
- package/pack/.claude/skills/rite-build/reference/wright-dispatch.md +2 -2
- package/pack/generated/claude/skills/rite-build/reference/phase-contract.md +3 -3
- package/pack/generated/claude/skills/rite-build/reference/wright-dispatch.md +2 -2
- package/pack/generated/codex/skills/rite-build/reference/phase-contract.md +3 -3
- package/pack/generated/codex/skills/rite-build/reference/wright-dispatch.md +2 -2
- package/package.json +1 -1
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.
|
|
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)
|
package/docs/command-map.md
CHANGED
|
@@ -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
|
|
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` |
|
package/docs/orchestration.md
CHANGED
|
@@ -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
|
|
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
|
|
266
|
-
redFailRe
|
|
267
|
-
redPassRe
|
|
268
|
-
|
|
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) &&
|
|
1065
|
-
return true, !
|
|
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. `
|
|
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
|
|
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
|
|
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",
|
|
808
|
-
return
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
998
|
+
return fmt.Errorf("inspect clean source %s: %w", changedPath, sourceErr)
|
|
839
999
|
}
|
|
840
1000
|
if err := os.RemoveAll(target); err != nil {
|
|
841
|
-
return
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
1024
|
+
return fmt.Errorf("restore file %s: %w", changedPath, err)
|
|
865
1025
|
}
|
|
866
1026
|
default:
|
|
867
|
-
return
|
|
1027
|
+
return fmt.Errorf("unsupported clean tree entry for %s: %s", changedPath, sourceInfo.Mode())
|
|
868
1028
|
}
|
|
869
1029
|
}
|
|
870
|
-
return
|
|
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;
|
|
60
|
-
|
|
61
|
-
`devrites-engine reconcile
|
|
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
|
|
74
|
-
|
|
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;
|
|
60
|
-
|
|
61
|
-
`devrites-engine reconcile
|
|
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
|
|
74
|
-
|
|
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;
|
|
60
|
-
|
|
61
|
-
`devrites-engine reconcile
|
|
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
|
|
74
|
-
|
|
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.
|
|
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",
|