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 +12 -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.go +49 -7
- package/engine/hooks_workspace.go +7 -6
- package/engine/internal/lib/reconcile.go +454 -47
- package/engine/internal/lib/reconcile_test.go +202 -0
- package/engine/internal/lib/util.go +22 -1
- package/engine/main.go +1 -1
- package/engine/tests/hook_test.go +30 -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,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.
|
|
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)
|
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. |
|
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", "
|
|
393
|
-
"grep", "
|
|
394
|
-
"readlink", "realpath", "basename", "dirname", "cmp", "
|
|
395
|
-
"
|
|
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 !
|
|
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 !
|
|
418
|
+
return !hasShellOption(args, "i", "--in-place", "--inplace")
|
|
402
419
|
case "git":
|
|
403
|
-
return len(args) > 0 &&
|
|
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
|
|
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 := 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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
985
|
+
return fmt.Errorf("restore parent escapes repository through a symlink: %s", changedPath)
|
|
834
986
|
}
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
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
|
-
|
|
841
|
-
|
|
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
|
|
1001
|
+
if len(treeEntry) == 0 {
|
|
1002
|
+
entries = append(entries, restoreEntry{path: changedPath, target: target, parent: targetParent})
|
|
844
1003
|
continue
|
|
845
1004
|
}
|
|
846
|
-
|
|
847
|
-
|
|
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
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
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
|
|
1065
|
+
return fmt.Errorf("read clean symlink %s: %w", entry.path, err)
|
|
862
1066
|
}
|
|
863
|
-
if err := os.
|
|
864
|
-
return
|
|
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
|
-
|
|
867
|
-
|
|
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
|
|
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
|
-
|
|
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;
|
|
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.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",
|