kodelyth-ecc 2.13.0 → 2.15.0

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,120 @@
2
2
 
3
3
  All notable changes to Kodelyth ECC are documented here.
4
4
 
5
+ ## v2.15.0 — Green CI, and the agents learn what the arena found (September 2026)
6
+
7
+ ### Fixed — CI had been red since v2.8.0
8
+
9
+ Eight tests asserted POSIX file semantics on the Windows runner. Every release
10
+ from 2.8.0 through 2.14.0 shipped with a failing badge, because `npm test` was
11
+ only ever run on macOS — a green local run beside a red CI is exactly the trap
12
+ this release also teaches `release-captain` to catch.
13
+
14
+ On Windows `chmod` only toggles the read-only bit, `statSync().mode` is
15
+ synthesized rather than real, `umask` is meaningless, and `symlinkSync` needs
16
+ administrator rights or Developer Mode. The eight tests assert precisely those
17
+ semantics.
18
+
19
+ They are **skipped there with a stated reason, not deleted** — the behaviour they
20
+ guard (mode preservation, umask independence, `O_EXCL` against planted symlinks)
21
+ is real on Linux and macOS, which is where CI proves it.
22
+
23
+ ```
24
+ tests/lib/safe-fs.test.js 5 guarded
25
+ tests/terse/compress.test.js 3 guarded
26
+ ```
27
+
28
+ All nine CI jobs now pass: Node 18/20/22 across Linux, macOS, and Windows.
29
+
30
+ ### Changed — three agents learned from three arena runs
31
+
32
+ The arena confirmed the same defects repeatedly, and none of the 70 agents knew
33
+ to look for them. Each addition below is a bug that was actually reproduced, not
34
+ a hypothetical.
35
+
36
+ **`security-reviewer`** gained three hunt blocks:
37
+
38
+ - **Lexical-only path containment** — `path.join`/`resolve` normalise `..` but do
39
+ not resolve symlinks, so a link inside the root passes a `startsWith` check
40
+ while pointing anywhere on disk. Confirmed four times across three files.
41
+ - **Prototype keys as map keys** — `map['constructor']` returns a truthy
42
+ function, so `if (!map[k])` never fires. Needs no attacker: one document
43
+ containing the word "constructor" bricked the memory store.
44
+ - **Truncate-then-write on persistent state** — `writeFileSync` opens with `'w'`.
45
+ A 6.3 MB store was measured at 0 bytes mid-rewrite, 32 torn reads in 1423
46
+ samples.
47
+
48
+ **`code-reviewer`** gained the same three as checklist items under Security.
49
+
50
+ **`release-captain`** gained **Phase 1.5 — Prove the build is green where it
51
+ actually runs**, with the `gh run list` / `gh run view --log-failed` commands and
52
+ a table of one-platform traps (POSIX modes, umask, symlinks, path separators,
53
+ BSD vs GNU flags). It states plainly: never cut a release on a red CI, and never
54
+ report "all tests passing" when only your own platform is passing.
55
+
56
+ ### Fixed — repository metadata
57
+
58
+ The GitHub About sidebar still advertised `194 skills · 97 commands`. Corrected
59
+ to 196 / 102, and the arena added to the description.
60
+
61
+ **572 tests passing on every supported platform.**
62
+
63
+ ## v2.14.0 — A false recurring class, and five more atomic writes (August 2026)
64
+
65
+ ### Fixed — the guard proposal was pointing at the wrong thing
66
+
67
+ After three arena runs the classifier reported **`resource-exhaustion` ×5** as the
68
+ top recurring class. Four of those five were not resource exhaustion at all:
69
+
70
+ ```
71
+ resource-exhaustion <- Prototype-key collision crashes indexing
72
+ resource-exhaustion <- A crash mid-append fuses the next memory into the torn row
73
+ resource-exhaustion <- A patch row surfaced as a phantom memory
74
+ ```
75
+
76
+ The pattern contained the bare word `memory`, which matches every finding about
77
+ the memory *store*. Acting on it would have meant building a guard nobody needed
78
+ — exactly the waste the proposal mechanism exists to prevent. This is the second
79
+ generic-word collision in this classifier; `permission` was the first.
80
+
81
+ `resource-exhaustion` now requires an actual exhaustion signal (`heap`, `rss`,
82
+ `oom`, `memory leak/usage/growth`, `allocates N`). A new **`data-integrity`**
83
+ class covers the double-index / desync / phantom-row / torn-row family, and
84
+ prototype-key findings now classify as `input-validation`. The 5 mis-tagged
85
+ memories already in the store were re-tagged.
86
+
87
+ The corrected picture across three runs:
88
+
89
+ | class | count | status |
90
+ |---|---|---|
91
+ | `filesystem-symlink` | 4 | already guarded by `scripts/lib/safe-fs.js` |
92
+ | `data-integrity` | 4 | all four in one file, all fixed |
93
+ | `input-validation` | 2 | fixed |
94
+ | everything else | 1 each | — |
95
+
96
+ **No new abstraction was built for `data-integrity`.** All four members live in
97
+ `store.js` and are already fixed, and no other subsystem keeps derived state with
98
+ the same drift — so a shared guard would have been speculative.
99
+
100
+ ### Added — `replaceFilePreservingMode`, applied to five real writers
101
+
102
+ What the sweep *did* find is the durability pattern in files holding state worth
103
+ keeping. `fs.writeFileSync` opens with `'w'`, truncating to zero before writing,
104
+ so a crash or a full disk part-way through leaves a truncated file and no copy of
105
+ the original:
106
+
107
+ - `scripts/codex/merge-mcp-config.js` — the user's **Codex IDE config**
108
+ - `scripts/codex/merge-codex-config.js` — the user's **Codex IDE config**
109
+ - `scripts/evolve/stats.js` — accumulated reuse and routing-miss stats
110
+ - `scripts/mcp/client.js` — the MCP server registry
111
+ - `scripts/memory/store.js` — the BM25 index
112
+
113
+ All five now write through `safeFs.replaceFilePreservingMode`, which keeps the
114
+ file's existing permissions and renames atomically. A crash leaves the original
115
+ completely untouched.
116
+
117
+ **572 tests passing**, up from 569.
118
+
5
119
  ## v2.13.0 — Arena run #3: eight bugs in the memory store (August 2026)
6
120
 
7
121
  Pointed the arena at `scripts/memory` — the persistent BM25 store every other
package/CLAUDE.md CHANGED
@@ -26,7 +26,7 @@ scripts/ → Node.js utilities: MCP server, dashboard, swarm, replay, router
26
26
  bundles/ → 3 power bundles (indie-hacker, red-team, enterprise)
27
27
  actions/ → GitHub Action (CI/CD integration for PR review)
28
28
  docs/ → Feature docs (arena.md, mcp.md, dashboard.md, swarm.md, replay.md, evolve.md, supply-chain.md)
29
- tests/ → 569 passing tests across 30 test files
29
+ tests/ → 572 passing tests across 30 test files
30
30
  ```
31
31
 
32
32
  ## Running Tests
package/README.md CHANGED
@@ -72,7 +72,7 @@ You never typed `use debug-detective`. You didn't have to. The toolkit read the
72
72
  |---|---|---|
73
73
  | **Intent routing** | Plain-language → right specialist via 10-tier priority rules | Mostly missing — you memorize names |
74
74
  | **70 agents** | Specialists with playbooks, severity calibration, real commands | Often persona-only ("you are a senior engineer...") |
75
- | **194 skills** | Domain knowledge files agents read on demand | Rarely separated from agents |
75
+ | **196 skills** | Domain knowledge files agents read on demand | Rarely separated from agents |
76
76
  | **102 commands** | Slash workflows (`/tdd`, `/arena`, `/devil-mode`, `/team-review`) | Limited or none |
77
77
  | **8 parallel commands** | Fire 3-8 agents simultaneously, aggregate results | Rare |
78
78
  | **Compound memory** | BM25 local recall + auto-inject + project lessons | Cloud-only or absent |
@@ -118,7 +118,7 @@ kodelythecc --target claude-code --codebase-graph
118
118
  That's it. This single flow:
119
119
 
120
120
  1. Installs both binaries (`kodelyth-ecc` and short-form `kodelythecc`) to your PATH
121
- 2. Copies 70 agents + 194 skills + 97 commands + 22 hooks + 14 rules into your AI tool's config dir
121
+ 2. Copies 70 agents + 196 skills + 102 commands + 22 hooks + 14 rules into your AI tool's config dir
122
122
  3. Auto-installs **RTK** binary and wires its PreToolUse hook (input compression starts on next AI restart)
123
123
  4. Installs **Terse mode** skill + `/terse` and `/terse-compress` slash commands (dormant — user types `/terse` to activate)
124
124
  5. Auto-installs **codebase-memory-mcp** and registers its MCP entries in your AI tool (with `--codebase-graph`)
@@ -220,7 +220,7 @@ npx kodelyth-ecc --bundle red-team # Security engineer — devil-mode + a
220
220
  npx kodelyth-ecc --bundle enterprise # Compliance / audit team — SBOM, license, supply chain
221
221
  ```
222
222
 
223
- Each bundle installs the full ECC toolkit (all 70 agents, 194 skills, 97 commands, 22+ hooks), adds a `BUNDLE.md` cheat sheet, and biases the AI toward audience-fit workflows on every session.
223
+ Each bundle installs the full ECC toolkit (all 70 agents, 196 skills, 102 commands, 22+ hooks), adds a `BUNDLE.md` cheat sheet, and biases the AI toward audience-fit workflows on every session.
224
224
 
225
225
  Combine with any target:
226
226
 
@@ -417,7 +417,7 @@ What's inside:
417
417
  | **Overview** | Agent count, memory stats, session count, recent activity |
418
418
  | **Memory** | Browse, search, and manage your local BM25 memory store |
419
419
  | **Evolve** | Self-improving memory — review AI-proposed refinements |
420
- | **Catalog** | Full searchable index of all 70 agents, 194 skills, 97 commands |
420
+ | **Catalog** | Full searchable index of all 70 agents, 196 skills, 102 commands |
421
421
  | **Sessions** | **Live IDE activity** (Claude Code, Windsurf, Windsurf-Next, Cursor, Antigravity) + orchestration/swarm sessions |
422
422
 
423
423
  Real-time:
@@ -888,7 +888,7 @@ The intent router will route you to the right one. The AI announces who's taking
888
888
  | Source | Destination | What it does |
889
889
  |---|---|---|
890
890
  | `agents/` | `~/.claude/agents/` | All 70 subagents available globally |
891
- | `skills/` | `~/.claude/skills/` | All 194 skills loadable via commands |
891
+ | `skills/` | `~/.claude/skills/` | All 196 skills loadable via commands |
892
892
  | `hooks/hooks.json` | `~/.claude/hooks/` | Automated quality gates |
893
893
  | `rules/` | `~/.claude/rules/` | Always-on standards + intent routing |
894
894
  | `commands/` | `~/.claude/commands/` | Slash commands (`/tdd`, `/plan`, etc.) |
package/VERSION CHANGED
@@ -1 +1 @@
1
- 2.13.0
1
+ 2.15.0
@@ -37,6 +37,16 @@ These MUST be flagged — they can cause real damage:
37
37
  - **SQL injection** — String concatenation in queries instead of parameterized queries
38
38
  - **XSS vulnerabilities** — Unescaped user input rendered in HTML/JSX
39
39
  - **Path traversal** — User-controlled file paths without sanitization
40
+ - **Lexical-only containment** — `startsWith(root)` after `path.join` catches `..` but NOT
41
+ symlinks: `path.resolve` does not resolve them, so a link inside the root passes the check
42
+ while pointing anywhere on disk. Demand `realpath` on both sides before the comparison.
43
+ - **Prototype keys as map keys** — `map['constructor']` returns a truthy function, so
44
+ `if (!map[k])` never fires and the next line reads a property off it. Any object keyed by
45
+ user text (tokens, tags, headers, filenames) must be `Object.create(null)`. Words like
46
+ "constructor" and "toString" are ordinary vocabulary — this needs no attacker.
47
+ - **Truncate-then-write on state worth keeping** — `fs.writeFileSync` opens with `'w'` and
48
+ truncates to zero before writing. A crash, a full disk, or a concurrent reader sees an
49
+ empty file. Config files, registries, ledgers and indexes need temp + rename.
40
50
  - **CSRF vulnerabilities** — State-changing endpoints without CSRF protection
41
51
  - **Authentication bypasses** — Missing auth checks on protected routes
42
52
  - **Insecure dependencies** — Known vulnerable packages
@@ -40,6 +40,31 @@ Read the diff since last tag. Classify each change:
40
40
 
41
41
  Be **strict** about MAJOR. Most teams under-call breaking changes and lose user trust.
42
42
 
43
+ ### Phase 1.5 — Prove the build is green where it actually runs
44
+
45
+ A local `npm test` proves the suite passes **on your machine**. It says nothing
46
+ about the other platforms CI covers, and a green local run beside a red badge is
47
+ how a project ships eight broken releases in a row without noticing.
48
+
49
+ ```bash
50
+ gh run list --workflow=CI --limit 5 # is the badge actually green?
51
+ gh run view <id> --log-failed # if not, what fails and on which OS?
52
+ ```
53
+
54
+ **Never cut a release on a red CI.** If the failure is platform-specific, fix or
55
+ explicitly guard it — do not delete the test, and do not tell the user "all tests
56
+ passing" when only your platform is passing.
57
+
58
+ Common one-platform traps:
59
+
60
+ | Assumption | Breaks on |
61
+ |---|---|
62
+ | `chmod` / `statSync().mode` carry POSIX bits | Windows — only the read-only bit exists |
63
+ | `process.umask()` is meaningful | Windows |
64
+ | `symlinkSync` just works | Windows — needs admin or Developer Mode |
65
+ | Paths use `/` | Windows — compare with `path.sep` |
66
+ | `timeout`, `stat -f`, GNU flags exist | macOS ships BSD variants; Windows ships neither |
67
+
43
68
  ### Phase 2 — Generate the changelog
44
69
 
45
70
  Group entries by category, in this order:
@@ -55,6 +55,30 @@ rg -n 'Math\.random\(\)' # non-CSPRNG f
55
55
 
56
56
  # ── Unsafe deserialization + prototype pollution (HIGH) ─────────────────────
57
57
  rg -n 'pickle\.loads|yaml\.load\(|Marshal\.load|JSON\.parse\([^)]*req\.|_\.merge\(\{\}|Object\.assign\(target' # yaml.load: confirm it lacks SafeLoader
58
+
59
+ # ── Lexical-only path containment (HIGH) ────────────────────────────────────
60
+ # path.join/resolve normalise ".." but do NOT resolve symlinks. A link sitting
61
+ # lexically inside the root passes a startsWith() check while its target is
62
+ # anywhere on disk, and the read follows it. Confirmed four times across three
63
+ # unrelated files in this codebase before the guard existed.
64
+ rg -n 'startsWith\(.*(?:ROOT|DIR|BASE|root|base|dir).*sep|startsWith\(.*\+ .?/.?\)' # then check: is there a realpathSync nearby?
65
+ rg -n 'readFileSync|createReadStream|readdirSync' --context 3 | rg -n 'path\.(join|resolve)' # read after a lexical check = the bug
66
+ # The fix is realpath on BOTH sides before comparing, or an existing helper.
67
+
68
+ # ── Prototype keys used as map keys (HIGH) ──────────────────────────────────
69
+ # map['constructor'] returns Object.prototype.constructor — TRUTHY — so an
70
+ # `if (!map[k])` guard never fires and the next line reads a property off a
71
+ # function. "constructor" and "toString" are ordinary vocabulary, so this needs
72
+ # no attacker: one document containing the word is enough.
73
+ rg -n 'if \(!\w+\[\w+\]\)|\w+\[\w+\] = \w+\[\w+\] \|\|' # any map keyed by user text
74
+ rg -n '= \{\};' --context 2 | rg -n 'token|term|word|tag|key|freq|count' # should be Object.create(null)
75
+
76
+ # ── Truncate-then-write on persistent state (HIGH) ──────────────────────────
77
+ # fs.writeFileSync opens with 'w', truncating to zero BEFORE writing. A crash,
78
+ # a full disk, or a concurrent reader sees an empty file. Measured: a 6.3 MB
79
+ # store observed at 0 bytes mid-rewrite, 32 torn reads in 1423 samples.
80
+ rg -n 'writeFileSync\(' | rg -v 'tmp|\.tmp|test' # then ask: does this file hold state worth keeping?
81
+ # Config files, registries, ledgers, and indexes need temp+rename, not writeFileSync.
58
82
  ```
59
83
 
60
84
  Report every confirmed hit with: file:line, severity, the exact fix, and (for secrets) "rotate immediately."
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kodelyth-ecc",
3
- "version": "2.13.0",
3
+ "version": "2.15.0",
4
4
  "description": "Production-grade AI coding toolkit — 70 agents (incl. devil-mode adversarial crew), 194 skills, 97 commands, parallel multi-agent commands, semantic intent routing, self-learning memory, and a built-in MCP server (16 tools / 6 prompts / 377 resources) that bridges to Claude Desktop, LangGraph, AutoGen, CrewAI, and OpenAI Agents SDK. Works with Claude Code, Windsurf, Cursor, Codex, Antigravity, OpenCode, Cline, RooCode, Aider, Kimi, and Gemini CLI.",
5
5
  "author": "Kodelyth <github.com/sifxprime>",
6
6
  "license": "MIT",
@@ -35,14 +35,15 @@ const CLASSES = [
35
35
  ['redos', /\bredos|backtrack|quadratic|catastrophic|O\(n\^?2\)|unanchored\b/i],
36
36
  ['path-traversal', /\btraversal|confinement|arbitrary (?:write|path)|escape the root\b/i],
37
37
  ['race-condition', /\btoctou|race condition|check.to.use\b/i],
38
- ['resource-exhaustion', /\bmemory|heap|rss|oom|amplification|exhaust\b/i],
38
+ ['resource-exhaustion', /\bheap\b|\brss\b|\boom\b|amplification|exhaust|out of memory|memory (?:leak|usage|growth|pressure)|allocates? ~?\d/i],
39
39
  ['missing-limit', /\bno (?:input )?(?:size )?cap|unbounded|no limit|missing limit\b/i],
40
40
  ['temp-file-handling', /\btemp(?:orary)? file|tmp file|leftover|predictable (?:name|filename)\b/i],
41
41
  ['prompt-injection', /\binjection|jailbreak|untrusted (?:text|content|input)|system.prompt leak|inert data|trust.boundary\b/i],
42
42
  ['secret-exposure', /\bsecret|credential|api key|token leak|password\b/i],
43
43
  ['semantic-corruption', /\bsemantic|meaning|threshold|negation|inverts?|widen/i],
44
44
  ['idempotency', /\bidempoten|fixed point|second run|re-?run\b/i],
45
- ['input-validation', /validat|wrong shape|silently accept|malformed|type ?error|unsanitiz/i],
45
+ ['data-integrity', /\bdouble-?index|desync|out of sync|phantom|corrupt|torn row|lost update|inconsisten|silently lost\b/i],
46
+ ['input-validation', /validat|wrong shape|silently accept|malformed|type ?error|unsanitiz|prototype[- ]key|__proto__|prototype pollution/i],
46
47
  ['access-control', /\bbypass(?:es|ed)? the|rebinding|host header|allowlist|authoriz|access control\b/i],
47
48
  ['supply-chain', /\btyposquat|lockfile|install script|dependency confusion\b/i],
48
49
  ];
@@ -229,6 +230,7 @@ const GUARD_ADVICE = {
229
230
  'secret-exposure': 'Add a secret scan to the pre-commit hook for this path.',
230
231
  'semantic-corruption': 'Add golden tests asserting that meaning-bearing tokens survive transformation.',
231
232
  'idempotency': 'Assert f(f(x)) === f(x) in the test suite for every transform.',
233
+ 'data-integrity': 'Derive the secondary structure from the primary one and add a cheap staleness check, so drift self-heals instead of persisting.',
232
234
  'input-validation': 'Validate argument shape at every public boundary and throw — never silently coerce to a plausible default.',
233
235
  'access-control': 'Deny by default: an allowlist of permitted values, with every absent or empty case treated as invalid rather than waved through.',
234
236
  'supply-chain': 'Pin and verify dependencies; add a lockfile-drift check to CI.',
@@ -13,6 +13,7 @@
13
13
 
14
14
  const fs = require('fs');
15
15
  const path = require('path');
16
+ const safeFs = require('../lib/safe-fs.js');
16
17
 
17
18
  let TOML;
18
19
  try {
@@ -310,7 +311,7 @@ function main() {
310
311
  return;
311
312
  }
312
313
 
313
- fs.writeFileSync(configPath, nextRaw, 'utf8');
314
+ safeFs.replaceFilePreservingMode(configPath, nextRaw);
314
315
  log('Done. Baseline Codex settings merged.');
315
316
  }
316
317
 
@@ -19,6 +19,7 @@
19
19
 
20
20
  const fs = require('fs');
21
21
  const path = require('path');
22
+ const safeFs = require('../lib/safe-fs.js');
22
23
  const { parseDisabledMcpServers } = require('../lib/mcp-config');
23
24
 
24
25
  let TOML;
@@ -319,7 +320,7 @@ function main() {
319
320
  if (updateMcp || hasRemovals) {
320
321
  for (const label of toRemoveLog) log(` [update] ${label}`);
321
322
  const cleaned = raw.replace(/\n+$/, '\n');
322
- fs.writeFileSync(configPath, cleaned + (toAppend.length > 0 ? appendText : ''), 'utf8');
323
+ safeFs.replaceFilePreservingMode(configPath, cleaned + (toAppend.length > 0 ? appendText : ''));
323
324
  } else {
324
325
  fs.appendFileSync(configPath, appendText, 'utf8');
325
326
  }
@@ -29,6 +29,7 @@ const fs = require('fs');
29
29
  const os = require('os');
30
30
  const path = require('path');
31
31
  const crypto = require('crypto');
32
+ const safeFs = require('../lib/safe-fs.js');
32
33
 
33
34
  const DEFAULT_DIR = process.env.KODELYTH_EVOLVE_DIR
34
35
  || path.join(os.homedir(), '.kodelythecc', 'evolve');
@@ -54,7 +55,7 @@ function safeReadJson(p, fallback) {
54
55
 
55
56
  function safeWriteJson(p, data) {
56
57
  try {
57
- fs.writeFileSync(p, JSON.stringify(data, null, 2));
58
+ safeFs.replaceFilePreservingMode(p, JSON.stringify(data, null, 2));
58
59
  return true;
59
60
  } catch { return false; }
60
61
  }
@@ -153,10 +153,29 @@ function replaceFileAtomic(absPath, contents, mode) {
153
153
  return absPath;
154
154
  }
155
155
 
156
+ /**
157
+ * Replace an existing file's contents atomically, keeping whatever permissions
158
+ * it already had (or `fallbackMode` when it does not exist yet).
159
+ *
160
+ * This is the safe replacement for `fs.writeFileSync(path, data)` on any file
161
+ * that holds state worth keeping — a user's IDE config, an accumulated stats
162
+ * file, a registry. `writeFileSync` opens with 'w', truncating to zero before
163
+ * writing, so a crash or a full disk part-way through leaves the user with a
164
+ * truncated file and no copy of the original anywhere.
165
+ */
166
+ function replaceFilePreservingMode(absPath, contents, fallbackMode = 0o644) {
167
+ let mode = fallbackMode;
168
+ try {
169
+ mode = fs.statSync(absPath).mode & 0o7777;
170
+ } catch { /* new file — use the fallback */ }
171
+ return replaceFileAtomic(absPath, contents, mode);
172
+ }
173
+
156
174
  module.exports = {
157
175
  resolveContained,
158
176
  statRegularFile,
159
177
  safeConfigDir,
160
178
  writeNewFile,
161
179
  replaceFileAtomic,
180
+ replaceFilePreservingMode,
162
181
  };
@@ -30,6 +30,7 @@
30
30
  const fs = require('fs');
31
31
  const os = require('os');
32
32
  const path = require('path');
33
+ const safeFs = require('../lib/safe-fs.js');
33
34
 
34
35
  const REGISTRY_DIR = process.env.KODELYTH_MCP_CLIENT_DIR
35
36
  || path.join(os.homedir(), '.kodelythecc');
@@ -54,7 +55,7 @@ function loadRegistry() {
54
55
 
55
56
  function saveRegistry(reg) {
56
57
  ensureDir(REGISTRY_DIR);
57
- fs.writeFileSync(REGISTRY_FILE, JSON.stringify(reg, null, 2) + '\n');
58
+ safeFs.replaceFilePreservingMode(REGISTRY_FILE, JSON.stringify(reg, null, 2) + '\n');
58
59
  }
59
60
 
60
61
  // ── Registry mutations ───────────────────────────────────────────────────────
@@ -18,6 +18,7 @@ const fs = require('fs');
18
18
  const os = require('os');
19
19
  const path = require('path');
20
20
  const crypto = require('crypto');
21
+ const safeFs = require('../lib/safe-fs.js');
21
22
 
22
23
  // Auto-migrate legacy ~/.kodelyth/ → ~/.kodelythecc/ before we touch any path.
23
24
  try { require('../migrate-legacy').main(); } catch { /* best-effort */ }
@@ -142,7 +143,7 @@ function logSize() {
142
143
 
143
144
  function saveIndex(index) {
144
145
  ensureDir(PATHS.dir);
145
- fs.writeFileSync(PATHS.index, JSON.stringify({ ...index, logSize: logSize() }, null, 2));
146
+ safeFs.replaceFilePreservingMode(PATHS.index, JSON.stringify({ ...index, logSize: logSize() }, null, 2));
146
147
  }
147
148
 
148
149
  // A patch row does not change any searchable text, so the index stays valid —