Skip to main content
Kodelyth ECC
Changelog

Release history

Live-verified releases pulled from the kodelyth-ecc repo. Last 10 highlighted below, full history further down.

Latest 10 releases

v2.24.11 · every docs page gets its own diagram

October 2026

Documentation and site only. No code change.

The website renders a doc's og_image on the page now, not just in the social card, so the image a doc points at is something readers actually see above the content. Most docs pointed at shared generic cards — one card was doing duty for four unrelated pages — which is worse on-page than no image at all.

Seventeen of the eighteen docs now carry a purpose-built 1200x630 diagram that teaches their own subject: the Arena's GOD-vs-EVIL loop, swarm's fan-out into isolated worktrees, the real RTK token ledger, the eight hook events sized by how many entries each carries, evolve's propose-then-you-review gate, the MCP tool surface, and so on. Each gains hero_alt describing the diagram's data, so it is legible to a screen reader and in the social card. The index hub keeps the main product card, which is the right image for a landing page.

The diagrams live as static SVG in the website repo; this release carries the og_image / hero_alt frontmatter for the docs that ship from here.

693 tests across 44 files.

v2.24.10 · the same config-destroying read, now on settings.json

October 2026

Data-loss fix. 2.24.9 fixed a nullable JSON read coalesced to {} in register-self.js, which destroyed ~/.claude.json. Grepping the codebase for that pattern found it a second time, in register-hooks.js, on a file that matters more.

``js const settings = readJsonOrNull(settingsPath) || {}; // … const body = JSON.stringify({ ...settings, hooks: merged }, null, 2); `

Same shape, same consequence: a settings.json that merely failed to parse was indistinguishable from one that did not exist, so the merge wrote it back carrying only the hooks block. Measured on a realistic file: **477 bytes in, 106 bytes out** — losing permissions.allow and .deny, env, model, statusLine, and the user's *own* hook entries. Losing permissions silently re-prompts them for every tool; losing hooks silently stops their quality gates.

Worth recording how it survived review: the write here was already hardened to preserve the file's mode, with a comment explaining that an installer has no business changing a user config's permissions. Someone thought carefully about the write and not at all about the read.

What changed

readSettings now returns absent / present / unreadable, and an unreadable config returns status unreadable-settings with the parse reason, writing nothing. The existing readJsonOrNull is kept for ECC's own shipped hooks.json, where null on failure is correct — that is a file we ship, not one a user can hand-edit into a broken state.

A documented behaviour was deliberately reversed

A test existed for this case: *"a corrupt settings.json does not lose the hooks"*, asserting status ok with ECC's three hook events written. The intent — don't lose ECC's hooks over a broken config — sounds right until it is priced. The only way to write into a file that will not parse is to discard everything else in it.

ECC's hooks not installing is recoverable: fix the JSON, re-run. The user's permissions and env are not. The test is rewritten rather than deleted, asserting the opposite contract with the trade-off stated in the test body.

693 tests across 44 files.

Also: brand art pointed at the wrong site

Four ECC cards printed kodelyth.com — the parent company site — rather than ecc.kodelyth.com, where ECC actually lives. og-image, github-social-preview and fb-post-launch all advertise ECC, so anyone reading the card and typing the URL landed somewhere else. section-author` keeps the parent domain, since that one is about the company rather than the product.

v2.24.9 · a malformed Claude config was silently replaced on install

October 2026

**Data-loss fix. Upgrade before running an install on a machine whose ~/.claude.json may have been hand-edited.**

MCP self-registration read the user's Claude config, merged ECC's server entry in, and wrote it back:

``js const existing = readJson(file) || {}; const next = { ...existing, mcpServers: servers }; writeJson(file, next); `

readJson returned null for every failure. So a config that merely failed to parse — one trailing comma from a hand-edit, a half-written file, a bad mount — was indistinguishable from a config that did not exist, and || {} turned it into an empty object. The merge then wrote that back.

Measured on a realistic config with two other MCP servers, a projects block with history, and user settings: 366 bytes in, 140 bytes out. Every other MCP server, every project entry, the theme and autoUpdates — gone. No error, no warning, no backup.

This runs automatically during install. So npx kodelyth-ecc on a machine whose .claude.json had any syntax error destroyed the user's Claude configuration.

What changed

readConfig now returns one of three states instead of a nullable value:

| state | meaning | action | |---|---|---| | absent | missing, empty, or whitespace-only | safe to treat as {} and create | | present | parsed to an object | merge normally | | unreadable | exists but will not parse, or is not an object | refuse |

Refusing is the point: there is no safe way to merge into a file whose contents cannot be reconstructed. The file is left byte-identical and the error names it along with the parse failure. unregisterInFile applies the same rule on the way out — removing an entry from a file we cannot read means writing a file we cannot reconstruct.

Writes now go through safeFs.replaceFilePreservingMode: atomic via temp-and-rename, so a crash or a full disk partway through cannot truncate the config — the same data loss by a different route — and the file keeps its existing permissions rather than having them widened.

Two mistakes caught while fixing it

The first version of the fix called replaceFileAtomic without its mode argument, which threw on every path including valid configs. Testing the happy path, not just the bug, is what surfaced it; verifying only that the refusal worked would have shipped an installer that never registered anything.

The mode-preservation test then asserted mode === 0o600 and failed Windows CI with 438 !== 384. Windows has no POSIX permission bits, so chmod there is close to a no-op and node reports 0666` regardless. The property worth testing was never the literal value but that the write does not *change* the mode, which is true on every platform.

Eight tests, where this file previously had none: the three read states, preservation of unrelated config on a valid merge, byte-identical refusal across four kinds of malformed input, creation from absent and empty, idempotence, unregister's refusal, mode preservation, and no stray temp files.

690 tests across 44 files.

v2.24.8 · adversarial pass: swarm, dashboard, supply-chain

October 2026

Three defects found by attacking the remaining unprobed surfaces rather than reading them.

baseRef could reach git's argv as an option

Every spawn in the swarm orchestrator uses spawnSync's array form with no shell, so shell injection does not work — and that was tested, not assumed: every generated send-keys string was executed in a real shell with a payload that would touch a marker file, across sessionName, worker name, task, repoRoot and baseRef. No marker was ever created.

Array form does not stop argument injection, though. git parses its own argv, so in git worktree add -b a commit-ish beginning with - is read as an option: --git-dir=/elsewhere points git at another repository, --force changes the command's meaning. baseRef was the only value reaching argv without being slugified or path-resolved first — sessionName goes through slugify, branchName carries a literal prefix, worktreePath is absolute — and it is user-settable through --base-ref. Swarm configs get shared, so this is not purely self-inflicted.

Rejecting a leading - costs nothing: git check-ref-format forbids refs that start with one, so HEAD, main, v1.2.3 and origin/main all still work.

An 800-character excerpt read the entire file

The dashboard's safeReadExcerpt called readFileSync on the whole file and then sliced 800 characters off the front, so a fixed-size excerpt cost the full size of the file. Measured on a 300MB task.md: 196ms and +628MB RSS for 800 characters, scaling linearly — roughly twice the file, since the bytes land in a Buffer and again as a UTF-16 string.

The dashboard is single threaded, so that blocks every other request, and it runs three times per worker — task, handoff, status — across every session in .orchestration. Those files are written by swarm workers, so their size is not something the dashboard controls.

It now reads a bounded window: stat first, then max * 4 + 4 bytes. Four because UTF-8 encodes a character in at most four, so the window always holds at least max characters. That detail is load bearing — the truncation decision counts CHARACTERS, as it always did, so slicing a byte window would cut a multi-byte character in half. 300MB now costs 0ms and +0MB. Output was diffed against the old implementation across twelve cases including emoji on the boundary; all identical.

verify passed with a planted file

An EXTRA file — present on disk, absent from the manifest — was advisory: ok: true, exit code 0. Confirmed against the real CLI, where tampering and deletion correctly exit 1 but planting rules/common/zz-injected.md exits 0.

The human output did say ⚠ extra: 1 (advisory). Automation did not hear it: kodelyth-ecc verify && deploy passed with an unknown file in place — and in this product an extra .md under rules/, agents/ or commands/ is not inert data. Those directories are enumerated and loaded as instructions, so an injected rule file becomes an always-on rule in every future session. That is the payload the session-bundle traversal delivered in 2.24.5, reached a different way.

The default is deliberately unchanged. This is a toolkit people are meant to extend, and failing verify on extras would fail it forever for anyone who added their own agent — a check that always fails gets ignored. The choice is offered instead:

``bash kodelyth-ecc verify # extras advisory, exit 0 (unchanged) kodelyth-ecc verify --strict # extras fail the verdict, exit 1 `

The report now carries strict, so a --json consumer need not infer which mode produced the verdict.

What held up

scripts/evolve was already hardened, and its comment records the same traversal class found in replay along with how it was confirmed. Re-tested anyway: target path traversal blocked, legitimate paths written, no prototype pollution. Swarm worker names collapse through slugify rather than escaping the worktree root, and normalizeSeedPaths rejects ..`, absolute paths and embedded traversal — both now pinned by tests so a later change cannot quietly lose them.

682 tests across 43 files.

v2.24.7 · the brand render scripts could not run

October 2026

Developer tooling only. Nothing here ships in the npm package — brand/ is not in the files allowlist — but it is what kept the published artwork correct.

2.24.6 fixed brand assets that had drifted to v2.7.0 with counts seven releases stale. This fixes the reason they drifted: **both scripts meant to regenerate them were broken, and had been for months.**

brand/convert.js and brand/veltix-convert.js each required puppeteer-core, which is neither installed nor declared in package.json, so a clean checkout failed with module-not-found before rendering anything. Both also hardcoded /Applications/Google Chrome.app/..., so neither could have run on Linux or Windows even with the dependency present. .gitignore names brand/convert.js as the way to regenerate brand/*.png; it pointed at a script that crashed on line 9.

convert.js was wrong in two further ways. It referenced favicon.svg, which does not exist in that directory, so three of its eleven entries rendered nothing. And it omitted five assets that do have published PNGs — the ECC badge, the icon at both 192 and 512, and both ECC lockups, plus the legacy post. Its list is now derived from the files actually present.

What changed

Both now use rsvg-convert. That keeps ECC's no-runtime-dependency rule intact — adding puppeteer-core would pull a large dependency into a package that deliberately has none — and matches scripts/brand/export-assets.js, so the whole brand pipeline renders through one tool.

Sizes reduce to a target width. Every published PNG was checked against its SVG's viewBox and all twenty-one are exact aspect-preserving scales, with ratios from 2x to 6x, so letting rsvg derive the height reproduces each one without a second number that can drift from the artwork. Every output keeps its exact published dimensions, captured before and after.

The renderer substitution was verified rather than assumed: re-rendering a Chrome-produced export gave a byte-comparable file at identical dimensions, and the text-bearing assets were viewed after rendering. The old document.fonts.ready wait needs no counterpart — these SVGs ask for 'Space Grotesk', 'Inter', system-ui, which are font-family names resolved against installed fonts rather than @font-face webfonts, so rsvg resolves them the same way Chrome did.

One gate for all brand artwork

All four scripts now share a --check mode that exits non-zero when anything is stale:

``bash node brand/convert.js --check \ && node brand/veltix-convert.js --check \ && node scripts/brand/sync-asset-version.js --check \ && node scripts/brand/export-assets.js --check ``

This release is the first to run that pipeline end to end: bump, stamp the version into 46 SVGs across both repositories, then render 70 PNGs.

672 tests across 43 files.

v2.24.6 · brand assets advertised the wrong version and stale counts

October 2026

Assets and documentation only. No code changes to the toolkit.

What was wrong

Every social and brand SVG carries its version and headline counts as literal artwork, and nothing kept them in step with releases.

ECC's 23 SVGs said v2.24.1, four releases behind. The website's copies said v2.18.0 — seven behind — and the two sets had drifted apart, so the marketing images on the site advertised a different version from the ones in the README.

The counts were worse, and were nearly missed: grepping for the *correct* values (70 agents, 196 skills) found them and suggested the counts were fine. Rendering github-social-preview.png showed otherwise — 97 COMMANDS and 22+ HOOKS against an actual 103 and 44, on the flagship image used for the repository's social card. Fixed across 11 SVGs per repo: 97 to 103 commands, 22+/20+ to 44 hooks, 384 to 672 tests, 14 to 15 rules.

The PNG exports were worse still, at v2.7.0.

Root cause

.gitignore names node brand/convert.js as the way to regenerate the exports. That script covers only the brand/ lockups from a hardcoded list, never the 49 social/ exports — and it cannot run at all, because puppeteer-core is neither installed nor declared in package.json. The documented regeneration path was broken, and because the PNGs are gitignored build artifacts the drift was invisible.

What ships

Two scripts, so this cannot recur silently:

  • scripts/brand/sync-asset-version.js stamps the version from package.json
into every SVG. Takes --root so the website's copies are driven from the same source of truth, and --check exits non-zero when anything is stale.
  • scripts/brand/export-assets.js renders all 49 exports at 7680px, taking each
height from the SVG's own viewBox. --check compares mtimes and fails when a PNG is older than its source.

Counts are deliberately NOT stamped by script. They are laid out as artwork, and some numbers that look like catalog counts are not: "20+ rules stacked" counts accumulated lessons, and "4 agents" / "8 agents" describe how many a given parallel command fires. A blanket replace would rewrite those into nonsense, so each count fix here was read in context first and those were left alone.

Also

The README now asks for a star. The post-install CLI asked once; the README — which is also the npm package page, and the first thing most people see — never did.

672 tests across 43 files.

v2.24.5 · fix path traversal in session bundles

October 2026

Security fix. A session bundle is a portable, shareable artifact — that is the entire point of the format — so every string in one is untrusted input from whoever built it. validateBundle type-checked session and each worker slug and nothing else, and both are used to build filesystem paths:

targetDir = path.join(coordRoot, bundle.session) -> fs.rmSync(recursive, force) wdir = path.join(targetDir, w.slug) -> mkdirSync + 3x writeFileSync

Arbitrary file write

A slug of ../../fake-claude/rules/common/injected wrote task.md, handoff.md and status.md with bundle-chosen contents three levels outside the import target. Aimed at a real install that path is an always-on rule file, so a shared bundle plants standing instructions into every future session. The CLI printed imported bundle into: .../.orchestration/looks-normal and then printed the escaped path in its own inspect: hint.

Arbitrary recursive delete

A session of ../important-work with --overwrite reached fs.rmSync(dir, { recursive: true, force: true }) and recursively deleted an unrelated project directory. It printed success for that too.

Both were confirmed by running the real CLI against a sandboxed victim tree, not by reading the code.

The fix

session and every slug must now be a single path segment. exportBundle only ever emits those — session is a path.basename, each slug a readdirSync entry name that slugify constrains to [a-z0-9-]+ — so this rejects nothing a genuine bundle contains, which a real export/import round-trip confirms with identical trees. A containment check was added at the write site as a backstop for future callers; the comment states plainly that it guards no currently reachable vector, because importBundle always creates the directory it writes into.

672 tests across 43 files.

v2.24.4 · fix catastrophic backtracking in the prompt-injection guard

October 2026

Security fix. The guard that scans untrusted text for prompt injection could be hung indefinitely by the text it was scanning.

What was wrong

override-prior-instructions — the critical-severity pattern matching the single most common injection phrase — was written as

\s+ (group)? \s* (group)? \s*

Three whitespace quantifiers with optional groups between them. With both groups matching empty they sat adjacent, so a run of N spaces could be partitioned between them in O(N^2) ways, and every partition was retried once the trailing noun failed to match. On "ignore" + N spaces + "x":

| N | time | |---|---| | 2,000 | 1.5s | | 4,000 | 12.5s | | 8,000 | never observed to finish | | 19,000 | never observed to finish |

The guard caps input at 20,000 characters, so the cap does not help: the blow-up begins around N=2,000, well inside it. Confirmed end to end through the hook's own stdin path with the payload delivered in tool_response, which is where untrusted file and network content arrives.

Why it is more than a hang

This hook runs on every tool call and is fail-open by design. In the default warn mode the session stalls until the harness kills the hook, and no warning is ever emitted. In block mode it is worse: a guard that never returns never exits 2, so the critical pattern it exists to catch goes unblocked. The denial of service doubles as a bypass of the control itself.

The fix

Each optional segment now carries its own leading \s+ inside the group — (?:\s+X)? rather than \s+(X)?\s* — so a whitespace run has exactly one valid partition and there is nothing to backtrack through. The pattern now takes 4.8ms at the full 20,000-character cap, and stays linear beyond it: 3.9ms on a million spaces.

system-prompt-leak carried the same shape one group shallower, making it quadratic rather than catastrophic (~590ms at 32,000 spaces, ~200ms at the cap). Not a denial of service today, but the identical defect and one optional group away from becoming one, so it is rewritten the same way. The other ten patterns were swept with trigger words they actually match and are unaffected (4-5ms at the cap).

Detection is unchanged

Verified rather than assumed. Both rewrites were checked against every phrase their grammars generate — 1,140 for the first, 483 for the second — and agree with the originals on all 1,623, with no phrase matched by one and not the other.

New tests cover the detections that must still fire, the ordinary text that must not, and the timing. The timing test runs in a child process with a hard timeout on purpose: a catastrophic regex is synchronous and CPU-bound, so node:test's own timeout cannot interrupt it — against the old pattern the assertion was never reached and the suite simply hung, which is a red build with no explanation. The child turns the same regression into a named failure. Verified in both directions: the old regex is killed at 15s with ETIMEDOUT, the new one finishes in 59ms.

668 tests across 43 files.

v2.24.3 · remove decorative emoji from docs and catalog

October 2026

Documentation only. No code, no behaviour change.

rules/common/coding-style.md bans decorative emoji and names the permitted status indicators: PASS/FAIL or the text characters ✓ (U+2713) and ✗ (U+2717). Three shipped files were not following it.

README comparison table

39 emoji across 11 rows replaced with ✓ / ✗.

This also fixed a polarity bug the emoji were hiding. In every row ❌ meant "lacks this", where lacking it is the bad outcome. In the telemetry row it meant "none", where none is the *good* outcome — so one glyph carried opposite meanings in the same column, and ECC's ❌ sat beside a competitor's ❌ looking like agreement while reading as praise and criticism. A mechanical swap would have preserved that, so the row now states the value instead of a verdict:

| Telemetry | None | Varies | None | Varies |

Agent and command bodies

10 decorative ❌ prefixes removed from bullet lists in agents/chaos-engineer.md and commands/devil-mode.md. Dropped rather than swapped for ✗: both lists sit under headings that already state the negation — "What You DON'T Do" and "When NOT to Use" — so every bullet was marked negative twice.

These two files were also the upstream source of the emoji appearing on the website. ecc-web syncs agent bodies into src/lib/agents.json, so the site inherited them and could not be fixed there; a site-side edit would be overwritten by the next sync.

Deliberately left alone

rules/common/coding-style.md still contains ✅ 🔍 🎯 🚀 ❌. Every one sits in the Wrong column of the table teaching which emoji not to use. They are quoted specimens, not decoration — stripping them would empty the column and leave the rule unable to demonstrate its own point. Future emoji sweeps should skip that file.

The ← ↑ ↓ ⚙ in README.md are also kept. They sit inside terminal-output code blocks and reproduce what the CLI actually prints — the gear comes from scripts/cli/menu.js:243. Changing them would make the README stop matching the tool.

Catalog parsing verified unchanged: 70 agents, 103 commands, frontmatter intact. 665 tests across 43 files.

v2.24.2 · adversarial pass: path traversal in get_rule, unrecallable memories

October 2026

Security fix. Three defects found by attacking the memory store, MCP server and dashboard rather than reading them.

get_rule returned any markdown file on the machine

loadRule was the only catalog loader that built a filesystem path out of its argument. Every other loader — agents, skills, commands, bundles — enumerates a directory and matches by name, so a caller can only ever select something already listed. This one did path.join(PATHS.rules, name + '.md'), so:

get_rule("../../README") -> 51,297 bytes of ROOT/README.md get_rule("testing/../../../README") -> same, via nested traversal

The .md suffix was the only thing bounding the reach, which left every readable markdown file on the machine in scope.

This matters because the caller is not always the user. An MCP tool argument can originate in text the agent merely read — a dependency README, an issue comment, a fetched page — which made this an arbitrary-markdown-read primitive reachable by prompt injection. That is exactly the class ECC's own prompt-injection-hunter agent exists to catch.

Fixed with two guards that fail differently: a name pattern that rejects anything that is a path rather than a name before touching disk, and the existing resolveContained() from scripts/lib/safe-fs.js, which proves the target sits under rules/common once symlinks resolve. The pattern alone is not enough — escape-probe is a valid rule name, and a symlink under that name reads 51KB.

loadRule had no test coverage at all, which is why this survived. It now has four tests, including the symlink case.

One memory was mathematically unrecallable

search() defaulted to an absolute minScore of 0.5 against unnormalised BM25 scores. BM25 IDF is log(1 + (N - df + 0.5) / (df + 0.5)), so a term appearing in every memory drives IDF toward zero and can never clear a fixed floor at any corpus size. The floor is now relative to the top hit, so it scales with the corpus instead of fighting it. An explicitly passed minScore is still honoured exactly. recall_memory passes none, so it was using the broken default.

Malformed request URIs returned 500 and wrote to stderr

decodeURIComponent throws on /%, /%zz or a truncated /%e0%a4. The dashboard did not guard it, so a malformed *client* URI became a 500 "internal server error" plus one stderr line per request — meaning any local process could spam the terminal the dashboard runs in. A path that cannot be decoded names no file, so it is now a 404 like any other miss.

What resisted

Worth recording, since the absence of findings was tested rather than assumed. Dashboard traversal (plain, encoded, double-encoded, backslash, absolute, null-byte) all returned 404. Host validation rejects absent, empty and foreign Host headers. Non-GET is 405. Stored XSS was tested in a real browser: a memory captured through the real store with breakout payloads in problem, approach, tags, language and project rendered as visible text, setting no global, creating no element and no attribute, with the breakout still inside its

.

665 tests across 43 files.