devrites 4.0.0 → 4.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  All notable changes to DevRites are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and DevRites adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). Releases are generated automatically by [semantic-release](https://semantic-release.gitbook.io/) from Conventional Commits on `main`.
4
4
 
5
+ ## [4.0.1](https://github.com/ViktorsBaikers/DevRites/compare/v4.0.0...v4.0.1) (2026-08-03)
6
+
7
+ ### Fixed
8
+
9
+ * **installer:** clean legacy Codex hooks during v4 updates ([4908945](https://github.com/ViktorsBaikers/DevRites/commit/49089453ade730a9fede6c9fade180457d8ce2f9))
10
+
5
11
  ## [4.0.0](https://github.com/ViktorsBaikers/DevRites/compare/v3.2.28...v4.0.0) (2026-08-03)
6
12
 
7
13
  ### ⚠ BREAKING CHANGES
package/README.md CHANGED
@@ -28,7 +28,7 @@ project-conventional push, tag, or PR action, and archive the workspace.
28
28
  Unattended runs may create local WIP checkpoint commits along the way, but they
29
29
  remain local unless Ship's disclosed plan includes an approved remote action.
30
30
 
31
- **Status:** [`v4.0.0`](https://github.com/ViktorsBaikers/DevRites/releases/tag/v4.0.0): see [`CHANGELOG.md`](CHANGELOG.md) for release notes.
31
+ **Status:** [`v4.0.1`](https://github.com/ViktorsBaikers/DevRites/releases/tag/v4.0.1): see [`CHANGELOG.md`](CHANGELOG.md) for release notes.
32
32
 
33
33
  This is the latest published release; `main` may contain unreleased work.
34
34
 
@@ -107,6 +107,11 @@ Some work needs a different route:
107
107
 
108
108
  The npm or shell updater acquires a local install candidate and uses
109
109
  `devrites-engine update` to refresh the installed engine and pack offline.
110
+ For a pre-v4 install, do not retry an older engine directly if it reports
111
+ `missing codex/hooks.json`. Run `npx devrites@latest update`, or verify the
112
+ release `install.sh` as shown below and run `bash ./install.sh update`. The v4
113
+ engine updates locally and offline; those adapters acquire its compatible
114
+ candidate first.
110
115
  `/rite-upgrade` is the separate native, preservation-first route for reconciling
111
116
  an unfinished workspace. It proves a current-contract defect before routing
112
117
  Clarify, Plan repair, Converge, Vet, Prove, Polish, Review, or Seal; it never
package/docs/cli.md CHANGED
@@ -46,6 +46,12 @@ the exact release candidate selected by the adapter without changing the
46
46
  installation. Use `npx devrites uninstall --keep-binary` to remove managed host
47
47
  artifacts while retaining the shared `devrites-engine` binary.
48
48
 
49
+ For a pre-v4 install, an older direct `devrites-engine update` can report
50
+ `missing codex/hooks.json`; do not retry that command. Run
51
+ `npx devrites@latest update`, or use a checksum-verified release `install.sh` and
52
+ run `bash ./install.sh update`. The v4 engine update is local and offline; the
53
+ npm or shell adapter acquires the compatible candidate before invoking it.
54
+
49
55
  ## Checks
50
56
 
51
57
  - `check candidate <slug>` validates the strict `touched-files.md` manifest and
@@ -93,6 +93,12 @@ var CodexConfigMerge = MarkerMerge{
93
93
  MarkerText: ".codex/config.toml contains the DevRites read-only-root permission profile.",
94
94
  }
95
95
 
96
+ var LegacyCodexHooksMerge = ManagedMerge{
97
+ MarkerRel: ".claude/devrites.codex-hooks-merge",
98
+ TargetRel: ".codex/hooks.json",
99
+ DryRun: ".codex/hooks.json legacy DevRites hooks",
100
+ }
101
+
96
102
  var managedMerges = []ManagedMerge{
97
103
  {
98
104
  MarkerRel: CodexAgentsMerge.MarkerRel,
@@ -113,6 +119,7 @@ var managedMerges = []ManagedMerge{
113
119
  TargetRel: ClaudeSettingsMerge.TargetRel,
114
120
  DryRun: ".claude/settings.json DevRites permissions",
115
121
  },
122
+ LegacyCodexHooksMerge,
116
123
  }
117
124
 
118
125
  func RequiredPayload(withCodex bool) []string {
@@ -925,7 +925,11 @@ func (r *runner) pruneDropped() error {
925
925
  if err := r.recheckPath(merge.TargetRel); err != nil {
926
926
  return err
927
927
  }
928
- if merge.TargetRel == hostpack.ClaudeSettingsMerge.TargetRel {
928
+ if merge.MarkerRel == hostpack.LegacyCodexHooksMerge.MarkerRel {
929
+ if err := stripHooksPath(filepath.Join(r.target, filepath.FromSlash(merge.TargetRel))); err != nil {
930
+ return fmt.Errorf("strip hooks from %s: %w", merge.TargetRel, err)
931
+ }
932
+ } else if merge.TargetRel == hostpack.ClaudeSettingsMerge.TargetRel {
929
933
  _ = r.stripClaudeSettings(filepath.Join(r.target, filepath.FromSlash(merge.TargetRel)), true)
930
934
  } else {
931
935
  _ = stripMarkerPath(filepath.Join(r.target, filepath.FromSlash(merge.TargetRel)), merge.Begin, merge.End)
@@ -1023,6 +1027,12 @@ func (r *runner) uninstall() error {
1023
1027
  if err := r.recheckPath(merge.TargetRel); err != nil {
1024
1028
  return err
1025
1029
  }
1030
+ if merge.MarkerRel == hostpack.LegacyCodexHooksMerge.MarkerRel {
1031
+ if err := stripHooksPath(filepath.Join(r.target, filepath.FromSlash(merge.TargetRel))); err != nil {
1032
+ return fmt.Errorf("strip hooks from %s: %w", merge.TargetRel, err)
1033
+ }
1034
+ continue
1035
+ }
1026
1036
  if merge.TargetRel == hostpack.ClaudeSettingsMerge.TargetRel {
1027
1037
  if err := r.stripClaudeSettings(filepath.Join(r.target, filepath.FromSlash(merge.TargetRel)), true); err != nil {
1028
1038
  return fmt.Errorf("strip DevRites settings from %s: %w", merge.TargetRel, err)
@@ -1092,6 +1102,26 @@ func (r *runner) uninstall() error {
1092
1102
  return nil
1093
1103
  }
1094
1104
 
1105
+ func stripHooksPath(path string) error {
1106
+ current, err := readJSON(path)
1107
+ if err != nil {
1108
+ if errors.Is(err, fs.ErrNotExist) {
1109
+ return nil
1110
+ }
1111
+ return fmt.Errorf("load hooks config: %w", err)
1112
+ }
1113
+ next := stripDevritesHooks(current)
1114
+ if len(next) == 0 {
1115
+ return os.Remove(path)
1116
+ }
1117
+ data, err := json.MarshalIndent(next, "", " ")
1118
+ if err != nil {
1119
+ return fmt.Errorf("encode hooks config: %w", err)
1120
+ }
1121
+ data = append(data, '\n')
1122
+ return fsutil.WriteFileAtomic(path, data, 0o644)
1123
+ }
1124
+
1095
1125
  func (r *runner) stripClaudeSettings(path string, preserveEmpty bool) error {
1096
1126
  current, err := readJSON(path)
1097
1127
  if err != nil {
@@ -1310,9 +1340,12 @@ func stripDevritesHooks(config map[string]any) map[string]any {
1310
1340
  }
1311
1341
  next[k] = v
1312
1342
  }
1313
- rawHooks, ok := next["hooks"].(map[string]any)
1343
+ rawHooksValue, exists := next["hooks"]
1344
+ if !exists {
1345
+ return next
1346
+ }
1347
+ rawHooks, ok := rawHooksValue.(map[string]any)
1314
1348
  if !ok {
1315
- delete(next, "hooks")
1316
1349
  return next
1317
1350
  }
1318
1351
  hooks := map[string]any{}
@@ -8,6 +8,7 @@ import (
8
8
  "os/exec"
9
9
  "path/filepath"
10
10
  "runtime"
11
+ "slices"
11
12
  "strings"
12
13
  "testing"
13
14
  "time"
@@ -551,6 +552,106 @@ func TestUpdateInstallsLocalPayload(t *testing.T) {
551
552
  }
552
553
  }
553
554
 
555
+ func TestUpdateCleansLegacyCodexHooks(t *testing.T) {
556
+ t.Setenv("DEVRITES_NO_BINARY", "1")
557
+ payload := testPayload(t)
558
+ source := testSource(t, "4.0.0")
559
+
560
+ t.Run("preserves user hooks", func(t *testing.T) {
561
+ target := t.TempDir()
562
+ hooks := `{
563
+ "$comment": "DevRites hooks for Codex. Project hooks load only after trust.",
564
+ "hooks": {"Stop":[{"hooks":[
565
+ {"type":"command","command":"devrites-engine hook stop-gate --harness=codex"},
566
+ {"type":"command","command":"echo user"}
567
+ ]}]}
568
+ }` + "\n"
569
+ seedLegacyCodexHooksInstall(t, target, hooks)
570
+
571
+ runLocalUpdate(t, target, source, payload)
572
+
573
+ if exists(filepath.Join(target, ".claude", "devrites.codex-hooks-merge")) {
574
+ t.Fatal("update kept the legacy Codex hooks marker")
575
+ }
576
+ got := testutil.ReadFile(t, filepath.Join(target, ".codex", "hooks.json"))
577
+ if strings.Contains(got, "devrites-engine hook") || strings.Contains(got, "DevRites hooks") || !strings.Contains(got, "echo user") {
578
+ t.Fatalf("legacy Codex hook cleanup mismatch:\n%s", got)
579
+ }
580
+ })
581
+
582
+ t.Run("removes empty hooks file", func(t *testing.T) {
583
+ target := t.TempDir()
584
+ hooks := `{"hooks":{"Stop":[{"hooks":[{"type":"command","command":"devrites-engine hook stop-gate --harness=codex"}]}]}}` + "\n"
585
+ seedLegacyCodexHooksInstall(t, target, hooks)
586
+
587
+ runLocalUpdate(t, target, source, payload)
588
+
589
+ if exists(filepath.Join(target, ".codex", "hooks.json")) {
590
+ t.Fatal("update kept an empty legacy Codex hooks file")
591
+ }
592
+ })
593
+
594
+ t.Run("preserves unrecognized user hook shape", func(t *testing.T) {
595
+ target := t.TempDir()
596
+ seedLegacyCodexHooksInstall(t, target, `{"hooks":"user-defined"}`+"\n")
597
+
598
+ runLocalUpdate(t, target, source, payload)
599
+
600
+ got := testutil.ReadFile(t, filepath.Join(target, ".codex", "hooks.json"))
601
+ if !strings.Contains(got, `"hooks": "user-defined"`) {
602
+ t.Fatalf("legacy cleanup removed unrecognized user hooks: %s", got)
603
+ }
604
+ })
605
+
606
+ t.Run("malformed hooks keep ownership marker", func(t *testing.T) {
607
+ target := t.TempDir()
608
+ seedLegacyCodexHooksInstall(t, target, "{\n")
609
+
610
+ err := applyUpdate(target, source, payload)
611
+ if err == nil || !strings.Contains(err.Error(), "load hooks config") {
612
+ t.Fatalf("update error = %v, want malformed legacy hooks error", err)
613
+ }
614
+ if !exists(filepath.Join(target, ".claude", "devrites.codex-hooks-merge")) {
615
+ t.Fatal("failed cleanup removed the legacy ownership marker")
616
+ }
617
+ if got := testutil.ReadFile(t, filepath.Join(target, ".codex", "hooks.json")); got != "{\n" {
618
+ t.Fatalf("failed cleanup changed hooks: %q", got)
619
+ }
620
+ })
621
+
622
+ t.Run("uninstall preserves user hooks", func(t *testing.T) {
623
+ target := t.TempDir()
624
+ hooks := `{"hooks":{"Stop":[{"hooks":[{"type":"command","command":"devrites-engine hook stop-gate"},{"type":"command","command":"echo user"}]}]}}` + "\n"
625
+ seedLegacyCodexHooksInstall(t, target, hooks)
626
+
627
+ runUninstall(t, target)
628
+
629
+ got := testutil.ReadFile(t, filepath.Join(target, ".codex", "hooks.json"))
630
+ if strings.Contains(got, "devrites-engine hook") || !strings.Contains(got, "echo user") {
631
+ t.Fatalf("legacy Codex hook uninstall mismatch:\n%s", got)
632
+ }
633
+ })
634
+ }
635
+
636
+ func TestLegacyCodexHooksMergeIsCleanupOnly(t *testing.T) {
637
+ legacy := hostpack.LegacyCodexHooksMerge
638
+ merge, ok := hostpack.ManagedMergeForMarker(legacy.MarkerRel)
639
+ if !ok || merge != legacy {
640
+ t.Fatalf("legacy marker lookup = %#v, %t", merge, ok)
641
+ }
642
+ if slices.Contains(hostpack.RequiredPayload(true), "codex/hooks.json") {
643
+ t.Fatal("legacy Codex hooks entered the required payload")
644
+ }
645
+ r := runner{opts: DefaultOptions(ModeInstall), payloadFS: os.DirFS(testPayload(t))}
646
+ desired, err := r.desiredInstallPaths()
647
+ if err != nil {
648
+ t.Fatal(err)
649
+ }
650
+ if desired[legacy.MarkerRel] || desired[legacy.TargetRel] || slices.Contains(r.installMergeTargets(), legacy.TargetRel) {
651
+ t.Fatalf("legacy Codex hooks entered active install paths: desired=%v merge-targets=%v", desired, r.installMergeTargets())
652
+ }
653
+ }
654
+
554
655
  func TestUpdateRejectsMissingGeneratedPayload(t *testing.T) {
555
656
  t.Setenv("DEVRITES_NO_BINARY", "1")
556
657
  oldPayload := testPayload(t)
@@ -939,6 +1040,33 @@ func applyInstall(target, payload string, force, dryRun bool, out *bytes.Buffer)
939
1040
  return Apply(opts)
940
1041
  }
941
1042
 
1043
+ func applyUpdate(target, source, payload string) error {
1044
+ opts := DefaultOptions(ModeUpdate)
1045
+ opts.Target = target
1046
+ opts.SourceDir = source
1047
+ opts.PayloadDir = payload
1048
+ opts.Stdout = &bytes.Buffer{}
1049
+ opts.Stderr = &bytes.Buffer{}
1050
+ return Apply(opts)
1051
+ }
1052
+
1053
+ func runLocalUpdate(t *testing.T, target, source, payload string) {
1054
+ t.Helper()
1055
+ if err := applyUpdate(target, source, payload); err != nil {
1056
+ t.Fatal(err)
1057
+ }
1058
+ }
1059
+
1060
+ func seedLegacyCodexHooksInstall(t *testing.T, target, hooks string) {
1061
+ t.Helper()
1062
+ markerRel := ".claude/devrites.codex-hooks-merge"
1063
+ marker := []byte(".codex/hooks.json contains DevRites managed hook entries.\n")
1064
+ testutil.WriteFile(t, filepath.Join(target, ".codex", "hooks.json"), hooks)
1065
+ testutil.WriteFile(t, filepath.Join(target, filepath.FromSlash(markerRel)), string(marker))
1066
+ testutil.WriteFile(t, filepath.Join(target, ManifestName), "# DevRites install manifest - do not edit by hand.\n# devrites-version: 3.2.27\n# devrites-flags: \n")
1067
+ addManifestRecord(t, filepath.Join(target, ManifestName), markerRel, marker)
1068
+ }
1069
+
942
1070
  func addManifestRecord(t *testing.T, manifest, rel string, data []byte) {
943
1071
  t.Helper()
944
1072
  hash := fmt.Sprintf("sha256:%x", sha256.Sum256(data))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "devrites",
3
- "version": "4.0.0",
3
+ "version": "4.0.1",
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",