devrites 3.2.7 → 3.2.8
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/orchestration.md +10 -0
- package/engine/internal/lib/reconcile.go +63 -0
- package/engine/internal/lib/reconcile_test.go +152 -0
- package/pack/.claude/skills/rite-build/reference/wright-dispatch.md +1 -1
- package/pack/generated/claude/skills/rite-build/reference/wright-dispatch.md +1 -1
- package/pack/generated/codex/skills/rite-build/reference/wright-dispatch.md +1 -1
- 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.8](https://github.com/ViktorsBaikers/DevRites/compare/v3.2.7...v3.2.8) (2026-07-26)
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
* **devrites:** prevent reconcile self-invalidation ([161ed18](https://github.com/ViktorsBaikers/DevRites/commit/161ed187e600dfc92623b22db5cd7f0824408d1a))
|
|
10
|
+
|
|
5
11
|
## [3.2.7](https://github.com/ViktorsBaikers/DevRites/compare/v3.2.6...v3.2.7) (2026-07-26)
|
|
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.8`](https://github.com/ViktorsBaikers/DevRites/releases/tag/v3.2.8): see [`CHANGELOG.md`](CHANGELOG.md) for release notes.
|
|
29
29
|
|
|
30
30
|
## Quick start
|
|
31
31
|
|
package/docs/orchestration.md
CHANGED
|
@@ -69,6 +69,16 @@ Each dispatch follows the retained-baseline sequence:
|
|
|
69
69
|
5. After proof and decision checks pass, `reconcile close` retires the private
|
|
70
70
|
window; only then does the root write canonical records.
|
|
71
71
|
|
|
72
|
+
The canonical-state fingerprint excludes root-owned operational records:
|
|
73
|
+
`timeline.jsonl`, feature `events.jsonl`, a valid `recovery-attempts.jsonl`,
|
|
74
|
+
the redwatch sentinel, automatic handoff snapshots, and hidden hook observation
|
|
75
|
+
logs. Those records cannot grant source authority or invalidate a writer result
|
|
76
|
+
merely because the engine or a hook wrote them. Unknown or canonical
|
|
77
|
+
`.devrites` paths remain fail-closed, and recovery ledgers must still parse
|
|
78
|
+
before reconciliation can pass. Gate-authoritative operational state such as
|
|
79
|
+
recovery attempts and `.red` remains enforced by its owning gate. Checks also
|
|
80
|
+
normalize these paths out of retained snapshots created by older engines.
|
|
81
|
+
|
|
72
82
|
On retry, the root may add only an accepted still-in-slice path, then runs
|
|
73
83
|
`reconcile snapshot` again. This refreshes the dispatch boundary while
|
|
74
84
|
preserving the original slice baseline. The same objective root cause has a
|
|
@@ -215,6 +215,7 @@ func Reconcile(root string, args []string, stdout, stderr io.Writer) int {
|
|
|
215
215
|
fmt.Fprintf(stderr, "reconcile: invalid .devrites snapshot: %v\n", err)
|
|
216
216
|
return 6
|
|
217
217
|
}
|
|
218
|
+
beforeDevrites = withoutRootOwnedOperationalState(beforeDevrites)
|
|
218
219
|
afterDevrites, err := captureReconcileDevritesState(root, devritesSnapshot, objects, checked)
|
|
219
220
|
if err != nil {
|
|
220
221
|
fmt.Fprintf(stderr, "reconcile: cannot compare .devrites: %v\n", err)
|
|
@@ -465,6 +466,13 @@ func captureDevritesState(root string, ignoredPaths ...string) ([]devritesStateE
|
|
|
465
466
|
}
|
|
466
467
|
return nil
|
|
467
468
|
}
|
|
469
|
+
operational, err := reconcileRootOwnedOperationalFile(filename, rel, entry)
|
|
470
|
+
if err != nil {
|
|
471
|
+
return err
|
|
472
|
+
}
|
|
473
|
+
if operational {
|
|
474
|
+
return nil
|
|
475
|
+
}
|
|
468
476
|
|
|
469
477
|
fingerprint, err := devritesFingerprint(filename, entry)
|
|
470
478
|
if err != nil {
|
|
@@ -483,6 +491,61 @@ func captureDevritesState(root string, ignoredPaths ...string) ([]devritesStateE
|
|
|
483
491
|
return state, nil
|
|
484
492
|
}
|
|
485
493
|
|
|
494
|
+
// reconcileRootOwnedOperationalFile excludes engine/root-owned operational
|
|
495
|
+
// records from the writer delta. Their owning hooks/gates validate them where
|
|
496
|
+
// needed; root activity must not be attributed to the slice writer.
|
|
497
|
+
func reconcileRootOwnedOperationalFile(filename, rel string, entry os.DirEntry) (bool, error) {
|
|
498
|
+
if !entry.Type().IsRegular() || !reconcileRootOwnedOperationalPath(rel) {
|
|
499
|
+
return false, nil
|
|
500
|
+
}
|
|
501
|
+
rel = filepath.ToSlash(filepath.Clean(rel))
|
|
502
|
+
parts := strings.Split(rel, "/")
|
|
503
|
+
if len(parts) == 3 && parts[2] == recoveryAttemptsFile {
|
|
504
|
+
if _, err := readRecoveryAttempts(filename); err != nil {
|
|
505
|
+
return false, fmt.Errorf("validate root-owned recovery ledger %s: %w", rel, err)
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
return true, nil
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
func reconcileRootOwnedOperationalPath(rel string) bool {
|
|
512
|
+
rel = filepath.ToSlash(filepath.Clean(rel))
|
|
513
|
+
if rel == "timeline.jsonl" {
|
|
514
|
+
return true
|
|
515
|
+
}
|
|
516
|
+
parts := strings.Split(rel, "/")
|
|
517
|
+
if len(parts) != 3 ||
|
|
518
|
+
(parts[0] != "work" && parts[0] != "features") ||
|
|
519
|
+
!slugToken.MatchString(parts[1]) {
|
|
520
|
+
return false
|
|
521
|
+
}
|
|
522
|
+
switch parts[2] {
|
|
523
|
+
case "events.jsonl",
|
|
524
|
+
recoveryAttemptsFile,
|
|
525
|
+
".red",
|
|
526
|
+
"handoff.md",
|
|
527
|
+
".a1-guard.log",
|
|
528
|
+
".reviewer-ro.log",
|
|
529
|
+
".stop-gate.log",
|
|
530
|
+
".wright-scope.log":
|
|
531
|
+
return true
|
|
532
|
+
default:
|
|
533
|
+
return false
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
func withoutRootOwnedOperationalState(state []devritesStateEntry) []devritesStateEntry {
|
|
538
|
+
filtered := state[:0]
|
|
539
|
+
for _, item := range state {
|
|
540
|
+
if strings.HasPrefix(item.Fingerprint, "file:") &&
|
|
541
|
+
reconcileRootOwnedOperationalPath(item.Path) {
|
|
542
|
+
continue
|
|
543
|
+
}
|
|
544
|
+
filtered = append(filtered, item)
|
|
545
|
+
}
|
|
546
|
+
return filtered
|
|
547
|
+
}
|
|
548
|
+
|
|
486
549
|
func devritesFingerprint(filename string, entry os.DirEntry) (string, error) {
|
|
487
550
|
info, err := entry.Info()
|
|
488
551
|
if err != nil {
|
|
@@ -361,6 +361,158 @@ func TestReconcileRejectsDevritesMutation(t *testing.T) {
|
|
|
361
361
|
}
|
|
362
362
|
}
|
|
363
363
|
|
|
364
|
+
func TestReconcileAllowsRootOwnedOperationalFiles(t *testing.T) {
|
|
365
|
+
newGitRepo(t)
|
|
366
|
+
root := workspace(t, "feat")
|
|
367
|
+
dir := featureDir(root, "feat")
|
|
368
|
+
writeWrightAllowlist(t, root, "feat", "seed.go")
|
|
369
|
+
if code := Stuck(root, []string{"log", "feat", "dispatch", "SLICE-010"}, &bytes.Buffer{}, &bytes.Buffer{}); code != 0 {
|
|
370
|
+
t.Fatalf("stuck log = %d, want 0", code)
|
|
371
|
+
}
|
|
372
|
+
if code, out := runReconcile(t, root, "snapshot", "feat"); code != 0 {
|
|
373
|
+
t.Fatalf("snapshot = %d, want 0\n%s", code, out)
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
writeFile(t, filepath.Join(root, "timeline.jsonl"), "telemetry\n")
|
|
377
|
+
writeFile(t, filepath.Join(dir, "events.jsonl"), "telemetry\n")
|
|
378
|
+
writeFile(t, filepath.Join(dir, ".a1-guard.log"), "WOULD-BLOCK\n")
|
|
379
|
+
writeFile(t, filepath.Join(dir, ".reviewer-ro.log"), "WOULD-BLOCK\n")
|
|
380
|
+
writeFile(t, filepath.Join(dir, ".stop-gate.log"), "WOULD-BLOCK\n")
|
|
381
|
+
writeFile(t, filepath.Join(dir, ".wright-scope.log"), "WOULD-BLOCK\n")
|
|
382
|
+
writeFile(t, filepath.Join(dir, ".red"), "npm test\n")
|
|
383
|
+
writeFile(t, filepath.Join(dir, "handoff.md"), "## Handoff snapshot\n")
|
|
384
|
+
if code := RecoveryAttempts(
|
|
385
|
+
root,
|
|
386
|
+
[]string{"record", "--class", "proof_tool_defect", "reconcile telemetry", "changed logs", "feat"},
|
|
387
|
+
&bytes.Buffer{},
|
|
388
|
+
&bytes.Buffer{},
|
|
389
|
+
); code != 0 {
|
|
390
|
+
t.Fatalf("recovery record = %d, want 0", code)
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
if code, out := runReconcile(t, root, "check", "feat"); code != 0 {
|
|
394
|
+
t.Fatalf("check = %d, want 0\n%s", code, out)
|
|
395
|
+
}
|
|
396
|
+
if code, out := runReconcile(t, root, "snapshot", "feat"); code != 0 {
|
|
397
|
+
t.Fatalf("refresh = %d, want 0\n%s", code, out)
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
writeFile(t, filepath.Join(dir, ".unknown.log"), "not engine-owned\n")
|
|
401
|
+
code, out := runReconcile(t, root, "check", "feat")
|
|
402
|
+
if code != 5 {
|
|
403
|
+
t.Fatalf("check after canonical mutation = %d, want 5\n%s", code, out)
|
|
404
|
+
}
|
|
405
|
+
if !strings.Contains(out, ".devrites/work/feat/.unknown.log") {
|
|
406
|
+
t.Fatalf("canonical mutation missing from output:\n%s", out)
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
func TestReconcileAcceptsLegacyOperationalFingerprints(t *testing.T) {
|
|
411
|
+
newGitRepo(t)
|
|
412
|
+
root := workspace(t, "feat")
|
|
413
|
+
dir := featureDir(root, "feat")
|
|
414
|
+
writeWrightAllowlist(t, root, "feat", "seed.go")
|
|
415
|
+
writeFile(t, filepath.Join(root, "timeline.jsonl"), "before\n")
|
|
416
|
+
writeFile(t, filepath.Join(dir, "events.jsonl"), "before\n")
|
|
417
|
+
writeFile(t, filepath.Join(dir, ".a1-guard.log"), "before\n")
|
|
418
|
+
writeFile(t, filepath.Join(dir, ".red"), "before\n")
|
|
419
|
+
writeFile(t, filepath.Join(dir, "handoff.md"), "before\n")
|
|
420
|
+
if code := RecoveryAttempts(
|
|
421
|
+
root,
|
|
422
|
+
[]string{"record", "--class", "proof_tool_defect", "reconcile telemetry", "first failure", "feat"},
|
|
423
|
+
&bytes.Buffer{},
|
|
424
|
+
&bytes.Buffer{},
|
|
425
|
+
); code != 0 {
|
|
426
|
+
t.Fatalf("initial recovery record = %d, want 0", code)
|
|
427
|
+
}
|
|
428
|
+
if code, out := runReconcile(t, root, "snapshot", "feat"); code != 0 {
|
|
429
|
+
t.Fatalf("snapshot = %d, want 0\n%s", code, out)
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
snapshotPath := filepath.Join(dir, reconcileDevritesName)
|
|
433
|
+
state, err := readDevritesState(snapshotPath)
|
|
434
|
+
if err != nil {
|
|
435
|
+
t.Fatal(err)
|
|
436
|
+
}
|
|
437
|
+
have := make(map[string]bool, len(state))
|
|
438
|
+
for _, item := range state {
|
|
439
|
+
have[item.Path] = true
|
|
440
|
+
}
|
|
441
|
+
for _, path := range []string{
|
|
442
|
+
"timeline.jsonl",
|
|
443
|
+
"work/feat/events.jsonl",
|
|
444
|
+
"work/feat/.a1-guard.log",
|
|
445
|
+
"work/feat/.red",
|
|
446
|
+
"work/feat/handoff.md",
|
|
447
|
+
"work/feat/recovery-attempts.jsonl",
|
|
448
|
+
} {
|
|
449
|
+
if !have[path] {
|
|
450
|
+
state = append(state, devritesStateEntry{Path: path, Fingerprint: "file:0600:legacy"})
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
if err := writeDevritesState(snapshotPath, state); err != nil {
|
|
454
|
+
t.Fatal(err)
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
writeFile(t, filepath.Join(root, "timeline.jsonl"), "after\n")
|
|
458
|
+
writeFile(t, filepath.Join(dir, "events.jsonl"), "after\n")
|
|
459
|
+
writeFile(t, filepath.Join(dir, ".a1-guard.log"), "after\n")
|
|
460
|
+
writeFile(t, filepath.Join(dir, ".red"), "after\n")
|
|
461
|
+
writeFile(t, filepath.Join(dir, "handoff.md"), "after\n")
|
|
462
|
+
if code := RecoveryAttempts(
|
|
463
|
+
root,
|
|
464
|
+
[]string{"record", "--class", "proof_tool_defect", "reconcile telemetry", "second failure", "feat"},
|
|
465
|
+
&bytes.Buffer{},
|
|
466
|
+
&bytes.Buffer{},
|
|
467
|
+
); code != 0 {
|
|
468
|
+
t.Fatalf("second recovery record = %d, want 0", code)
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
if code, out := runReconcile(t, root, "check", "feat"); code != 0 {
|
|
472
|
+
t.Fatalf("check = %d, want 0\n%s", code, out)
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
func TestReconcileRejectsCorruptRootOwnedRecoveryLedger(t *testing.T) {
|
|
477
|
+
newGitRepo(t)
|
|
478
|
+
root := workspace(t, "feat")
|
|
479
|
+
writeWrightAllowlist(t, root, "feat", "seed.go")
|
|
480
|
+
if code, out := runReconcile(t, root, "snapshot", "feat"); code != 0 {
|
|
481
|
+
t.Fatalf("snapshot = %d, want 0\n%s", code, out)
|
|
482
|
+
}
|
|
483
|
+
writeFile(t, filepath.Join(featureDir(root, "feat"), recoveryAttemptsFile), "{not-json}\n")
|
|
484
|
+
|
|
485
|
+
code, out := runReconcile(t, root, "check", "feat")
|
|
486
|
+
if code != 6 {
|
|
487
|
+
t.Fatalf("check = %d, want 6\n%s", code, out)
|
|
488
|
+
}
|
|
489
|
+
if !strings.Contains(out, "validate root-owned recovery ledger") {
|
|
490
|
+
t.Fatalf("corrupt recovery ledger missing from output:\n%s", out)
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
func TestReconcileRootOwnedOperationalPathsAreExact(t *testing.T) {
|
|
495
|
+
for _, path := range []string{
|
|
496
|
+
"timeline.jsonl",
|
|
497
|
+
"work/feat/.red",
|
|
498
|
+
"features/feat/events.jsonl",
|
|
499
|
+
} {
|
|
500
|
+
if !reconcileRootOwnedOperationalPath(path) {
|
|
501
|
+
t.Errorf("%q should be root-owned operational state", path)
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
for _, path := range []string{
|
|
505
|
+
"work/feat/.unknown.log",
|
|
506
|
+
"work/feat/action.log",
|
|
507
|
+
"work/feat/footprint.log",
|
|
508
|
+
"work/feat/state.md",
|
|
509
|
+
} {
|
|
510
|
+
if reconcileRootOwnedOperationalPath(path) {
|
|
511
|
+
t.Errorf("%q must remain fingerprinted", path)
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
364
516
|
func TestReconcileCheckFailsClosedWhenObjectDatabaseIsMissing(t *testing.T) {
|
|
365
517
|
newGitRepo(t)
|
|
366
518
|
root := workspace(t, "feat")
|
|
@@ -26,8 +26,8 @@ only the source-write lifecycle.
|
|
|
26
26
|
4. Immediately before a serial dispatch:
|
|
27
27
|
|
|
28
28
|
```bash
|
|
29
|
-
devrites-engine reconcile snapshot
|
|
30
29
|
devrites-engine stuck log "$(cat .devrites/ACTIVE 2>/dev/null)" dispatch "<slice id>"
|
|
30
|
+
devrites-engine reconcile snapshot
|
|
31
31
|
```
|
|
32
32
|
|
|
33
33
|
A Forge slice runs `forge plan` first, then takes this same snapshot; see
|
|
@@ -26,8 +26,8 @@ only the source-write lifecycle.
|
|
|
26
26
|
4. Immediately before a serial dispatch:
|
|
27
27
|
|
|
28
28
|
```bash
|
|
29
|
-
devrites-engine reconcile snapshot
|
|
30
29
|
devrites-engine stuck log "$(cat .devrites/ACTIVE 2>/dev/null)" dispatch "<slice id>"
|
|
30
|
+
devrites-engine reconcile snapshot
|
|
31
31
|
```
|
|
32
32
|
|
|
33
33
|
A Forge slice runs `forge plan` first, then takes this same snapshot; see
|
|
@@ -26,8 +26,8 @@ only the source-write lifecycle.
|
|
|
26
26
|
4. Immediately before a serial dispatch:
|
|
27
27
|
|
|
28
28
|
```bash
|
|
29
|
-
devrites-engine reconcile snapshot
|
|
30
29
|
devrites-engine stuck log "$(cat .devrites/ACTIVE 2>/dev/null)" dispatch "<slice id>"
|
|
30
|
+
devrites-engine reconcile snapshot
|
|
31
31
|
```
|
|
32
32
|
|
|
33
33
|
A Forge slice runs `forge plan` first, then takes this same snapshot; see
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "devrites",
|
|
3
|
-
"version": "3.2.
|
|
3
|
+
"version": "3.2.8",
|
|
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",
|