> For the complete documentation index, see [llms.txt](https://docs.thecolliery.org/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.thecolliery.org/tools/changelog.md).

# Changelog

All notable changes to CoalMine are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow SemVer (canonical version lives in `.claude-plugin/plugin.json`).

## \[3.21.1] - 2026-10-01

The probe behind the UNREADABLE line now keeps its path checks and its open in separate functions, and a user sees no difference.

### Security

* **CodeQL #74–#79 (`js/file-system-race`) — the probe behind the `UNREADABLE:` line is restructured so no path check sits in the same function as its open.** The six alerts are one site, `cfgRefusalReason` in `hooks/_shared/node-config.js`, synced into the three hooks and their `plugin/` copies; the function arrived in v3.21.0, so no earlier release has it. Its path checks (is the candidate a directory, a contained regular file, an unreadable entry) now live in `cfgPlacement`, and its one probe open lives in `cfgOpenVerdict`, which closes the handle unread; `cfgRefusalReason` itself now makes no file system call. The verdicts are those of v3.21.0, except one: a non-file swapped in after the path checks is no longer reported as `unreadable`. When the open is denied (`EACCES`, `EPERM`), `cfgOpenVerdict` asks `statSync(file).isFile()`, which follows a link, so a symlink inside the project that points at an unreadable regular file is still reported `unreadable`, and a FIFO swapped in after the path checks, directly or behind a link, stays silent. The probe still never reads the file. This is the shape `readRepoFileBounded` already has. Whether the alerts clear is shown only by the code-scanning list after the next push. — test: `scripts/lib/hooks.test.mjs` (`R12: an in-root symlinked config whose target is unreadable is still reported UNREADABLE (POSIX)`, `R12: a non-file swapped in between the path checks and the open is never reported UNREADABLE (mode-0 FIFO, POSIX)`)

**What you need to do:** nothing is required. To receive the changed hook files, on Claude Code run `claude plugin update coalmine@coalmine`; users of `coalmine@claude-community` receive them when that catalog's pin moves; for any other agent, update your CoalMine checkout and re-run `node scripts/install.mjs <agent>`.

## \[3.21.0] - 2026-10-01

Session start now reports a config it could not read, and the release workflow alone now creates Releases.

### Added

* **UMB-174 (b) — an `UNREADABLE:` line when a config exists but cannot be used.** The session-start conductor (on Claude Code, Antigravity and Gemini CLI alike) now reports, in the flock's wording, a config the walk selected that it could not read: `UNREADABLE: <path> exists but is not a readable config (<reason>); it was skipped — canonical = .claude/coal/coalmine.json`. The four reasons are `malformed JSON` · `a directory` · `unreadable` (EACCES, or EPERM from a Windows ACL) · `not a JSON object` (valid JSON that is not an object, R6 amendment 2). A leading U+FEFF is still stripped before the parse, so a BOM-prefixed config is read, not reported. Which config is used is unchanged: only the silence goes. A config the CWK-137 reader refuses (a link out of the project, over 1 MiB, a FIFO or device) stays silent. — test: `scripts/lib/hooks.test.mjs` (`UMB-174:` / `R6 AMENDMENT 2:` / `HEAD RULING (R8):`)
* **CWK-135 (a) — the global tier names its own path.** An unreadable `~/.claude/.coalmine.json` gets its own `UNREADABLE:` line whose canonical is `~/.claude/.coalmine.json`, never the project path, because a global config has no project location to move to. — test: `scripts/lib/hooks.test.mjs` (`CWK-135 (a):`)

### Changed

* **CWK-124 — the release workflow is now the sole creator of this repo's Releases, and it derives the title and body from the CHANGELOG entry.** `.github/workflows/claude-ai-zips.yml` is the flock's canon workflow (byte-identical to the `TheColliery/.github` overlay) and replaces the earlier one. On a stable `v*` tag it runs `verify.mjs`, stages the skill directories, zips each one, derives the Release title (`vX.Y.Z - <summary>`) and body from the top `CHANGELOG.md` entry with `scripts/release-notes.mjs`, creates the Release (or edits it, if a re-run finds one), attaches the ZIPs and `SHA256SUMS.txt`, then re-reads the Release and compares its title and body by SHA256. The top entry must be a dated `## [X.Y.Z]` heading that matches the tag and open with a one-line summary: an `[Unreleased]` heading, a version that does not match the tag, or a missing summary stops the run with a `ChangelogShapeError`. A tag with a hyphen (a pre-release) and a run against a branch get no Release and no ZIPs. The maintainer no longer posts a Release by hand. The asset set is unchanged (nine ZIPs and `SHA256SUMS.txt`, measured identical to the earlier workflow's). Seven new test files joined `scripts/test.mjs`. — test: `scripts/lib/release-shape.test.mjs` · `scripts/lib/release-prune.test.mjs` · `scripts/lib/asset-upload-mode.test.mjs` · `scripts/release-notes.test.mjs` · `scripts/verify-release-shape.test.mjs` · `scripts/decide-upload.test.mjs` · `scripts/prune-release-zips.test.mjs`

### Fixed

* **A FIFO planted where the in-place fallback writes no longer hangs the write.** The fallback's open is `O_WRONLY` on a path an attacker can swap for a FIFO, and that open blocks until a reader appears, before any check on the handle can run. It now opens with `O_NONBLOCK` as well (`O_WRONLY | O_NONBLOCK | O_NOFOLLOW`, each where the platform has it): a FIFO with no reader fails at the open with `ENXIO` and the original error is rethrown. `O_NONBLOCK` does not change writes to a regular file, and Windows has neither the flag nor FIFOs in a directory. — test: `scripts/lib/repo-fs.test.mjs` (`writeRepoFile EPERM fallback: a FIFO planted before the fallback opens fails fast, never hangs`)
* **CodeQL #70–#73 (`js/unused-local-variable`) — `MAX_DOC_BYTES` moved out of the shared config region into the conductor, its only reader**, so the stop and touch hooks no longer carry it. — test: `scripts/lib/repo-fs.test.mjs` (`the hooks carry the SAME two bounds as repo-fs.mjs`)

### Security

* **CodeQL #69 (`js/file-system-race`) — the write-side in-place fallback now checks the open handle, not the path.** When Windows refuses the rename over a file another process holds open, `writeRepoFile` falls back to an in-place write for a single-link regular file. In v3.20.2 that fallback checked the path first, so a link swapped in between the check and the open was followed. It now opens the target without truncating (`O_NOFOLLOW` where the platform has it), checks `isFile` and `nlink === 1` on that handle, then truncates and writes through it; a link already planted before the open (inside the refused rename) fails the open on POSIX. **Residual, named:** Windows has no `O_NOFOLLOW`, so there a symlink swapped in during that window is still followed; it must still resolve to a single-link regular file, and creating a symlink on Windows needs a privilege. — test: `scripts/lib/repo-fs.test.mjs` (`writeRepoFile EPERM fallback: a link planted BEFORE the fallback opens (inside the refused rename) is never followed`, and the swap-after-the-check test beside it)
* **CWK-133 + CWK-136 — every `git` the installer, the gates and the fixtures run strips the inherited `GIT_*` environment.** Inside a linked worktree, a git hook exports an absolute `GIT_DIR`; a `git` spawned with the inherited environment then acts on the repository that `GIT_DIR` names (measured: a fixture `git init` re-initialised it). Every `git` spawn under `scripts/` now goes through `gitEnv()`, which deletes the whole `GIT_*` family and pins `GIT_CEILING_DIRECTORIES`. `verify.mjs` gained a census over `scripts/**/*.mjs` that fails on a `spawn`, `spawnSync`, `execFile` or `execFileSync` of `git` with no `env:`, or one passing `process.env` without `gitEnv()`, and that refuses `git` run through a shell string (`exec` or `execSync`) whether or not `gitEnv()` is present. — test: `scripts/lib/git-env.test.mjs`, `scripts/lib/git-env-census.test.mjs`

**What you need to do:** nothing is required. If session start now prints an `UNREADABLE:` line, that config was already being skipped; fix or delete the file it names (see Configure in the README).

## \[3.20.2] - 2026-09-24

A link planted in a cloned repository can no longer crash the hooks or make install and configure write outside it.

### Security

A cloned repository is untrusted input, and three defects let one act on your machine through a planted symbolic link (a junction on Windows), FIFO or device file. **Every release from 1.0.0 (the first release, untagged: its heading below is dated 2026-06-09) through v3.20.1 is affected**; the range comes from a walk of versions (`plugin.json` history, these headings and the tags), not of tags alone. The per-defect first version and the full advisory are in [SECURITY.md](https://github.com/TheColliery/CoalMine/blob/main/SECURITY.md#-security-advisories). No CVE id is claimed; none exists. Found by a blind automated security review (2026-09-24).

* **CWK-137 (1 of 3) — the hooks read repo-derived paths with no bound.** A link to `/dev/zero` at `AGENTS.md`, `MEMORY.md`, `README.md`, a rules file or the project `coalmine.json` crashed the hook (`std::bad_alloc`, exit 134, measured on Linux under a 3 GB address-space cap); a FIFO at any of those paths, or a `.claude/rules` link to `/`, made the hook never return. The same unbounded read sat in `verify.mjs <target>` and its manifest check. Reads now go through `readRepoFileBounded`, in the three hooks (`hooks/_shared/node-config.js`, synced by `build-plugin.mjs`) and in `scripts/lib/repo-fs.mjs` for the CLIs. It does `lstat` first. A regular file proceeds. A symlink proceeds only when its `realpath.native` target lies inside the project root and is a regular file. A FIFO, device, socket, directory, or a link that escapes the project or dangles is skipped before `open`. The open uses `O_NONBLOCK` where the platform has it, the fd is re-checked (regular file, size), and a file over its bound is **skipped, never truncated**: `MAX_CONFIG_BYTES` = 1 MiB for configs, `MAX_DOC_BYTES` = 4 MiB for governance docs (measured: the largest real config is 9,114 B and the largest real doc is 216,465 B). The conductor's two rule-tree walks became one bounded walk, `forEachRuleDoc`, capped at `MAX_RULE_WALK_ENTRIES` = 5000 entries and `MAX_RULE_WALK_DEPTH` = 16 levels; an escaping `.claude/rules` root is not entered. The stop hook's language probe reads a 4096-byte prefix through the same helper. Your own home files (the global config, the mode switch, the update stamp) keep their symlinks, since dotfile managers link them, but still must be a regular file within the bound. **Behaviour that is now narrower, on purpose:** a project config or rule file that is a link out of the project, or larger than its bound, is ignored by the hooks. `verify.mjs <target>` reports such an installed file as `REFUSED` (distinct from `MISSING`) and such a `SKILL.md` as unreadable. The PowerShell fallback hooks carry the same check as `Test-CoalmineSafeFile` (`hooks/_shared/ps-config.ps1`), **stricter than Node by design**: PowerShell 5.1 has no `realpath.native`, so it refuses every reparse point on the file or on any directory between the file and the project root, even one that stays inside — test: `scripts/lib/repo-fs.test.mjs`, the `CWK-137:` tests in `scripts/lib/hooks.test.mjs` and `scripts/lib/ps-config.test.ps1`.
* **CWK-137 (2 of 3) — `install.mjs` wrote through a planted link.** With `.github/copilot-instructions.md` linked to `~/.bashrc`, `install.mjs copilot` appended CoalMine's rules block to the shell rc and reported success (measured on the newest 1.0.0 tree, v2.0.0 and v3.20.1). The same write-through applied to the platform rules file each target writes, an existing git hook or its `.pre-coalmine` backup slot, the default project config, the manifest, and a link on any of the nine project-level agent folders `install.mjs` writes into as of v3.20.1 (`.github`, `.agents`, `.claude`, `.gemini`, `.cursor`, `.windsurf`, `.junie`, `.kiro`, `.augment`) that carried the skills install and the default config outside the project. Writes now go through `writeRepoFile`: the nearest existing ancestor must resolve inside the project, the target must not be a link and must be a regular file, and the bytes go to a sibling temp opened with `wx` and are renamed into place, so a link planted after the check is replaced and never written through. A refusal is loud: `[refused] <path>: <reason>`, exit 1, nothing written. A hooks directory outside the worktree (a linked worktree's gitdir, an absolute `core.hooksPath`) is its own root, on the reasoning that git configuration chose it rather than a file the repo planted. That holds for a git clone, which carries neither `.git/config` nor a `.git` file, and not for a tree delivered as an archive, so it is a named residual below. Where Windows refuses the rename over a file another process holds open (`EPERM`/`EBUSY`/`EACCES`, e.g. re-installing the hooks from inside a running pre-commit), the write falls back to an in-place write only for a single-link regular file — test: `scripts/lib/repo-fs.test.mjs` and the `CWK-137:` tests in `scripts/lib/install.test.mjs`.
* **CWK-137 (3 of 3) — `configure.mjs` read, backed up and overwrote through a planted link.** With the project config linked to `~/.bashrc`, `configure.mjs` treated it as malformed, copied its bytes into a `.bak` inside the repository, then overwrote the link target (measured on v3.3.0 and v3.20.1: the rc file ended as `{ "language": "en" }` and the `.bak` held the original bytes). It now checks the read path and the write path with `checkRepoWriteTarget` before any read or backup, refuses with the path named (exit 1), reads through `readRepoFileBounded`, and writes the config and the `.bak` through `writeRepoFile`. **`configure.mjs --global` keeps its follow-through write** to `~/.claude/.coalmine.json`, because dotfile managers link that file; its read is bounded and regular-file only — test: `scripts/lib/repo-fs.test.mjs` and the `CWK-137:` tests in `scripts/lib/configure.test.mjs`.

Residuals, named: a regular file swapped in between the `lstat` and the `open` may lie outside the root (the fd re-check still holds the read to a bounded regular file); a link planted between a write's check and its rename is replaced, not followed, except on the single-link in-place fallback above, where a link swapped in between its `lstat` and its open is not caught. A tree delivered as an archive can carry a planted `.git/config` with an outside `core.hooksPath` (or a `.git` file naming an outside gitdir), and `install.mjs` will then replace git hooks in that directory; the bound is that the bytes are CoalMine's own fixed gate script, never attacker text, and an existing hook is first kept as `<hook>.pre-coalmine`. An agent's own file reads through its tools are the host's permission system, not covered here.

**What you need to do:** update. On Claude Code run `claude plugin update coalmine@coalmine`; users of `coalmine@claude-community` receive it when that catalog's pin moves; for any other agent, update your CoalMine checkout and re-run `node scripts/install.mjs <agent>`. If you ran CoalMine in a clone you did not write, the "What to check" list in the [advisory](https://github.com/TheColliery/CoalMine/blob/main/SECURITY.md#-security-advisories) says what to look for.

## \[3.20.1] - 2026-09-22

### Changed

* **CWK-121 (b) — the shipped `.claude-plugin/plugin.json`** **`homepage`/`repository` fields, `coalmine-conductor.js`'s self-error-report URL, `commands/update.md`'s latest-tag check, and the shared `escalation-footer.md`'s self-error-report URL (renders into all 9 canary `SKILL.md` bodies) still pointed at the pre-transfer `github.com/HetCreep/CoalMine` address (redirects, but the dist should carry the live one). All now read `github.com/TheColliery/CoalMine`. `plugin.json`'s `author.name` ("HetCreep") is left unchanged — it names the person, not the repo address.**

### Fixed

* **CWK-120 row 10 — `commands/update.md`'s latest-tag check could select an annotated tag's peeled `^{}` deref record instead of the release tag itself**, since `git ls-remote --tags | head -1` has no guarantee the plain and `^{}` lines for one tag sort adjacently. Filtered with `awk '!/\^\{\}$/ { print; exit }'` before selecting — test: none (a shell-pipeline correctness fix, no test harness covers `commands/*.md` prose).
* **CWK-120 row 5 — `rot-canary/SKILL.md`'s Fix-mode menu condition was self-contradictory**: it opened "in an interactive session" and then separately listed "no user is present" as a skip clause inside that already-interactive scope. Reworded to one non-overlapping condition, matching the shared `escalation-footer.md` Hook Context rule's own vocabulary — no behavior change, the menu still offers on any interactive session (manual or hook-nudged) and stays report-only when non-interactive — test: none (a legibility fix, no test harness reads SKILL.md prose for this condition).
* **CWK-120 row 6 — `scale-canary/SKILL.md`'s Fix-mode checkpoint instructed `git stash`/`git commit` as an ad-hoc backup**, which can hide (`stash`) or include (`commit`) unrelated staged/unstaged user work sitting in the same repo. Changed to: back up only the touched file(s), or use an isolated worktree — never `git stash`/`git commit` — test: none (a prose safety-instruction fix).
* **CWK-120 row 7 — `telemetry-canary/SKILL.md` and `testability-canary/SKILL.md`'s Fix-mode "auto-revert if newly red" had no baseline to compare against**, so the agent could not tell a post-edit failure was NEW versus already-failing before the edit. Both now record a build+test baseline before applying, and auto-revert only on a failure that is new versus that baseline — test: none (a prose safety-instruction fix).
* **CWK-120 row 23 — `rot-canary/references/tooling.md`'s Python row listed bare `python -W error` as a project check**, which runs no project code, tests, or static analysis (it starts the REPL in an interactive shell). Replaced with the project's own test command + `-W error` (e.g. `python -m pytest -W error`) — test: none (a reference-table content fix).
* **CWK-120 row 14 — the Antigravity auto-cadence status in `rot-canary/references/cadence.md` (shipped) cited only the 2026-07-12 pilot's live fire, omitting the 2026-08-04 isolated re-test that recorded ZERO fires on a real AG 2.0 install.** `platform-configs/hooks/antigravity-hooks.json`'s own `$comment` already discloses both measurements; the shipped cadence reference and the root README's `primed` definition and `platform-configs/hooks/README.md`'s AG row (neither ships into `plugin/`) now say the same: firing is UNRESOLVED, not verified, and a reader is told to probe their own copy before relying on it — test: none (an accuracy fix; no fabricated resolution of the contested fact).
* **CWK-120 rows 22/24 — four `coalmine: verified` reference stamps re-verified content-first, not merely re-dated** (`drift-canary/references/checks.md`, `gold-standard/references/method.md`, `telemetry-canary/references/checks.md`, `supply-chain-audit/references/tooling.md`, all `revalidate 90d`, all overdue since 2026-09-10): each file's content was re-read in full and confirmed still accurate before its stamp moved to `2026-09-22`. `skills/_shared/references/escalation.md` (`revalidate 30d`, overdue since 2026-08-22) is left EXPIRED and undisposed here — its per-platform Heavy-tier levers (Cursor Max Mode, Amp Oracle, GitHub Copilot `/fleet`, …) are exactly the fast-moving version-sensitive claims this room's own doctrine says need a live source-grounding pass, not a same-unit rubber-stamp; the file's own text already tells a reader to verify live rather than trust it. Routed upward as a pending decision — test: none (stamp-and-content maintenance).
* **CWK-120 SAME-BATCH CLASS SWEEP — the two overclaims rows 2/15 and row 6 fixed on one surface each stood uncorrected on their siblings, against this room's own MUST-class ONE FLOCK ONE COLOR rule (`AGENTS.md`, consequence (1): a fix is swept to every sibling surface IN THE SAME BATCH).** Re-derived both surface sets fresh by grep rather than trusting the prior unit's own count (which undercounted the second class by one). **The config-cascade "project wins per key" overclaim** — checked against `hooks/_shared/node-config.js`'s real clamp code, not restated by feel — corrected on `platform-configs/copilot-instructions.template`, `platform-configs/cursor.mdc.template`, `commands/stats.md`, `commands/update.md`, and the shared `skills/_shared/language-header.md` (renders into all 9 canary `SKILL.md` bodies — the highest-blast-radius instance of this class). **`skills/rot-canary/SKILL.md:38`'s own "project wins per key" is DIFFERENT and left alone**: it scopes to `autoFixMode` alone, which is genuinely unclamped (not one of the 6 `SAFER_ENUM`/ `UNION_ARRAY_KEYS` keys) — the claim is true as written for that one key, not the same overclaim. **The `git stash`/`git commit` checkpoint data-integrity hazard** — corrected on `drift-canary/SKILL.md`, `rot-canary/SKILL.md`, `telemetry-canary/SKILL.md`, and `testability-canary/SKILL.md` (4 siblings, one more than the prior unit's own estimate of 3 — `drift-canary` was the uncounted instance). Same correction text as the exemplar fix in both classes, no rewording en route — test: none (prose safety/precedence-instruction fixes, no test harness reads SKILL.md/command prose for this content).
* **CWK-120 FINDINGS-BACK — the class sweep above shipped a clamp correction that was ITSELF wrong, in the PERMISSIVE direction, on all 18 surfaces it touched.** The new text read "…can only quieten, never escalate, an explicit global…", implying the safety clamp does not bind when the global layer is unset. Measured against `hooks/_shared/node-config.js:290` (`const globalValue = globalVal !== undefined ? globalVal : def;`): an ABSENT global reads as the SCHEMA DEFAULT and the clamp still binds — with no global config at all, a project's `scanEverything: true` still resolves to `false`. `README.md:189` already states this correctly; the swept text disagreed with this repo's own README. Corrected on the same 7 source files (7 → 18 with their `plugin/` mirrors and shared-partial renders): "(a project can only quieten, never escalate; an absent global reads as the schema default and is clamped the same way)" — derived from `README.md:189` and the clamp code directly, not a third composed wording — test: none (prose correctness fix; `hooks/_shared/node-config.js` itself is untouched and its own test suite covers the clamp behavior this text now accurately describes).
* **CWK-120 FINDINGS-BACK — row 7's build+test-baseline fix had a third, unswept sibling set, and two files now contradicted THEMSELVES.** `telemetry-canary/SKILL.md` and `testability-canary/SKILL.md` had their Fix-mode bullet (`:26`) updated to require a baseline while their own grants table (`:34`, eight lines below) still read "auto-revert if newly red" — an agent reading the second half of the file got back the exact defect row 7 removed from the first half. Closed together with the unswept class: `drift-canary/SKILL.md`, `rot-canary/SKILL.md` (both its Fix-mode bullet and its standing-consent line), and `scale-canary/SKILL.md` all gained the same baseline-before-revert language, and all five files' grants tables now read "checkpoint → baseline → build+tests → auto-revert only on a NEW failure" — test: none (prose safety-instruction fix, matching row 7's own test disposition).
* **CWK-120 FINDINGS-BACK ROUND 2 — the round-1 baseline-class sweep (row 7 + its findings-back close) had a THIRD unswept form, a comma/space spelling neither grep pass matched: `gold-standard/SKILL.md` and `resilience-audit/SKILL.md` (2 sites) still read "checkpoint → \[fix] → build+tests → revert if newly red" with no baseline concept anywhere in either file.** Closed the same way as the rest of the class: both now record a build+test BASELINE before applying and revert only on a failure new versus it, in both the Fix-mode bullet and (for `resilience-audit`) its grants-table row. A form-independent sweep (`grep -rln "revert\|rollback\|undo" skills/*/SKILL.md`) confirms exactly these 7 files carry the class now (`drift-canary`, `gold-standard`, `resilience-audit`, `rot-canary`, `scale-canary`, `telemetry-canary`, `testability-canary`) and no eighth shape — `supply-chain-audit` is correctly outside the class (`checkpoint → apply → verify`, no build/test revert step at all) — test: none (prose safety-instruction fix, matching the rest of the class's own disposition).
* **CWK-120 FINDINGS-BACK ROUND 2 — the permissive-clamp correction (round 1's findings-back) read as EXHAUSTIVE, and a real, pre-existing, code-side gap sits behind that reading.** `node-config.js:293` (`if (gi === -1 || pi === -1) continue;`) lets an unrecognized project value escape the clamp entirely and win the plain merge — a value outside the enum, not merely a louder one inside it. The ship-text fix is TEXT-ONLY, per the reviewer's own explicit bound (the clamp's behavior is a shipped safety guard and is not changed here, unproven, at the tail of a five-commit unit): the same 18 surfaces now add "…among the clamp's own known values… — an unrecognized project value is not validated here", so the sentence no longer implies exhaustiveness it does not have. **Whether the clamp should fail closed on an unknown value is a CODE decision, named as next-touch, not settled by this unit** — test: none (prose scope-correction; the clamp code itself is untouched).

## \[3.20.0] - 2026-09-21

### Added

* **A project config written where the walk does not read it is now REPORTED, not silently ignored (UMB-133).** The session-start conductor checks a fixed, closed list of plausible wrong homes -- `<project>/.agents/.coalmine.json`, `<project>/.gemini/.coalmine.json`, `<project>/coal/coalmine.json` (agent dir dropped) and `<project>/.claude/coalmine.json` (`coal/` dropped) -- and, if one exists, adds one context line `IGNORED: <path> is not a config path; canonical = .claude/coal/coalmine.json` for the agent to relay (settings in that file have NO effect). It rides the channel each mode already has (Claude Code session context, Antigravity `injectSteps`, Gemini `additionalContext`) -- no new channel, nothing on stderr (Phoenix #13). The probe is `existsSync` on those fixed paths: no directory walk, and the text around each path is constant, so a cloned repo cannot steer what the line says. **HONEST BOUND: a config anywhere outside that list is not reported** -- this is a report of the likely typos, not a search of the project.
  * **Cost, stated:** a project still on a legacy path (see Deprecated) now carries one extra context line per session start until it migrates (roughly 40 tokens).

### Deprecated

* **Both legacy per-project config paths -- `<project>/.claude/.coalmine.json` and `<project>/.coalmine.json` (UMB-133).**
  * **Marker + replacement:** both are marked DEPRECATED in the README's Configure section, which names the canonical path `<project>/.claude/coal/coalmine.json` verbatim as the replacement. A canonical file always wins over both.
  * **Window:** deprecated at this MINOR, removable no earlier than the next MAJOR -- this series' own SemVer boundary (`scripts-quality.md` §3), not a calendar count. Until then both keep being read, exactly as before.
  * **Owner:** CoalMine. `node scripts/configure.mjs` moves either legacy file to the canonical path on its next write (nothing is moved on a mere read).
  * **Channel:** this entry and the README note. **No hook prints a deprecation warning** -- Phoenix #13 keeps hooks silent on stdout/stderr, so nothing appears in the terminal. The one runtime signal is the conductor's single migration-notice context line described under Added, sent only when the config actually read is a legacy one.

### Fixed

* **A project config at `<project>/.claude/.coalmine.json` was silently ignored (UMB-133).** It was never a candidate in the per-project read order -- only the root dotfile was honoured as legacy -- so a config written where a user reasonably expects it had no effect and nothing said so. The order is now canonical (own agent dir, then `.agents`, then `.gemini`), then `<project>/.claude/.coalmine.json`, then `<project>/.coalmine.json`; first found wins, and the merge, the safer-value clamp and the global layer are untouched. `configure.mjs` and `install.mjs` honour both shapes too (a writer blind to the nested one would have written a fresh canonical file that shadowed it and silently dropped every setting in it).
  * **A guard the change needed:** when the project root IS the home directory, `<root>/.claude/.coalmine.json` is the GLOBAL config. It is compared by identity (both sides through `realpathSync.native`) and never treated as a project config, so the walk does not anchor at `~` and `configure.mjs` does not migrate -- move and delete -- the file the hooks read as the global layer.
  * **The cascade wording on eight agent-instruction surfaces named only ONE legacy shape** -- the shared language header rendered into all nine skills, `rot-canary`'s fix-mode rail, `/coalmine:stats`, `/coalmine:update` and the four `platform-configs/*.template` files -- so an agent following it would have skipped a config the hook reads. All eight now name both shapes, in order.
  * **PowerShell fallback: not ported, and the gap is named** (`alt/powershell/README.md`): the twins still read only `<gitroot>/.coalmine.json`, so the second legacy shape is one more config they do not see.
* **On the Antigravity and Gemini adapters the conductor read the project config from the hook process's own working directory, not from the workspace it was reporting on (UMB-133).** The workspace is named by the hook's stdin payload (Antigravity: `workspacePaths[0]`, falling back to the payload's `cwd`; Gemini: the payload's `cwd`). The conductor now reads that workspace's project config, and **the config gates follow it** -- `enableConductor`, `disabledCanaries`, `updateMode` -- not only the new migration / `IGNORED` lines. Before, the gates and the config both came from wherever the hook process happened to start, while the lines beside them were computed for the workspace, so a workspace's own config had no effect when the two differed. **User-visible:** on those two adapters, a workspace whose config sets `enableConductor: false`, a `disabledCanaries` list or an `updateMode` now takes effect where it silently did not; and a config sitting only in the hook process's start directory no longer governs a different workspace.
  * **Unchanged:** Claude Code and the file-copy platforms (they read from the process directory as before), a payload that names no workspace (falls back to the process directory), and `rot-canary`'s own hooks (they still read from the process directory).
  * **The safer-value clamp is unaffected:** it runs inside the config merge for whatever directory is read, so a workspace config can quieten `updateMode` but never escalate it past the global layer.
* *A Coal uninstall could delete a repo's own TRACKED hook files (CWK-096).*\* `uninstallGitHooks()` resolves `core.hooksPath` (correct since `d1c917f`) but then unlinked whatever it found there with no tracked-ness check -- in any repo whose `core.hooksPath` points at a versioned directory (this room's own `.githooks/` included), that deleted the repo maintainer's tracked hooks. Tracked-ness is now asked of git (`git ls-files --error-unmatch`), never inferred: a confirmed `tracked` or an `unknown` (could-not-tell) answer REFUSES loudly and exits non-zero, naming the file and why; only a confirmed `untracked` answer deletes. The question is asked only when the resolved hooks dir sits INSIDE the worktree -- the ordinary `<gitDir>/hooks` case is untracked by construction and is unaffected. Distributed via the Universal Installer (`scripts/install.mjs`), a surface outside `plugin/` (Option B) -- same shipped-surface-outside-the-dist precedent as `d1c917f`.

## \[3.19.0] - 2026-09-10

### Security

* **Shell injection in the CWK-058 classifier shape, which this room authored and six rooms copied (CWK-074).** A `${{ }}` expression inside a `run:` block is TEXT-SUBSTITUTED by Actions before bash parses the line, and the value here is a list of changed FILENAMES -- attacker-controlled by construction, since anyone can add a file in a pull request. **Proven by execution, not reasoning**, against the shipped lines: `x$(cmd).md`, an equivalent backtick payload, and a double-quote breakout all RAN; after routing the value through `env:` (an environment variable is data and is never re-parsed as script text) all four payloads went inert.
  * **CORRECTION to the reported payload:** the single-quote form does NOT fire here, because both sites are inside a DOUBLE-quoted `echo`. The live vectors are `"`, `` ` `` and `$(`. **And `git` does not escape any of them** -- measured with plumbing: `git diff --name-only` emits `x$(touch pwned).md` verbatim, so no quoting behaviour stands between a PR and the payload.
* **A THIRD site, and it is not an interpolation at all: `emit()`'s FIXED heredoc delimiter is forgeable BY A FILENAME.** `git diff --name-only` emits a file named `EOF` as a bare line, which terminates `files<<EOF` early, so the following lines are parsed as fresh `$GITHUB_OUTPUT` entries — and the runner does take the LATER key (`actions/runner` `FileCommandManager.cs`: `context.DeferredOutputs[pair.Key] = pair.Value;`).
  * **SEVERITY NARROWED after INSPECT, because the first wording outran its evidence.** It claimed the payload yields `code=false` → a skipped matrix → **all-green over unanalysed code**, "measured end to end". What was measured here is the FILE CONTENT; the runner's parse was not, and it is decisive: the same parser throws `Invalid format` on any line carrying neither `=` nor `<<`, and the described payload leaves two — `emit()`'s own trailing delimiter, written unconditionally, and any ordinary path. So `changes` FAILS and CWK-058's guard makes both required checks refuse. **The real outcome is a RED gate: denial of analysis, not a forged green.**
  * **The fix stands at full strength, because a THROW-FREE forgery is reachable:** every emitted line need only parse — a path containing `=` is a valid pair, and a file named `files<<EOF` consumes the orphaned delimiter. **The defect is the forgeable delimiter, not the one payload that exposed it.** Fixed with a random per-run delimiter; re-measured, the same hostile input leaves `code=true` and the filenames are captured intact as data.
  * **Why random is sufficient — the property is ORDERING, not ENTROPY.** The delimiter is generated at RUN TIME, after the attacker's filenames are already frozen in the commit, so they cannot name a file after a string that did not exist when they wrote it. `$RANDOM` not being a CSPRNG is irrelevant. Written down so nobody later "strengthens" a property that never came from entropy.
  * **`outputs.code` was mis-classified as a trusted literal and is now INTRINSICALLY safe.** A file named `code=x"; cmd; "` injects it through the same forgery, and `ci.yml`'s summary executed it — proven by execution. The random delimiter closes that transitively; passing the value through `env:` closes it intrinsically, so the site stays safe even if the delimiter defence is ever weakened.

### Fixed

* **The CodeQL summary claimed a SARIF upload it could not yet know about.** The honesty step runs FIRST, deliberately, so a reader sees it first -- which means it runs BEFORE `init`/`analyze` and cannot know their outcome, yet it asserted *"the SARIF was uploaded"*. It now states only that an analysis is REQUIRED, and a final step reports the analyze step's own recorded `outcome`. **`ci.yml` was audited for the same defect and does NOT have it:** `all-green` declares `needs: gate`, so `needs.gate.result` is a completed outcome, not a prediction.
* **`codeql.yml` and `scorecard.yml` stated opposite verdicts on cancelling a SARIF upload, with nothing explaining why.** Both now cross-reference each other and name the actual discriminator, which is NOT the SARIF: CodeQL publishes only to GitHub code scanning, where a missed upload leaves the prior analysis standing; Scorecard additionally posts to the OpenSSF API, a third-party endpoint where a cancelled publish can be partially applied. Same artifact, different blast radius.

### Fixed

* **Every push to this repo echoed `Bypassed rule violations ... 2 of 2 required status checks are expected` — on a non-bypass account a doc-only change would block FOREVER (CWK-058).** The branch ruleset requires `all-green` and `analyze (javascript)`; `paths-ignore` on those two workflows deliberately prevented them from firing on doc-only paths. Verified at source, not assumed: the doc-only push `0ccc3f1` produced **exactly one** check-run (`lint | success`) — neither required check existed. And GitHub's own docs give the mechanism: *"If a workflow is skipped due to path filtering ... then checks associated with that workflow will remain in a 'Pending' state. A pull request that requires those checks to be successful will be blocked from merging."*
  * **The filter MOVED from the workflow's `on:` to the job that does the work.** `ci.yml` and `codeql.yml` now always fire; a `changed-paths` job classifies the push, and the expensive work is `if:`-guarded. So each required check-run always EXISTS and always reports — no Pending, no bypass, and **no second workflow reporting a duplicate check name** (the alternative shape, which cannot express the true complement of `paths-ignore` and would have double-reported `all-green` on any mixed code+doc push).
  * **The skip path does not claim an analysis it never ran.** Both checks write a job SUMMARY — visible in the UI, not buried in a commit message — that states plainly which happened. The doc-only wording is explicit: *"NO ANALYSIS RAN -- this is not a clean bill of health ... Read it as 'there was nothing to analyze', never as 'the analysis passed'"*, and it lists the changed paths. What is analysed is **unchanged**: the classifier reproduces each workflow's former `paths-ignore` set exactly, and the two sets stay deliberately different (a SHIPPED `SKILL.md` still runs CI, because `verify.mjs` checks dist-sync; CodeQL still ignores all markdown, because it analyses js/ts).
  * **A classifier that FAILS can no longer produce a green required check.** Found re-reading this before commit, in my own new code: with `if: always()`, a failed `changed-paths` job leaves the work skipped, and both `gate: skipped` (read as "nothing to do") and a skipped `analyze` job satisfy the ruleset — green, with neither a classification nor an analysis behind it. That is the silent-pass class this change exists to remove, reproduced inside its own fix. Both required checks now test the classifier's OWN result first and refuse if it did not succeed.
  * **FAIL-SAFE POLARITY, stated because it is the safety argument:** anything the classifier cannot determine — a non-push event, a missing or unreachable base commit, an empty diff — counts as CODE, never as docs-only. A wrong "docs-only" skips a real analysis silently; a wrong "code" costs one cheap job.

### Added

* **`concurrency` on all six workflows — previously ZERO of 6 — with `cancel-in-progress` decided PER WORKFLOW, not uniformly.** `ci.yml` / `codeql.yml` / `markdownlint.yml` **cancel** (read-only work, nothing half-written; a superseded CodeQL run simply never uploads and the previous analysis stands). `claude-ai-zips.yml` **queues** — this room already shipped a Release-asset race fix at `v3.17.1`, and cancelling mid-`gh release upload` can leave a Release carrying a partial or missing ZIP, a broken install path that is strictly worse than a slow one; grouped on the TAG, since the collision is per-Release. `scorecard.yml` **queues** — it uploads SARIF and posts to the OpenSSF API, so a cancellation mid-publish is a partially-applied result. `dependabot-auto-merge.yml` **queues, grouped per PULL REQUEST** — never per-ref, so two Dependabot PRs do not serialise behind each other.
* **ONE CONFIG-READ PATH PER ROOM — the second read path is now machine-checked (CWK-064).** The owner's improve-and-unify ruling: no key is read from a bare project file, by hook or by agent instruction. **The hook side already conformed** — a hermetic probe spawned the real Stop hook with a GLOBAL-ONLY config and no project file and the global tier reached, with a non-vacuous control proving the probe could detect the opposite. **The defect was entirely the SECOND read path: ship-text telling an agent to `honor .coalmine.json <key>` with no cascade named.** On a machine configured only globally — which is the owner's machine today — the bare project file is ABSENT, so the agent reads nothing and silently falls back to defaults.
  * **The highest-leverage surface was a SHARED PARTIAL, and a per-skill glob cannot see it.** `skills/_shared/escalation-footer.md` names `.coalmine.json` `defaultTier` and is injected into **all nine** skills, so it was the defect vector for every one of them — including five that name no config key of their own. **One line there fixed nine surfaces**; `commands/stats.md` and `commands/update.md` receive no shared partial and took their own rail.
  * **`verify.mjs` block 2.10 keeps it that way:** a surface that NAMES the config must also NAME the global tier. **Skills are checked RENDERED, not raw** — load-bearing, not incidental: the rail lives in the shared footer and appears in no skill's own source, so a source-side check would red-flag all nine for correctly inheriting it. Measured: switching the check to raw source produces 3 false FAILs and `VERIFY: FAIL`.
  * **Deliberately COARSE:** it does not try to tell "instructs an agent read" from "describes hook behaviour", because that is a judgment a regex cannot make and erring permissively IS the defect. The price is a rail sentence on a surface that arguably did not need one; the alternative price is a silent miss.
  * **RED-FIRST against this room's own history:** run over `0019e09`'s blobs the gate FAILs **10 of 11** surfaces — and the single pass is `rot-canary`, the one surface already known to state the cascade, which is independent corroboration rather than a tuned result.
  * **A FOURTH surface class, missed by BOTH sweeps and by the gate's own first surface set (INSPECT MEDIUM-1):** the four `platform-configs/*.template` files still said *"honor `.coalmine.json` at the project root"* — the banned bare read, verbatim, in files we SHIP into other agents' config homes. Fixed, and `platform-configs/` is now IN the gate's surface set, with a planted-template assertion guarding that it stays there. This was CoalLedger's own MED-1 shape happening to us in the unit that cites it.
  * **Granularity corrected from per-FILE to per-MENTION (INSPECT MEDIUM-2).** One compliant line was immunising every bare read in the same file — a per-file check on a per-line defect. Strict per-line was measured and rejected too (23 mention lines, 11 lacking the tier on the line, so the \~40-word rail eleven times over). The unit that is actually right: **a mention is governed by a rail that CLAIMS to govern it** — a UNIVERSAL rail (naming the global tier AND scoping itself to *every config key*) vouches for the surface; anything else is LOCAL and governs its own line. Findings now carry `label` and `line`.
  * **The wiring is guarded (INSPECT MEDIUM-3, task #38's H1 for the third time):** unwiring block 2.10 reddens `verify.mjs 2.10 config read-path: a bare-read line fails the WHOLE gate`.
  * **The rail moved from the escalation footer to the language header (NOTE-1):** it rendered 51 lines AFTER the mentions it governed; it now renders at line 11 of all nine skills, before every mention.
  * **HONEST BOUND:** a hook-side clamp is enforced with probability 1; an instruction an agent follows is not. What is CLOSED is *"a surface can silently lack the rail"*. What stays PROSE-STRENGTH is the agent's compliance once the rail is there. No claim beyond that.

### Fixed

* **CWK-057's `scanEverything` read-path sentence gave only half its reason.** It said *"read it through the merged config, never the project file alone"* and justified that entirely by the CLAMP. Correct instruction, incomplete reason: under the one-read-path convention it stands on a second, independent ground — a bare project file is ABSENT on a globally-configured machine, so the agent sees nothing and uses defaults. Both reasons now stated.
* **`verify.mjs` block 2.9 — a documentation-vs-schema drift gate (CWK-059), the class four rooms shipped in one night.** Every config key NAMED on a user-facing surface must RESOLVE in `config-schema.mjs`, or be declared. Born from CWK-054's own MEDIUM: `693931b` shipped six sites promising `scanEverything` while the key was measured unimplemented. **Proven against that history, not a fixture** — run over `693931b`'s own blobs the gate names the defect by key and by both files.
  * **DETECTION RULE, measured before it was chosen, because a gate that cries wolf is a dead gate** (this room already paid for one — the `tripwireMaxLines` gate firing on compliant code). A candidate is a backticked Markdown token, or an identifier inside a runtime notice STRING, matching camelCase **with at least one internal capital**. A naive "any backticked token" rule flagged 22 tokens across `skills/*/SKILL.md`, **12 of them not keys — 55% noise**; requiring the internal capital drops 10 (enum values `off`/`safe`/`interactive`/`true`/`false`, prose words `file`/`line`/`fs`). A further "config marker on the same line" filter was TESTED and REJECTED: zero additional false positives, a miss risk for free.
  * **UNDER-FIRES BY DESIGN and says so** — a miss is a bug, a flood is a dead gate. A single-word lowercase or snake\_case key would not match and would sail through; this flock has none today, and the day it does the rule must be revisited.
  * **Two declarations, deliberately NOT one bucket.** `PENDING_KEYS` = named-but-not-yet-implemented, each with its ticket — because CWK-054's whole point is that naming an unimplemented key honestly IS correct, so the honest case is one line and the dishonest case is loud. `NOT_CONFIG` = a code identifier that is camelCase in prose and never a key, each with a reason. Merging them would let "planned" and "not a key" hide in one bucket, which is the escape-hatch rot this gate is against.
  * **EXPIRY BY EVENT, NOT BY DATE, and the list prunes itself:** a declared key that NOW resolves in the schema FAILs ("implemented — delete the entry"; `scanEverything`'s own entry died this way), and a declaration NO surface mentions FAILs as dead weight. A calendar date expires on a day unrelated to the work; these expire exactly when the entry stops being true. **Rule 2 is gated on a COMPLETE scan** — a partial one degrades to a visible SKIP rather than convicting a declaration nobody looked for, this room's own "a 0-hit proves nothing when the scope was incomplete".
  * **SURFACES, each in or out with its measurement.** IN: every `skills/*/SKILL.md`, `README.md`, and the runtime notice block inside each `hooks/*.js`. OUT: **`CHANGELOG.md` — measured 63 flags**, and decisively it names retired and planned keys BY DESIGN, so a red there fires on accurate history; `CONTRIBUTING.md`/`SECURITY.md` (measured zero candidates); the commented config template (it IS config, already schema-validated — scanning it would double-report). **Source only**: the `plugin/` twins are byte-identical by the existing parity check, proven live by CWK-057's sabotage, so scanning both sides would double every finding and add no coverage.
  * **The gate ASSERTS ITS OWN PRECONDITION rather than claiming one (INSPECT MEDIUM-1).** The module first carried a comment saying a lowercase key *"would sail through — this flock has no such key today"*, and that was **measured FALSE against the very schema the module consumes**: of 26 keys, `language` fails `KEY_SHAPE`, and it is backticked in `README.md`, an in-scope surface the module's own comment calls the most user-visible key list. The gate had been reading that line and discarding it since it shipped, and the revisit trigger the sentence named had already passed and could never fire — documentation-vs-code divergence committed inside the gate built to catch it. **The claim is now a machine:** every run reads the live schema and emits a visible SKIP naming each key `KEY_SHAPE` cannot see. It travels, which is why it is not a comment nit: `AGENTS.md`'s 5 Standard Systems mandates `language` in EVERY room, so an adopting room inherits the disclosure instead of the false claim.
  * **Widening `KEY_SHAPE` was considered and REJECTED, measured not argued:** allowing any lowercase identifier takes the residue on this repo's own surfaces from **4 to 37 (+33 false positives)** — platform names, language codes, enum values and prose. Closing a one-key blind spot by requiring a 33-entry hand-kept `NOT_CONFIG` roster trades a named gap for the exact allowlist rot this design refuses. An honest SKIP beats a flood.
  * **The lowercase blind spot is CLOSED BY DESIGN, not disclosed (CWK-061) — `BLIND_KEYS`.** The previous fix PRINTED the gap every run, and a printed line is not closure: nobody reads it after the third run, and the next room to add a lowercase key inherits the same silent discard. **Any schema key the detection rule cannot see is now a hard FAIL unless it is declared in `BLIND_KEYS` with its reason.** The gate is structurally incapable of ACQUIRING a blind spot without a human writing down that they accepted one — a room adding a lowercase key hits a red gate, not a line it can scroll past. `BLIND_KEYS` expires on the same EVENT principle as the other two lists: an entry whose key has left the schema, or that the rule can now see, FAILs as stale.
  * **The obvious fix was measured and REJECTED, and this is the sharper of the two rejections.** A schema-to-docs LITERAL pass looks like it should work — a schema key is a known literal, and matching a literal needs no heuristic. But it answers the WRONG QUESTION: this gate asks *"is a key NAMED in the docs REAL?"*, and a literal built FROM the schema can only ever find keys that are already in it, i.e. real by construction. **Measured: the pass returns exactly one hit (`language`, README.md:178) and ZERO findings**, because that hit resolves. It would also import the noise the capital rule exists to remove — measured on this repo's own surfaces, ordinary-word keys a room plausibly has would force it to adjudicate `auto` 6 times, `off` twice, and `safe`/`all`/`file`/`line`/`description` once each, every one an English word or an enum value. Zero detection gain, real false-positive cost.
  * **The new path's false-positive surface is ZERO BY CONSTRUCTION:** it reads `schemaKeys` only and never opens a document, so it cannot manufacture a finding from prose. The document-scanning residue is unchanged at 4, all declared.
  * **The stop did NOT cost the disclosure (INSPECT MEDIUM-1).** The first cut of the CWK-061 fix took a silent `continue` on a declared key, deleting the every-run line the previous fix existed to provide — so `verify` printed only `ok every config key … resolves`, which was FALSE while `language` was read and discarded. A stop and a disclosure are not a trade. A declared key now emits a **SKIP** (filtered out of the failure set, so it cannot redden the gate) and the pass line is **qualified** to `every DETECTABLE config key` whenever a declared blind spot exists — a gate whose success line overclaims is the defect it exists to catch.
  * **A STRUCTURED-SURFACE pass closes the part of the blind spot that is NOT irreducible (INSPECT LOW-1).** In free prose a lowercase key is indistinguishable from an English word; **in a key table the first cell is a key by the table's own contract**, so POSITION supplies the signal SHAPE cannot and the check runs **shape-free**. Region-bounded by the same technique the hook scan already uses, and the bound is what keeps it from being a second cry-wolf path — **measured: unbounded it would fire on the Commands table's 2 slash-command rows; bounded to the Configure section it sees 8 rows, 8/8 schema keys, ZERO false positives**, with the 2 excluded by construction rather than by an exclusion rule someone would have to maintain. A room supplies its own `{file, heading}`.
  * **HONEST RESIDUE, because a closure claim must be exact:** this closes the SILENT ACQUISITION of a blind spot. It does NOT make a lowercase key detectable in FREE PROSE — shape cannot separate it from an English word and a literal pass has nothing to match, since an absent key contributes no literal. **NARROWED from the first wording, which claimed one notch too much:** a STRUCTURED surface is not blind, and the key-table pass above catches exactly that case. That residue is irreducible with this design, and the FAIL is what stops it growing quietly.
  * **PORTABLE BY CONSTRUCTION** — an adopting room supplies four things and changes no logic: its schema's key list, its doc surfaces, its hook surfaces, and its own two declarations (the notice-block name is a parameter too, defaulting to `TRANSLATIONS`).

### Added

* **A fully-suppressed auto-scan now says so, instead of being indistinguishable from a clean one (CWK-054).** When `scanExcludePaths` cut EVERY touched file, the Stop hook emitted nothing at all — byte-identical to a session where the scan ran and found nothing. The two knobs were already disclosed on the auto-scan path (`capNotice`, `scanExcludeNotice`, both with a count, both in all five languages), but each is concatenated onto the loud `reason`, and `reason` is only built when files survive the filter — so the all-excluded case fell through every disclosure. It now emits one quiet line naming the suppressed COUNT, the knob that cut them, and a recourse that ACTUALLY EXISTS today: *"Scan scope: all {N} touched file(s) were skipped per `scanExcludePaths` — no code-health scan ran this session. A suppressed scan is not a clean one; scan them by narrowing `scanExcludePaths`, or invoke rot-canary manually. (A `scanEverything` override that bypasses every scope cut is PLANNED but NOT yet implemented — do not look for it in your config.)"* New `allExcludedNotice` translation key, **all five languages** (en/th/ja/zh/es).
  * **The forward-looking key is named with its STATUS, never bare (INSPECT M1).** The first cut of this notice ended *"never scanned unless `scanEverything` is on"* — flat present tense for a key that does not exist (`grep -c scanEverything scripts/lib/config-schema.mjs` → 0, no reader anywhere). That is the exact over-claim this disclosure exists to prevent, committed inside it: a user reads a note whose only job is honesty, greps their config, and finds nothing. Fixed at all six sites — five language strings plus the agent-facing `SKILL.md` rail. The user-facing strings carry the STATUS only (a ticket id is unlookuppable for a user); the internal rail additionally names CWK-057 and tells the agent not to recommend the key.
  * **Quiet channel, not the loud one:** it rides `systemMessage` — the same sanctioned Stop channel the memory-drift note already uses (board #82), never `hookSpecificOutput.additionalContext`, which forces a phantom second turn that discards a `-p --output-format json` session's `result`. No severity table, no *"invoke rot-canary"*, no fix menu, no `decision: block` — there are no findings to report, only a scope fact to disclose. **Phoenix #13 is unbreached: the sanctioned-channel list does not grow**, this is a second message on a channel that already carries one; stdout/stderr are exactly as silent as before.
  * **Anti-cry-wolf still binds.** The line fires only when files were touched AND all of them were cut. A stop that touched nothing stays fully silent, unchanged. A PARTIAL cut keeps riding the existing loud `scanExcludeNotice`.
  * **`scanEverything` LANDED in this same unreleased window (CWK-057), so the status clauses this bullet's own notice shipped with are now FALSE and were flipped, not reverted.** Every surface that said *"PLANNED but NOT yet implemented — do not look for it in your config"* now tells the user how to use it. Grepped rather than trusted: **15 live occurrences across 7 files**, not the 6 authored sites the earlier ticket counted — 5 language strings + `SKILL.md` × their `plugin/` twins (12 tracked), this CHANGELOG entry, and 2 gitignored scratchpad notes left alone.
* **`scanEverything` — the scan-override key (CWK-057), the antivirus shape.** `true` bypasses EVERY scan-scope cut for the run: `scanExcludePaths` is ignored and the `autoScanFileCap` slice is not applied. Every scope-cutting feature stays exactly as it is for everyone else; this one key turns them all off at once. **Positive polarity by design** — `true` means MORE scanning, never a double negative like `ignoreExclusions`, which would invert at every read site.
  * **SCAN-SCOPE ONLY, and the boundary is stated rather than left to be discovered.** It does NOT re-enable a disabled canary (`disabledCanaries` / `rotCanaryMode: off|manual` are the canary's ON/OFF switch, not a scope cut — "scan everything" means "when scanning, scan everything", never "scan even when you turned me off"; overriding them would also make the key unusable as a permanent global, which is its intended use). It does NOT reach the RECORDING-side cuts in the touch hook — `watchedExtensions` (a room boundary: code is CoalMine's pole, docs are CoalLedger's, so overriding it would be wrong on the merits, not merely out of reach), the tmpdir exclusion (a throwaway harness is not shipped code hiding behind an exclusion), or the tripwire's own size cap (Phoenix #3 latency, and it hides nothing — the file is still recorded and still scanned at Stop). **Structurally, a Stop-time key cannot retroactively record a file the touch hook never wrote.**
  * **CASCADE: clamped safer-value-wins (`hooks-safety.md` §9).** Declared in `SAFER_ENUM` as `{ order: [false, true], default: false }` — the same boolean-as-enum-of-two shape as `enableConductor`, opposite polarity, because §9's blast test reads the DIRECTION of the escalation, not the key's name: a clone-borne project config forcing a full scan is exactly what the clamp exists to stop. **A global `true` is never clamped away by a project file's silence** — the merge only constrains a key the project actually set — and a project may still QUIETEN `true`→`false`. **Named out of reach, per §9's own rule that a guard which only looks like coverage is worse than a named gap: the agent-invoked manual path.** The `SKILL.md` rail now tells the agent to read the MERGED value and never the project file alone, but that is prose, enforced at prose strength — no mechanism stops an agent reading `.coalmine.json` directly.
  * **The runtime notice BOUNDS its own claim (INSPECT LOW-1) — this ticket's own defect wearing the opposite sign.** The notice first said *"every scope cut was bypassed"* with no bound, while `SKILL.md`, `README.md` and `config-schema.mjs` all carried one, and `tripwireMaxFileSizeKb` was named on **no** user-facing surface at all. CWK-054 stopped a NARROWED scan reading as a complete one; unbounded here, this let an INCOMPLETELY-WIDENED scan read as fully widened — the same trust defect, sign reversed, in the one message a user actually sees. All five languages now say *"every SCAN-scope cut"*, then name what is still outside: files the touch hook never recorded (non-code extensions, anything under the temp dir) and an over-`tripwireMaxFileSizeKb` file, which IS recorded and IS scanned — only its edit-time pre-flag is skipped. The other three surfaces gained that third residue item in the same batch, so all four now agree rather than one carrying a longer list than the rest.
  * **DISCLOSURE, CWK-054's twin in reverse:** when the override is on, the run says so on the SAME quiet `systemMessage` channel (all five languages). That ticket's thesis is that a suppressed scan must not look like a clean one; the mirror obligation is that an unfiltered run must not look like an ordinary one. **Phoenix #13's sanctioned-channel list does not grow** — a third message on a channel that already carries two. Mutually exclusive with the all-excluded notice by construction, not by a guard.
  * Ships with a `config-schema.mjs` entry (so `--scanEverything` / `--scan-everything` resolve), the commented-config doc block, and a README Configure row.
* **`rot-canary`'s manual path gained the disclosure obligation it never had.** The Stop hook covers the auto path; an agent-invoked scan had no rail at all. `SKILL.md`'s SCOPE section now requires disclosing every scope cut — count and knob — for `scanExcludePaths`, the `autoScanFileCap` slice, and `watchedExtensions`, **explicitly including when the scan found nothing**, since that is exactly the case where "scanned, clean" and "never scanned" read identically. If every file in scope was cut, it must say plainly that no scan ran.

### Fixed

* **Three tests asserted the retired behaviour and would have gone red against the fix that closed the defect** — all three retargeted, not deleted. Each asserted `stdout === ''` for an all-excluded stop, i.e. the silence CWK-054 removes. Their real subjects survive and are now proven more directly, because the new note carries the skip count: separator portability and the literal-`?` regression both now assert the count rather than the absence. **One of the three (the `?` positive arm) is capability-gated and SKIPS on Windows — it would have reddened only the Unix CI runners**, the same shape that reddened CI in CWK-043; fixed on this pass rather than left for CI to find. Found by sweeping the ASSERTION shape (`r.stdout, ''`) rather than the test titles — a title-scoped sweep found one of the three.

### Changed

* **The Stop hook's emit block now records the MEASURED cost of its own loud channel (CWK-087/CWK-088) — comment only, no behaviour change.** The board-#82 note there covered the QUIET channel; measured on CC 2.1.266, the LOUD one has the same effect by design: `decision:"block"` + `reason` makes the platform run a second model turn whose text REPLACES a `-p --output-format json` session's `result` (`num_turns` 2), while `systemMessage` alone leaves `result` intact (`num_turns` 1). The block is what makes the agent run the scan, so it is not silenced; the hook also has no honest way to detect a `-p` session (no mode field in the Stop payload, hook stdio is never a TTY, and `CLAUDE_CODE_ENTRYPOINT` is inherited from an interactive parent). The consequence is the caller's: read the child's report from disk, or run `--output-format stream-json --verbose`, which keeps the pre-hook answer as its own `assistant` event and marks the blocked turn `system`/`post_turn_summary` `status_category:"blocked"`.
* **The `<tmp>/coalmine` residual now states its own bounds where it is named (CWK-044).** `rot-canary-stop.js` and `coalmine-conductor.js` each already named the residual — `mkdirSync`'s `mode` is a no-op when the directory already exists, so a third party who pre-creates `<tmp>/coalmine` world-writable leaves it permissive. Each block now also states, in the same place rather than a file away: the **bounded worst case** (denial and metadata only — a suppressed sweep or a skipped advisory nudge, plus marker existence/mtime and the conductor's djb2 filename hash; never our marker contents, never a write through a path we own, and no reach at all to `.touched`/`.smells`/`.scanned`, which sit flat under `/tmp`'s own sticky bit); the **forgery/suppression** case (planting a regular file at the marker path makes the throttle read "already swept", or makes the conductor's `wx` create hit EEXIST and fail closed — reachable by design, because the marker carries no authenticator and a forged one is indistinguishable from ours; content forgery is vacuous since both markers are written empty); and the **delete-vs-read distinction** (reading is governed by the file's `0o600`, but delete/rename/replace is governed by the *directory's* bits, which we do not control — `0o600` does not bear on `unlink` at all — measured under WSL2/tmpfs as non-root, and cited with that provenance because this room's dev box is NTFS where neither mode exists to test: `dir 0500 + file 0600` fails to unlink while `dir 0700 + file 0400` succeeds, the pair isolating the directory bit as the causal variable; the cross-user leg remains POSIX semantics rather than a measurement of ours). Comment-only — the non-comment diff is empty and the `plugin/` twins are byte-identical to source by `git hash-object`. Filed under Changed rather than Security deliberately: it fixes no vulnerability and changes no behaviour, it bounds one already-named and already-accepted residual.

## \[3.18.3] - 2026-08-31

### Security

* **Every `os.tmpdir()` write in the shipped hooks now passes an explicit `mode: 0o600` (CWK-043, CodeQL `js/insecure-temporary-file` #66/#67).** Six sites: the sweep-throttle stamp and the `.scanned` acknowledgement marker in `rot-canary-stop.js`, the AG conductor's once-per-session marker, and `rot-canary-touch.js`'s `.memmoved`, `.touched` and `.smells` files. Two were reported (the stamp and its `plugin/` twin); the rest are the same shape and were fixed in the same batch rather than left for a later scan. **The threat, not the scanner, drew the boundary** — and the last two are the reason that distinction matters: `.touched` and `.smells` are written with `appendFileSync`, which is *not* among the query's 14 modelled sinks, so nothing flagged them, yet they are flat `os.tmpdir()` writes in the same directory as the markers and carry **more** than any of them (the user's edited file paths, and the smell findings against those paths). These files previously took the default mode (`0o666 & ~umask`), and on a shared Unix `/tmp` that mode is the only thing scoping them to the current user — the two markers under `<tmp>/coalmine/` sit in a `0o700` dir, but `mkdirSync`'s `mode` is a no-op when the dir already exists, so a pre-created world-writable `<tmp>/coalmine` leaves the file's own mode as the last line; the other four are flat `os.tmpdir()` writes with no private dir at all. The `flag: 'wx'` on three of them is unchanged and does separate work (`O_EXCL` refuses a pre-planted name, link or not); no site traded `O_EXCL` for a mode. **What `0o600` is, stated precisely:** it satisfies the query's own sink predicate (`isSecureMode` is `mode & 0o77 == 0` — grant nothing to group or other), which is *not* the same as "the rule's own remediation" — the query's qhelp recommends a library like `tmp`, which Phoenix #2 (zero-dep) forbids, so the recommended path is unavailable to us rather than merely unattractive. Re-derived from the query source before fixing: the predicate reads the mode argument only, never the flag, the directory's permissions, the `lstat` guards, or filename randomness, so a random filename suffix would not have closed it. Deliberately unchanged: the update-check stamp is under `os.homedir()`, not a temp dir; and the two `openSync(…, 'r')` calls are untouched because **neither path derives from `os.tmpdir()`** — being read-only is not what saves them, since `openSync` *is* one of the 14 and a two-argument call satisfies the no-mode branch. **Known residual:** `mode` is `open(2)`'s creation mode and is ignored when the file already exists, so a `.scanned` marker left by an earlier version at `0o666` is not retroactively tightened; it self-heals on the next tmp clear. PowerShell twins not ported, divergence named in both `alt/powershell/rot-canary-stop.ps1` and `alt/powershell/README.md`'s Known-differences list: `[System.IO.File]::WriteAllText` has no mode parameter, and POSIX modes are not the access model on NTFS.

## \[3.18.2] - 2026-08-31

### Security

* **rot-canary Stop hook: the tmp-sweep throttle marker no longer writes through a planted symlink (CWK-031/U8).** The 24h throttle marker sat flat in the shared tmp ROOT under a fixed, predictable name and was written with the default `w` flag, which follows a symlink at the destination (`node/runtime.md` §5). On Unix the shared `/tmp` let any local user pre-plant a link at that name; the victim's next Stop then truncated the link target (truncate-to-empty / empty-file creation — not arbitrary content). The write also fired on every Stop regardless of `rotCanaryMode`, because the sweep is called before the mode gate by design (the AG conductor's markers must be collected whatever rot-canary's own mode is). Fixed by moving the marker into the private `os.tmpdir()/coalmine/` subdir (mode `0o700`, plus the `lstatSync` dir guard the AG conductor already ships — `mkdirSync(recursive)` silently succeeds on a pre-planted symlink at that dir) and re-stamping it via a per-pid temp + `renameSync` rather than a plain write. Rename acts on the directory entry rather than writing through it, so nothing is ever written into a planted link's target, and it keeps the overwrite semantics the throttle needs — a bare `wx` swap would have frozen the marker's mtime and made the sweep run on every Stop forever. A symlink found at the marker path is never obeyed as a gate whatever its mtime reads, so it cannot be used to suppress the sweep; what happens to the link itself depends on its type, and is stated rather than generalised — a **file**-type link is replaced by the re-stamp, while a **directory**-type link (a Windows junction) survives it, because renaming a file onto one fails `EPERM`. In both cases the link is never obeyed and never written through. A pre-U8 flat-root marker left behind is collected as ordinary stale canary temp **on the active path only** — a permanently disabled canary keeps it, matching the existing "a disabled canary does no work" rule rather than overriding it. PowerShell twin deliberately not ported, divergence named in both `alt/powershell/rot-canary-stop.ps1` and `alt/powershell/README.md`'s Known-differences list (Windows `$env:TEMP` is per-user, so the shared-root threat does not exist on that script's only platform).

## \[3.18.1] - 2026-08-23

### Fixed

* **SKILL.md body-lean campaign (campaign #6 unit 6, belt 2/board 2 — CB v2.3.1 · CT v1.5.1 · CW v1.3.1 · CF v0.7.3 · CL v0.5.0-beta.2 preceded this room). Measurement shape: PER-SKILL, not whole-product** — CoalMine's 9 canaries are 9 genuinely independent single-purpose bodies, matching this room's own established §5 discipline (SKILL.md = lean core, `references/*.md` = on-demand depth), not a monolithic product skill. **Measured via `claude plugin details coalmine@coalmine` (the platform's own on-invoke projection, never `chars/4`) BEFORE reading anything — the CB trap avoided: installed-cache byte-verified against git source for all 9 bodies (`git hash-object`, all 9 `OK`), `verify.mjs` independently confirmed `plugin/` dist in sync.**
* **8 of 9 skills declared ALREADY LEAN — residue is rail, not forced fat, per this campaign's own honest frame.** `drift-canary`/`resilience-audit`/`scale-canary`/`telemetry-canary`/`testability-canary`/`source-grounding`/`supply-chain-audit` (2.8k–3.2k on-invoke) and `rot-canary` (3.7k) all sit well under the \~5000-token body budget (`skill-authoring.md` §3b); grepped every body for the standard fat markers (explanation-shaped parentheticals, restated ledger rows, "because"/"the reason"/"rationale" prose) and found none — every remaining line is a distinct rail (a category, a discipline item, a fix-mode branch, an output shape). No line was cut to manufacture a smaller number.
* **`gold-standard` (14,920 → 14,400 chars, \~5.5k → \~5.3k on-invoke est.) — the one skill genuinely over budget, and the one real cut in this unit.** `## Discipline`'s three bullets (don't-inflate, exemplar-cite, multi-source) were near-verbatim restatements of Prohibitions rows P10/P11/P12, and its blocked-lookups bullet restated Degrade-paths row D1 — a `skill-authoring.md` §1 "say it once" violation the file's own established Dedup-pointer convention (already used for the D3/D4-D6 and CLASSIFY-BLOCK sections) had not yet been applied to. **Grepped BOTH directions before cutting** (per this campaign's own rail: confirmed P10/P11/P12/D1 each still resolve to exactly ONE canonical ledger row after the cut — `grep -c` on all four returned `1` each, so the cut removed a restatement, never the only copy of a rail) — collapsed to two lines pointing at the ledgers, matching the file's own convention. Also trimmed the FILL act's stamp-format sentence to keep the 30d/90d decision rule (a rail) while moving its "why" (already in `references/method.md`) out of the always-loaded body. **The Dedup line's own P11 claim was stale the instant Discipline stopped restating it — caught and corrected in the same pass** (a live instance of this room's own "an edit that falsifies its own citer" class). Residue: \~5.3k on-invoke, still marginally over the \~5k bar — **stated plainly, not hidden**: the remaining structure (16 prohibitions, 8 degrade paths, a CLASSIFY-BLOCK table, a 5-act pipeline) is enumerable rail this room's own §3b variance walk has already proven legible; no further cut was forced to close the last \~6%.
* **§3b variance walk: 11 leaves across all 3 tiers (5 weak/haiku, 3 medium/sonnet, 3 strong/opus), zero-context, no-tool, one frozen prompt — ZERO variance on every enumerated rail.** Scope followed the diff (Discipline + the FILL stamp sentence, both universal prose read identically by Agent and Hook lanes, so one pass sufficed, no lane split needed). All 11 leaves independently resolved the compressed Discipline pointer to the identical 4 rails (P10/P11/P12/D1, materially equivalent wording) and enumerated the identical complete P1–P16/D1–D8 sets with zero gaps; all 11 answered "no vanished rail" to the explicit adversarial question. Blob walked: `plugin/skills/gold-standard/SKILL.md`, sha1 `ffbc38475e795c4aa9f2d30a555bfe8bf494efb5`, 14,400 chars. **Correction, caught by this unit's own INSPECT: the first version of this entry declared weak-tier-only "per §3b's own escape valve" — INSPECT grepped `skill-authoring.md` exhaustively for any disproportionate/escape-valve/waive language and found ZERO hits; no such clause exists.** §3b's text is unconditional ("No exceptions, no size carve-out") and explicitly requires all three tiers ("weak alone cannot tell an ambiguous file from a hard one, and a strong alone hides the ambiguity"). The missing 6 leaves (3 medium + 3 strong) were run to close the gap rather than amending the rule out-of-band; full 3-tier/3-round conformance now holds, and this is the second and last unit to cite a clause that was never in the file — future units: §3b has no size- or edit-shape-based exemption, run all three tiers every time.
* **Next-touch, out of scope for this unit, surfaced unprompted by 2 of 3 strong-tier walk leaves (not by the weak/medium tiers — a FACT-axis tier disagreement per §3b): `gold-standard`'s Prohibitions row P13 ("never fix without a chosen option, Hook lane") and Degrade-paths row D5 ("this skill defines no Fix mode section, so the footer's deferral resolves to report-only either way") may be in tension — P13 prohibits an unchosen fix in a mode the skill never offers.** Both rows predate this unit (present before the CLASSIFY-BLOCK retrofit, untouched by this diff) — named here, not fixed, since the walk's own scope-follows-the-diff rule puts it outside this unit's authority to touch.

## \[3.18.0] - 2026-08-21

### Added

* **A declared "Grants & denials (CLASSIFY-BLOCK)" section on 8 of 9 skills, retrofitting `skill-authoring.md` §5b (board #93) onto this room's already-live skills — a `prefer/should`-force retrofit, not the MUST that binds a brand-new skill.** §5b's own evidence base was two tool classes (write, spawn-via-Bash) from same-day incidents in four OTHER rooms; the umbrella's own survey (`goldstd-2026-08-18/f22-classify-block-survey.md`) found CoalMine holding the strongest existing degrade ledger of the 20 skills surveyed (gold-standard's D1-D6) yet, like every other skill in the org, ZERO stated WRITE-denial branch — a `rot-canary` Edit denial under `autoFixMode: safe` on the unattended Stop hook was named the sharpest instance (silent no-op, no interactive user to notice). Each skill's table states its own read/write(/network) rows only — no skill needed a spawn row, since sub-agent fan-out is already discharged by the shared Escalation section's own D3-class fallback ("degrade gracefully, never fake parallelism"); re-declaring it would violate P6's own "don't re-declare an already-discharged branch" rule.
* **Room-specific technical finding, verified at source before building (the survey's own claim, re-checked): CoalMine's `scripts/lib/render.mjs` already injects shared partials by HTML-comment marker (`<!-- SHARED:X -->`), and `verify.mjs` already byte-compares every injected region against its one source of truth.** One new partial, `skills/_shared/classify-block.md` (the four reusable denial idioms — network/write/spawn/read — reused verbatim from the survey, never re-invented), one new marker `<!-- SHARED:CLASSIFY_BLOCK -->`, registered in `render.mjs`'s `loadShared()`/`inject()` — covers all 9 templates in one edit. **INSPECT (below) caught that the pre-existing byte-compare only proves the marker's CONTENT matches where the marker is PRESENT — it does not prove presence itself** (a skill silently missing its whole CLASSIFY-BLOCK still passed `verify.mjs` before this fix). Closed with a dedicated `verify.mjs` presence check (8 of 9 skills must carry the marker, `source-grounding` named as the one exclusion) — `verify.mjs` now enforces BOTH halves of §5b's own fourth-tense gap ("no gate greps for the section") for this room, uniquely (no other room's build has a marker-injection mechanism to hang this on).
* **Render architecture choice, stated per the order's own instruction: common core + per-skill one-liners, not full per-skill row selection inside the partial.** The shared partial holds only the INVARIANT explanatory prose (the four idioms, reused byte-identical everywhere they apply); the actual TABLE — heading + rows, which vary from 1 row (`gold-standard`, write-only, since D1/D3/D4/D6 already discharge read/spawn/network) to 3 rows (`supply-chain-audit`, which also fetches GHSA/OSV/NVD) — is skill-specific body content, matching the existing precedent of this room's own G/P/D-ledgers (never shared-injected, always per-skill declared). A single fully-shared block could not have supported `gold-standard`'s divergence note or the row-count spread without turning into a large conditional template `render.mjs` has no mechanism for.
* **`source-grounding` deliberately EXCLUDED, per the survey document's own "P6 EXCLUSIONS" section (`f22-classify-block-survey.md` — not `source-grounding`'s own internal P6 prohibition row, an unrelated table INSPECT correctly flagged this entry as conflating), re-verified against the live file rather than trusted: read+network only, no write step, no Fix mode section, and its existing D1 ("mark N-A, never guess" / "log ⚠️ UNVERIFIED, continue") already IS the one hot-class (network) denial branch.** A four-row block there would have added two structurally-unreachable rows (write, spawn) and restated a shipped one (network/D1) — the exact overkill `skill-authoring.md` §2 already bans. Nothing in this skill's file was touched.
* **Per-skill divergence named inline, per the mold's own no-silent-drift rule:** `gold-standard`'s table carries one explicit sentence stating which classes it omits and why (D1/D3/D4/D6 already cover them) rather than a bare row-count difference a reader would have to infer.
* `_shared/README.md`'s partials table and "the mold" section order updated to name the new partial and its position (between `Fix mode` and `Output`) — an enumerating doc left stale by a new sibling is the exact falsification class this room's own `MEMORY.md` already records paying for once (the `fd89f89` fix).
* **§3b variance-walk: DECLARED DISPROPORTIONATE, not run** (`skill-authoring.md` §3b's own escape valve — declare, never silently skip). A purely additive declarative table; touches no existing branch/lane a prior walk baselined (Fix mode's branches, Output's shape, and the Escalation tier table are byte-unchanged); the four idiom sentences are reused verbatim, not authored fresh. **Sharpened by INSPECT's F4 (below): §3b's pass condition counts membership in enumerated classes (prohibitions, degrade paths), not just edits to existing rails — a bare, unnumbered "MUST" sentence inside a new table row IS such a new member and needed a countable home to keep the disproportionate call honest.** Where a countable home already existed (`gold-standard`'s P/D-ledgers), the new denial branches were given numbered rows (P16, D7, D8) rather than left as free prose. The 7 skills with no such ledger have no pre-existing enumeration to extend — for those, the CLASSIFY-BLOCK table's own rows ARE the enumeration, so the fix was to drop the one bare "MUST" (`rot-canary`'s write row) rather than invent ledger infrastructure these skills were never designed to carry. Full reasoning: the unit's own commit messages, `583c32b` (BUILD) + `c38e734` (findings-back).

**RE-INSPECT (same day, same reviewer) — SHIP.** All 5 F1–F5 fixes independently re-derived and confirmed closed (F3 re-proven live: removed the marker again, watched `verify.mjs` FAIL, restored). One LOW regression the F2 fix itself introduced — `gold-standard`'s CLASSIFY-BLOCK header sentence dropped its network (D1) pointer while correcting the read/spawn claim — fixed same round (one clause, `.../D6.` → `.../D6, D1.`).

**Findings-back (same day, INSPECT — code-reviewer resident).** FIX-NEEDED, 3 MEDIUM + 2 LOW, all closed same round, RE-INSPECT pending: (F1) every Fix mode row's checkpoint→build+tests→revert interlock needs Bash, which no write row granted — widened all 8 write-row grants to include `Bash` alongside `Edit`(`/Write`), with the on-denial text now naming the checkpoint/revert as a SEPARATE failure from the apply itself. (F2) `gold-standard`'s divergence sentence claimed READ was already discharged with no D-row backing it — added a real read row + D8, corrected the sentence. (F3) the shared-region byte-compare only proved content-where-present, not presence itself — closed with a dedicated `verify.mjs` check (see the bullets above). (F4) the disproportionate-walk reasoning undercounted what §3b actually measures (enumerated membership, not just edited rails) — closed per the bullets above (gold-standard's P16/D7/D8; the bare MUST dropped elsewhere). (F5) this entry's own source-grounding exclusion cited the wrong P6 — corrected above.

### Fixed

* **`scripts/lib/claude-ai-trim.mjs`'s `trimDescription()` could split a non-BMP character mid-surrogate-pair (found by CoalFace's INSPECT during its own port of this file, board #40 fixback F3; reproduced and confirmed against this repo's own copy before fixing).** The trim cuts on UTF-16 code units (`description.slice(0, budget)`); when a non-BMP character (e.g. an emoji, or a CJK-extension codepoint) straddles the cut index, the split leaves a lone high surrogate (`0xD800`-`0xDBFF`) at the end — invalid UTF-16, decodes to `U+FFFD` downstream. The existing whitespace-boundary rescue only saves it when an ASCII space exists in the first `budget` characters, so a Thai/CJK description with no ASCII word breaks would ship the raw split. **Nothing is live today** (every shipped description is English with ASCII spaces) — this is a when-not-if fix, not a hypothetical, since this org writes Thai. Fixed by dropping the one trailing lone high surrogate before the whitespace rescue runs, judged over a code-point-array (`Array.from`) rewrite: the latter would change what "200 chars" MEANS (code points vs. UTF-16 code units). This is not a guess about which convention claude.ai uses — for non-BMP text, a UTF-16 code-unit count is never LESS than the codepoint count of the same string (a surrogate pair is 2 code units, always 1 codepoint), so trimming to ≤200 in code units stays ≤200 in codepoints too; it can only come in under a codepoint-counted cap, never over. (This dominance does not extend to UTF-8 byte count — a 200-code-unit Thai/CJK string can measure \~600 bytes — so it says nothing about a byte-counted cap.) The minimal fix preserves this dominance and fixes only the boundary defect. **A second, unrelated finding in the same file — named, not fixed:** this trims `description` alone, while `desc-cap.mjs` sums `description + when_to_use` against our own 1024 cap; whether claude.ai's ZIP-install listing does the same is unverified (`⚠️ unverified` comment added per source-grounding's blocked-lookup law — no `when_to_use` field exists anywhere in this flock today, so nothing is live either way). **This does not reach `plugin/`** (`scripts/lib/` is not in `build-plugin.mjs`'s copy list, confirmed by reading it) **but does change what a user downloads from the Release** — the claude.ai ZIP's staged `SKILL.md` descriptions are built by `scripts/build-claude-ai-zips.mjs`, which imports this function — same distributed-artifact precedent this room's own `[3.17.1]` entry already set. New regression test added (`claude-ai-trim.test.mjs`): one case (an emoji at the exact cut boundary with no ASCII space before it), confirmed red against the unmodified file before fixing, green after — scoped deliberately to the one reproducing case, since the failure mechanism (`charCodeAt` landing on a high surrogate) is identical regardless of which non-BMP character or exact boundary offset triggers it; not multiplied into near-duplicate assertions for other codepoints or positions.

## \[3.17.4] - 2026-08-16

### Fixed

* **`scripts/lib/claude-ai-trim.mjs`'s `trimDescription()` could split a non-BMP character mid-surrogate-pair (found by CoalFace's INSPECT during its own port of this file, board #40 fixback F3; reproduced and confirmed against this repo's own copy before fixing).** The trim cuts on UTF-16 code units (`description.slice(0, budget)`); when a non-BMP character (e.g. an emoji, or a CJK-extension codepoint) straddles the cut index, the split leaves a lone high surrogate (`0xD800`-`0xDBFF`) at the end — invalid UTF-16, decodes to `U+FFFD` downstream. The existing whitespace-boundary rescue only saves it when an ASCII space exists in the first `budget` characters, so a Thai/CJK description with no ASCII word breaks would ship the raw split. **Nothing is live today** (every shipped description is English with ASCII spaces) — this is a when-not-if fix, not a hypothetical, since this org writes Thai. Fixed by dropping the one trailing lone high surrogate before the whitespace rescue runs, judged over a code-point-array (`Array.from`) rewrite: the latter would change what "200 chars" MEANS (code points vs. UTF-16 code units). This is not a guess about which convention claude.ai uses — for non-BMP text, a UTF-16 code-unit count is never LESS than the codepoint count of the same string (a surrogate pair is 2 code units, always 1 codepoint), so trimming to ≤200 in code units stays ≤200 in codepoints too; it can only come in under a codepoint-counted cap, never over. (This dominance does not extend to UTF-8 byte count — a 200-code-unit Thai/CJK string can measure \~600 bytes — so it says nothing about a byte-counted cap.) The minimal fix preserves this dominance and fixes only the boundary defect. **A second, unrelated finding in the same file — named, not fixed:** this trims `description` alone, while `desc-cap.mjs` sums `description + when_to_use` against our own 1024 cap; whether claude.ai's ZIP-install listing does the same is unverified (`⚠️ unverified` comment added per source-grounding's blocked-lookup law — no `when_to_use` field exists anywhere in this flock today, so nothing is live either way). **This does not reach `plugin/`** (`scripts/lib/` is not in `build-plugin.mjs`'s copy list, confirmed by reading it) **but does change what a user downloads from the Release** — the claude.ai ZIP's staged `SKILL.md` descriptions are built by `scripts/build-claude-ai-zips.mjs`, which imports this function — same distributed-artifact precedent this room's own `[3.17.1]` entry already set. New regression test added (`claude-ai-trim.test.mjs`): one case (an emoji at the exact cut boundary with no ASCII space before it), confirmed red against the unmodified file before fixing, green after — scoped deliberately to the one reproducing case, since the failure mechanism (`charCodeAt` landing on a high surrogate) is identical regardless of which non-BMP character or exact boundary offset triggers it; not multiplied into near-duplicate assertions for other codepoints or positions.

## \[3.17.3] - 2026-08-13

### Security

* **Three more hook-read config gates clamped through `loadCfg()`'s safer-value-wins merge (board #113, closing board #112's own INSPECT-named next-touch set: `enableConductor`/`disabledCanaries`/`rotCanaryMode`).** All three were entirely unclamped before this — a project-only `.coalmine.json` could silently re-enable a globally-disabled canary, flip the whole conductor back on, or re-arm auto-scanning past an explicit global choice. `enableConductor` (boolean, treated as an enum-of-two `[false, true]`, `false` = safest) and `rotCanaryMode` (`off` < `manual` < `auto`) join `SAFER_ENUM`; `disabledCanaries` (a SET, not an enum — hooks-safety.md §9: the safer direction for an array is UNION, never pick-one-side) joins `UNION_ARRAY_KEYS`. **Legacy-alias escalation, found auditing the read sites rather than assumed: each key has a legacy name (`conductor`/`mode`/`disable`) read independently at every call site** (`cfg.disabledCanaries !== undefined ? cfg.disabledCanaries : cfg.disable`, etc.) — clamping only the new key name left a project able to escalate through the OLD name alone, entirely bypassing the new-key clamp. Closed by resolving each layer's effective value through EITHER name before comparing, and (for `enableConductor`'s OR-shaped read site specifically) mirroring the clamped result into both key names. **A second defect caught before it shipped, independent of the dispatch's own ask: the clamp was storing the raw-cased WINNING value instead of the canonical enum member** — `rotCanaryMode`'s consumers (`mode === 'off' || mode === 'manual'` in `rot-canary-stop.js`/`rot-canary-touch.js`) compare with strict `===` and do not lowercase first (unlike `updateMode`'s own consumer, which does), so a legitimately-entered `'OFF'` that won the case-folded comparison would have been stored verbatim and then silently failed to match `'off'` downstream — the exact storage trap CoalWash's own K1 finding already named ("compared the folded spelling but stored the RAW one"). Fixed: the merge now stores `order[winning index]`, never the raw input. `disabledCanaries` array entries are case-folded (`config-schema.mjs`'s `lower: true` is enforced by the CLI on write only; a hand-edited file bypasses it). Synced via `build-plugin.mjs` into all three consuming hooks (source + `plugin/` mirror). **PowerShell twin ported (`hooks/_shared/ps-config.ps1`, synced into both consuming PS hooks): `enableConductor`/`rotCanaryMode`/`disabledCanaries` + their legacy aliases, same clamp/union/alias-resolution shape.** `enableConductor` was first judged out of scope for PS ("no PS hook reads it, a clamp defends nothing" — still true: no `coalmine-conductor.ps1` exists, the PS twin is scan-only) — corrected in this same unit's findings-back: `ps-config.test.ps1`'s own `updateMode` coverage already clamps a key with no PS consumer, on the stated reasoning that the shared merge FUNCTION must stay Node↔PS parity regardless of which hooks exist on either side; `enableConductor` was inconsistently exempted from that same precedent. `enableConductor`'s `order` is a boolean pair (`@($false, $true)`), compared via a direct index lookup rather than `.ToLower()` (booleans aren't strings; stringifying one first would silently fail every lookup against a real-boolean `$order` array). Two of the Node-side fixes are also deliberately NOT ported, verified live rather than assumed: PowerShell's `-contains`/`-eq` are case-insensitive by default (confirmed: `'ROT-CANARY' -contains` matches `'rot-canary'`, `'OFF' -eq` matches `'off'`), so neither the array case-fold nor the canonical-member-storage fix protects against anything reachable on this platform. **Findings-back correction: this entry originally claimed "no PS test harness exists in this repo" — false, and it is why the actual regression below sat undetected for four releases.** `scripts/lib/ps-config.test.ps1` and `scripts/lib/ps-hooks.test.ps1` both exist, are tracked, and are gate-wired in `.githooks/pre-commit`, `.githooks/pre-push`, and `.github/workflows/ci.yml` — nobody who shipped this unit ran them. `ps-config.test.ps1`'s own `updateMode` safer-merge assertion (`no explicit global choice leaves the project free`, asserting `-eq 'auto'`) was still checking board #112's PRE-fix behavior and had been failing on every CI run since `v3.17.0` — fixed to assert the schema default (`'ask'`), and its name corrected to describe what actually holds now. New `Check` assertions added covering board #113's own three keys (`enableConductor` canonical + legacy-key escalation, `rotCanaryMode` canonical + legacy-cross-key + case-fold, `disabledCanaries` canonical-UNION + legacy-cross-key + the PS-side case-insensitivity proof), all red-before/green-after against the live gate-wired file — not only the throwaway sandbox script this unit ran earlier. `enableConductor`'s legacy-mirror write (`merged.conductor = result`, added to satisfy the OR-shaped read site) has one documented behavior change for a self-contradictory, single-layer project config with no global at all (e.g. `{enableConductor:true, conductor:false}`): the canonical name now wins outright where the old either-false-wins shallow merge would have disabled — no cross-layer escalation vector, since there is no global choice being defended against in that shape.

## \[3.17.2] - 2026-08-13

### Fixed

* **`[3.17.1]`'s own claim below — "the fix is proven instead by this v3.17.1 tag's own live run" — was FALSE, caught by main re-checking the live run instead of trusting the entry it had just written.** `v3.17.1`'s `claude-ai-zips` run also failed, at the SAME "Ensure the GitHub Release exists" step, for a DIFFERENT reason than v3.17.0: `gh release view "${GITHUB_REF_NAME}" >/dev/null 2>&1` failed silently (stderr redirected away, so the real cause was never logged) even though the Release genuinely existed at that point (main had created it via REST moments earlier) — the `||` then fell through to `gh release create`, which correctly reported `Release.tag_name already exists` (HTTP 422) and still failed the step, so the ZIPs/sums never attached. **Fixed by removing the unreliable `view`-first probe entirely**: the step now runs `gh release create` directly and treats its one EXPECTED failure mode (tag already exists) as success — falling through to a `gh release view` only to confirm that specific case, and erroring loud with an explicit message on any other failure, so a future unknown failure surfaces instead of being swallowed by a blind `||`. **This is the second published version in a row whose own CHANGELOG entry claimed a fix was live-proven when it was not — the fourth-tense lesson applied to itself: a "proven live" claim is verified by reading the actual run's conclusion field, never by the absence of an error in the terminal that cut the release.**

## \[3.17.1] - 2026-08-13

### Added

* **`SHA256SUMS.txt` attached alongside every claude.ai ZIP release asset (board #99).** `.github/workflows/claude-ai-zips.yml` (board #40, shipped `f494d7f`) published per-skill ZIPs to the GitHub Release with no way for a downloader to verify integrity; a new step runs `sha256sum *.zip > SHA256SUMS.txt` over the staged ZIPs and it's uploaded in the same `gh release upload` call as the ZIPs themselves — **one invocation, sequential, best-effort, never claimed atomic: `gh release upload` uploads its file list one at a time, and a mid-run failure can leave ZIPs on the Release with no sums file.** (Findings-back correction: the original text here claimed the single call made the upload atomic — wrong; the v3.17.0 production run is direct proof the call can fail outright, see the HIGH below.) **Corrected premise: board #99 stated the workflow "computes a SHA-256 during build and discards it" — grepped `sha256`/`createHash`/`checksum` across the workflow + `scripts/build-claude-ai-zips.mjs` + `scripts/lib/claude-ai-trim.mjs`: zero hits, no hash was ever computed anywhere. Nothing was discarded; the gap was that nothing existed.** README's Option A3 gained a line pointing at `SHA256SUMS.txt` and the actual verify commands (`sha256sum --ignore-missing -c SHA256SUMS.txt` — a downloader typically has one of the nine ZIPs, and plain `-c` reports the other eight as FAILED; a tested PowerShell one-liner for Windows, since `Get-FileHash` alone only prints a hash, it doesn't verify against a list). `SECURITY.md`'s Dist Integrity section is a different concern (source→`plugin/` reproducibility) and is left untouched. **This is a distributed-artifact change earning an entry despite `plugin/` being unaffected — same precedent as the git-hook installer (`d1c917f`): what a user downloads from the Release changed, even though the installed plugin dist did not.**

### Fixed

* **Findings-back, HIGH (confirmed live, not theoretical): the v3.17.0 tag's `claude-ai-zips` run failed at the upload step with `release not found`.** Root cause: the workflow triggers on `push: tags: v*`, which fires the instant `git push --follow-tags` lands — before main's separate REST call creates the GitHub Release. A new "Ensure the GitHub Release exists for this tag" step runs immediately before the upload (`gh release view "${GITHUB_REF_NAME}" || gh release create "${GITHUB_REF_NAME}" --title "${GITHUB_REF_NAME}" --generate-notes`) — safe under either ordering: if main's Release already exists, this no-ops; if CI runs first, it creates a minimal one. **Process note (no tooling built for it): main's manual Release-creation step now needs to be create-or-update, never assume-first-create, since CI may have already created a minimal Release for the tag.** Also added `workflow_dispatch: {}` so a failed run can be re-fired without cutting a new version — **caveat CONFIRMED live by main after push: dispatching `workflow_dispatch` against the `v3.17.0` ref failed with `"Workflow does not have 'workflow_dispatch' trigger"` — GitHub reads the trigger from the workflow YAML AS IT EXISTS AT THE DISPATCHED REF, and `v3.17.0`'s own tagged snapshot predates this fix. Backfilling that specific tag's assets is not possible via dispatch; the fix is proven instead by this v3.17.1 tag's own live run, which carries the fix from its own snapshot.**

## \[3.17.0] - 2026-08-13

### Added

* **`verify.mjs`'s DESC\_CAP gate now also checks `.claude-plugin/plugin.json`'s OWN description field (board #64; CoalMine is the flock exemplar — the diff shape a later room copies verbatim).** Section 1.5 walked `skills/*/SKILL.md` + `commands/*.md` frontmatter only against the shared 1024-char cap (`desc-cap.mjs`'s `DESC_CAP`); the plugin manifest's own `description` field — the string a marketplace listing actually renders — was unchecked, so it could silently exceed the cap (CoalLedger shipped one at 1067 chars, only a human eye caught it). New section 1.6 reads `.claude-plugin/plugin.json` directly (plain JSON, not YAML frontmatter, so it bypasses `frontmatterField`/`descriptionCapCheck` and compares `pj.description.length` against the same imported `DESC_CAP` — the cap constant is never redefined) and FAILs with `.claude-plugin/plugin.json: description <N> chars exceeds the 1024-char cap`, matching the existing FAIL-line shape exactly. Proven RED-then-GREEN by hand (a planted 1025-char description FAILed the gate with that exact message; restored, PASSED again) before the new automated negative-path test (`render.test.mjs`, same tmp-repo-copy pattern as the existing stale-dist test) was added to keep it that way. CoalMine's own live `plugin.json` description measures 506/1024 chars — no trim needed, no dist change, no version bump.
* **claude.ai ZIP packaging via CI (board #40, C2-v2 design; CoalMine is the flock exemplar — the shape a later room copies).** A new `.github/workflows/claude-ai-zips.yml` builds one ZIP per skill on every version tag and attaches them to the GitHub Release as assets, so a claude.ai user downloads a ready-to-upload ZIP instead of hand-zipping `skills/`. The build (`scripts/build-claude-ai-zips.mjs`, backed by `scripts/lib/claude-ai-trim.mjs`) deterministically trims each skill's frontmatter `description` to claude.ai's 200-char skill-listing cap (our own cross-platform cap is 1024, `desc-cap.mjs`) — a DERIVED artifact staged by that script; the source `skills/*/SKILL.md` files are never edited. README's Option A3 now points at the Releases page instead of instructing a hand-zip, and names the real problem it fixes: our own descriptions run well past claude.ai's cap, so a hand-zipped folder was never guaranteed to work there. **Verification gap, stated plainly: the workflow itself runs on GitHub's runners and cannot be exercised locally — the trim script is hermetically tested (9 tests) and the workflow YAML shape was hand-validated, but the workflow's first LIVE run happens at the next version tag. Not yet claimed validated.**

### Fixed

* **Stop hook's `hookSpecificOutput.additionalContext` forced a phantom second turn that discarded a `-p --output-format json` session's `result` field (board #82, one-flock class, CoalLedger `78905f1` shipped the proven fix shape first).** `hooks/rot-canary-stop.js`'s memory-drift note now emits via `systemMessage` instead — the note still reaches the session transcript / an interactive user, with no forced second turn. **Scoped to the Stop call site only** (`rot-canary-stop.js:625`): `hooks/coalmine-conductor.js`'s identical `hookSpecificOutput.additionalContext` shape is SessionStart/UserPromptSubmit, confirmed unaffected by the platform's own behavior and deliberately untouched. Firing conditions unchanged (`memoryDriftNudge`, the `.memmoved`/root-`MEMORY.md` gate). Regression-guarded: the hermetic Stop-hook tests now assert `systemMessage` present AND `hookSpecificOutput` absent.

### Security

* **Two holes closed in the conductor's `updateMode` safer-value-wins clamp (board #112) — a project-only config could silently escalate to `auto` (a standing-consent network update-check), unchallenged.** `hooks/_shared/node-config.js`'s `loadCfg()` (synced into all three hook files) previously skipped the clamp entirely whenever the global config was absent, treating "no global" as "project free" — the common case, since most users never write `~/.claude/.coalmine.json`. Fixed: an absent/unset global now reads as its schema-declared default (`ask`), never "anything goes" — mirrors the fix already shipped in CoalWash's `mergeSafety`/`config-load.mjs` and CoalBoard's `hooks/coalboard-conductor.js` SAFER\_ENUM (both re-audited at their CURRENT state before writing this, since `hooks-safety.md` §9 itself records that its own named exemplar shipped this same hole after five rooms had already copied it). Second: the ordered-enum lookup compared raw case, so a project value in a different case than the lowercase enum (e.g. `AUTO`) missed the lookup and fell through unclamped (the CW H5 shape) — both sides are now lowercased before comparing. `updateMode` is the only consent-bearing key THIS FIX clamps; `scanExcludePaths`'s UNION-merge guard and `autoFixMode`'s documented no-hook-consumer exception are untouched. **Not closed by this fix, named as a follow-up (`MEMORY.md`): `enableConductor`, `disabledCanaries`, and `rotCanaryMode` are three more hook-read gates that go through the same plain shallow-merge with no clamp at all — a project config can flip `enableConductor` back on, re-populate `disabledCanaries`, or re-arm `rotCanaryMode` past an explicit global `off`, unchallenged.** **The PowerShell twin (`alt/powershell/`) carried the SAME class, worse: `Load-CoalmineConfig` (`hooks/_shared/ps-config.ps1`, synced into both PS hooks) returned the RAW project config on an absent global — `if (-not $globalCfg) { return $projectCfg }` — with zero merge or clamp applied at all, not even the shallow-merge loop.** Also had the identical case-fold hole: `[array]::IndexOf` on .NET strings is ordinal (case-sensitive), so a project `AUTO` missed the lookup the same way the Node CW-H5 shape did. Both early returns removed — the merge always runs now — and the same absent-global-reads-as-schema-default + case-fold-both-sides shape shipped in Node above was ported to PS (named-divergence implementation, not a literal port; no PS test harness exists in this repo — verified live in an isolated sandbox by dot-sourcing `ps-config.ps1` against both defect scenarios and three regression scenarios, confirmed red against the unmodified file first).

## \[3.16.0] - 2026-08-09

### Added

* **Per-project `.coalmine.json` moves under an agent dir — namespace campaign #69+#39, owner-designated 2026-08-08, CoalMine sets the flock's canonical wording.** New read order, a RAIL identical across the series: (1) `<project>/.<the running agent's own dir>/coal/coalmine.json` — CoalMine activates only through Claude Code's own hook system (SessionStart/PostToolUse/Stop, plus the AG/Gemini/FileCopy adapters riding the same files), so "own dir" collapses onto `.claude`; (2) other known agent dirs, fixed order `.claude` → `.agents` → `.gemini` (first found wins); (3) LEGACY: `<project>/.coalmine.json` at the project root (the pre-2026-08-08 shape) — still read normally, no breakage for an existing config. New shared functions `projectConfigCandidates`/`projectConfigPath` (`hooks/_shared/node-config.js`, synced into the three hook files; `scripts/lib/config-paths.mjs` for the ESM scripts side, imported by `configure.mjs` and `install.mjs`). `findGitRoot`'s marker set widened additively (the three new-shape candidate paths + the legacy dotfile, alongside `.git`) so a project configured ONLY through the new shape still anchors at itself instead of falling through — same class hooks-safety.md §8 (the phantom-slug law) already names for a wrongly-anchored state root. Move-on-CONFIG-WRITE-only (Phoenix #5, a hook never moves state on a mere read): `configure.mjs` now reads via the candidate order and writes back wherever the config was found, EXCEPT a config found at the legacy location migrates on that write, and the legacy file is removed only after the new-home write succeeds (no-old-version-leftover) — a config found at another new-shape candidate (e.g. `.agents/coal/coalmine.json`) is never force-migrated between agent dirs. `install.mjs`'s `copyDefaultConfig` — the installer's own default-config writer, previously undocumented as a second config writer alongside `configure.mjs` — now checks the same candidate order before deciding whether to write a default, and a never-configured project gets the new shape instead of the retired root dotfile, so a fresh install stops perpetuating the shape this campaign migrates off; an already-configured project (at any candidate, including legacy) is left untouched. **Fresh-default / migration write target (findings-back, INSPECT MEDIUM 2, 2026-08-08): both writers target `ownDirDefault(root)` — the FIRST agent dir the project already has on disk (`.claude` → `.agents` → `.gemini`), never a bare `.claude`.** A project that already uses only `.agents/` or `.gemini/` (no `.claude/`) gets its fresh or migrated config there, so this migration never plants a foreign `.claude/` into a non-Claude-Code project; a project with no agent dir at all still defaults to `.claude/coal/coalmine.json`, unchanged from before this fix. The `alt/powershell/` twins are named-divergent (Node-hook-only): they still read only the legacy root `<gitroot>/.coalmine.json` path. The safer-value-wins clamp semantics (`SAFER_ENUM`/`updateMode`) are UNTOUCHED — only the file's address moved.
* **The self-update throttle stamp moves to `~/.claude/coal/coalmine/update-check`** (`hooks/coalmine-conductor.js`), migrated off the pre-campaign `~/.claude/.coalmine-update-check` the same read-new/fallback-old + write-new/delete-old shape CoalWash's `caliper.mjs` already ships for its own equivalent stamp — `readUpdateStamp` checks the new location first, falls back to the old root stamp; `writeUpdateStamp` writes the new location (crash-safe tmp+rename, unchanged) then best-effort deletes the old root stamp.

A user can now configure CoalMine from whichever agent dir their project already uses instead of a bare root dotfile, and an existing install keeps working with zero migration action required — MINOR-minimum per `scripts-quality.md` §3's decisive test.

## \[3.15.0] - 2026-08-07

### Added

* **Seven canaries adopt Claude Code's `ReportFindings` panel as their reporting surface — call-if-callable, fallback-to-text-on-anything-else.** Panel-decision study (recorded in `4ab4ef9`'s commit message): the panel wins UX/lifecycle/noise/anti-duplication (click-to-file, walk-through-in-diff, fixed/skipped/no-change marks on re-report, out of the chat transcript, no-duplication built into the tool contract); text wins severity vocabulary/SUSPECTED-discipline/universality — both encodable onto the panel, so the panel wins the tiebreak. The branch collapses to one sentence: call `ReportFindings` when callable; not callable (tool absent, call fails) → the existing text table, unchanged, no host-detection, no request-sensing prose. Shape: severity prefixed into `summary` (`[HIGH] …`), ranked most-severe first, SUSPECTED as `verdict: PLAUSIBLE`, confirmed as `CONFIRMED`; chat carries only the wrap-up line (counts · coverage gaps · overflow past the panel's 32-finding cap) + the choice-gated fix menu — never a restatement of a finding already in the panel. **Wires three real functions, not display-only** (panel-decision §6): **F1** click-nav/diff-walk needs precise coordinates, so every finding's `file` + 1-indexed `line` must be the defect site, not the enclosing function — an unresolvable line is reported with its best guess and named imprecise in the wrap-up, never dropped and never faked. **F2** an Apply-fixes click is consent to the SAFE-fix class only, composing with (never bypassing) each canary's existing checkpoint→apply→build/test→revert-if-red discipline; the never-auto-fix list is unchanged and those items are skipped with reasons. **F3** after any fix round (menu- or button-driven), re-report the same findings with `outcome: fixed`/`skipped`/`no_change_needed` — the panel then marks each row; a round that skips this is unfinished, same force as "pushed is not a report". New shared partial `skills/_shared/reporting-footer.md` (say-it-once — one canonical block, seven canaries point at it via a new `<!-- SHARED:REPORTING_FOOTER -->` marker — named to match the file→marker pattern the other three partials already follow (`escalation-footer.md`→`ESCALATION_FOOTER`) — wired into `render.mjs` alongside the three existing SHARED markers), placed after each canary's Output section: `rot-canary`, `drift-canary`, `resilience-audit`, `scale-canary`, `supply-chain-audit` (whose `file` maps to the manifest/lockfile that named the package, per a skill-specific line in its own `SKILL.md`), `telemetry-canary`, `testability-canary`. **`gold-standard` and `source-grounding` do NOT get it** — their Output sections don't map onto the tool's per-defect schema (`file`/`line`/`summary`/`failure_scenario`/`category`/`verdict`/`outcome`): `gold-standard`'s Output is a 5-part AUDIT REPORT (bar/scorecard/percentages/gaps/verdict), not per-defect findings; `source-grounding`'s Output is verified/unverified annotations with no `failure_scenario` or severity at all. Forcing either into the panel schema would misrepresent what they produce. A user can do something they couldn't before (panel navigation + fix lifecycle tracking on the seven finding-shaped canaries) — MINOR-minimum per `scripts-quality.md` §3's decisive test.

### Changed

* **`rot-canary`'s default scan scope is code files only, for every SCOPE value — not just the auto-triggered touched-files path.** The touched-files auto-scan was already code-only via `watchedExtensions` (`hooks/rot-canary-touch.js` never records a non-code extension, and `MEMORY.md` is separately marker-only, never scanned). The manual `diff` / `named files` / `whole repo` scopes carried no equivalent boundary — `SKILL.md` never said a whole-repo DEEP sweep should skip docs/prose/config-prose. Added a `FILE TYPES` line to Parameters stating the same code-only default, matching `watchedExtensions`, for all four SCOPE values; a user naming non-code files explicitly still gets them scanned. Docs/prose/config-prose stay CoalLedger's axis (its own factory-scope ruling, the twin of this one).

### Fixed

* **`source-grounding`'s Degrade-paths ledger dropped its `lane` column — the axis was wrong, not merely mislabeled.** `SKILL-VARIANCE-WALK.md` §Run 43 (post-membership-test-fix re-walk) found Hook Q4 bimodal — five readers said 2, four said 4, nobody said 3, the pre-registered key. The two camps agreed on the same two ledger rows and diverged only on whether to count undeclared branches found elsewhere in the body; the `2` camp excluded D1 by its own stated condition (`non-interactive`) even though D1 was labelled `Hook only` — the previous round's fix had moved D1 from one wrong axis (universal) to another (lane) rather than to its real one (interactivity, a mode, not a lane). Per the chair's ruling, the condition column already is a self-sufficient predicate for every row (verified against the whole file, including cross-reads against G1/D4) and the lane column was a redundant derived second axis — deleted rather than relabeled again. Column header renamed `condition` → `fires when:`, matching the Consent-gates ledger's own `when it fires` column so the two ledgers share vocabulary. The prose paragraph below the table was rewritten short: dropped the now-pointless lane-justification text, kept the three things that were never about lane — D2's four-site restatement, the Freshness-cap exclusion, and the vacuous Fix-mode note — and closed the completeness gap Run 43 also located: D3's own single-site restatement (the shared Escalation footer's `…none → numbered text menu`) is now named alongside D2's, with an explicit "neither is a new branch" so a reader hitting that phrase in the shared file doesn't manufacture a fifth row. `orchestration.md` and `escalation-footer.md` — shared across all nine canaries, confirmed by grep — were not touched; `gold-standard` runs its own D1–D6 numbering over the same shared text (its D3/D6 are byte-identical in branch and condition to this skill's D2/D3 — only the row numbers differ, not the content), so no skill-specific row ID was hardcoded into the shared files. The ledger's divergence from skill-authoring.md §3b's column-or-lane-ledger rule is now named in-body with its measured reason (Run 43's bimodal Hook-Q4 split), and the Fix-mode/D4-footer completeness gap Run 43 also implied is closed with two named phrases. Body bytes (`Buffer.byteLength`, not JS `.length` — this body's curly quotes and `⚠️`/`✅` glyphs are multi-byte in UTF-8): source 5,766 → 5,501 (−265 net across both fix rounds), shipped 9,340 → 9,075 (−265, identical delta).
* **`skills/_shared/reporting-footer.md` carved three rails a §3b variance walk found wobbling.** `rot-canary`'s Hook-lane readers (weak-tier, 3 independent rounds) split on whether an unresolvable finding line is "never faked" as well as "never dropped" (1 of 3 dropped it), whether an Apply-fixes click still applies during a non-interactive Hook-Context auto-scan (1 of 3 gave an incomplete account), and whether skipping the post-fix outcome re-report leaves the round unfinished (2 of 3 omitted it); its Agent lane split once on whether "the fix menu" is part of the wrap-up line's own membership (it is not — the text lists it as a separate item). `supply-chain-audit`, sharing the identical text, read clean on all five rails both lanes — a plausible but unproven mechanism is Hook Context sitting immediately after its Reporting paragraph, with no Cadence/Tooling section in between as `rot-canary` has. Carved the shared block: split "never dropped and never faked" into its own clause, named the Hook-Context/interactivity gate on the Apply-fixes rail inline instead of relying on the reader connecting to a separate paragraph, and front-loaded the outcome-skip consequence in bold. Re-walked both wobbling cells post-carve: zero variance, all five rails correct, no strong-tier escalation needed. Full record: `de45ab0`'s commit message.

## \[3.14.3] - 2026-08-05

### Changed

* **`source-grounding`'s consent gates, prohibitions, and degrade paths now have a countable home instead of scattered prose — the third canary carve, and the first made on measured ground (`SKILL-VARIANCE-WALK.md` §Run 39/40/41).** Post-fix-prohibition-scoping (`v3.14.2`), the un-carved rails — prohibitions and degrade paths — were the worst in both lanes (55.6%–66.7%), exactly as the two prior canaries' baselines predicted, while the config-key rail held its now-four-time reproduction at 0.0% (Agent) — the shape this carve copies. Added three declared ledgers to the body: **Consent gates (G1–G4)** with a per-lane column (G1, the tier pick, is the only Agent-only row — matches the scoring key exactly, Agent=4/Hook=3) · **Prohibitions (P1–P6)**, atomizing every "never"/"don't" clause in the body and the shared partials, with the shared footer's fix-prohibition explicitly excluded and named — this skill defines no Fix mode section, so that clause is vacuous here, not merely redundant (the distinction `gold-standard`'s own carve got wrong) · **Degrade paths (D1–D4)** (lane split corrected below, post-walk). `.coalmine.json` `trustedDomains`/`defaultTier` — the positive control, reproduced 0.0%/2-of-9 across three independent draws — are untouched, byte-identical. Lean pass alongside the carve: the Source hierarchy's per-rank justification glosses (why each rank sits where it does) moved to `references/sources.md`, which already carried the more detailed per-claim-type source map — the body keeps the bare ranked list (a rail: which source to prefer) and points to the reference for the rationale (an explanation). Nothing behavioural dropped: every ledger row points back to (or restates) an existing sentence. **One de-duplication was needed, the same shape as `gold-standard`'s: the shared "degrade gracefully / never fake parallelism" rule is stated at four sites in the shared partials — one row (D2), four mentions, noted in the body so a reader counting mentions doesn't overshoot the declared total.** One rail relocation: `— never block` moved out of the "Non-interactive runs" sentence and into D1's own row, still in the body — a rail moving into a countable home, not a drop. Body chars, before this paragraph's fix: source 2,366 → 4,719 (+2,353), reference 1,904 → 2,625 (+721) — prose that left the body landed in the reference, not the void; shipped 5,940 → 8,293 (+39.6%).

**A post-carve walk (`SKILL-VARIANCE-WALK.md` §Run 42, both lanes, judged against the record's own published noise floor) found two ledger defects — and confirmed the carve is working: Q1 and Q2 both hit 0.0%, 9 of 9, in both lanes; ledger uptake 18 of 18 (every walker reasons in row IDs, not paraphrase); the phantom fix-gate stayed dead at 0 of 18.** The two defects are the ledgers converting vague prose into a falsifiable claim, and the walk falsifying it — prose could not have been wrong this precisely. **Output declared 3 "annotation forms", the third being "stable fact → no annotation needed" — the absence of an output, counted as one.** 0 of 9 Agent walkers matched 3; modal was 2, both lanes. Fixed by stating the membership test in the body (*a location is a place this skill writes something a reader can see; the absence of an annotation is not one*) and recomputing the total to **2**. **D1 was labelled `universal` and cannot fire in the Agent lane** — its condition is non-interactive, and Agent Context is interactive by definition; a walker quoted verbatim excluding it for exactly that reason. Relabelled `Hook only`. Auditing every remaining D-row the same way (not just the one measured wrong) found a second, unmeasured mislabel: **D2** (no capability lever for the target tier) can likewise never fire in Hook, since Hook is unconditionally fixed at Light and Light needs no lever — relabelled `Agent only`. D3 was checked and confirmed correctly `universal` (it fires wherever `ask_question` fires: Agent always, Hook when interactive — the same shape already established for G2–G4, not a new one). The G- and P-ledgers were audited for the same class and found clean: G2–G4 already carry (or now carry, via a added footnote) the interactive-Hook caveat that keeps their "both lanes" label accurate, and every P row is a lane-independent negative constraint, not a positive branch, so the "fires only in one lane" confusion does not structurally apply to prohibitions. Body chars after this fix: source 4,719 → 5,766 (+1,047), shipped 8,293 → 9,340 (+1,047, identical delta).

## \[3.14.2] - 2026-08-05

### Fixed

* **`v3.14.1`'s escalation-footer reword removed the phantom fix-menu OFFER but left its PREMISE standing one clause to the right.** The Hook Context sentence's trailing `Never fix without a chosen option.` presupposes that this skill fixes something — true for the seven canaries with a Fix mode section, **true but redundantly carried for `gold-standard`** (it fixes code too, via its own CONFORM act — "checkpoint → one fix → build+tests → revert if newly red" — gated independently at G3, P2 and P7, so the prohibition already binds there through those rows without this clause's help), and false only for `source-grounding` (no fix path at all). Measured (`SKILL-VARIANCE-WALK.md` §Run 39, `source-grounding`, post-reword): 0 of 3 weak-tier walkers manufactured a gate from the reworded offer clause, but **all 3 strong-tier walkers counted a 4th consent gate from this trailing prohibition**, one naming it verbatim as `Applying any fix | any point a fix would be made in Hook Context`. A prohibition about fixing is itself an assertion that fixing is on the table. Reworded to `Where a Fix mode section exists, never fix without a chosen option.` — same clause shape as the sentence before it (`if it defines one`), so it stays a live rail for the seven skills that fix and asserts nothing for the two that don't. Verified true for all nine by checking `## Fix mode` heading presence directly (`grep -c "^## Fix mode" skills/*/SKILL.md`): 7 present (rot-canary, the four Agent-scoped, the two lane-silent), 2 absent (gold-standard, source-grounding) — the conditional resolves live for the first group, vacuously true for the second, matching each skill's actual behavior (`gold-standard`'s absence here is covered by its own G3/P2/P7 redundancy, not by having nothing to fix). **Known limitation, not fixed here:** the conditional discriminates on a heading (`## Fix mode`) rather than on fix capability — `gold-standard` is the proof the two can differ, since it fixes code without carrying that heading. A future canary that fixes without a `## Fix mode` section and without its own gate ledger would be silently released from this footer's only fix prohibition, and nothing in the repo would flag it. Scoping the conditional to capability rather than heading presence is a shared-file change that needs its own variance walk — out of scope for this unit. Two more fix-presupposing sentences in the shared partials were swept and named, not changed: `language-header.md`'s "menu labels" is a localization instruction listing output kinds, not a rail asserting a menu exists; the tier rubric's "findings will drive code changes" is a scoring condition ("if they will"), not an assertion that this skill changes code — neither forces behavior a skill without a fix path doesn't already correctly skip. `orchestration.md` swept clean. Reaches all nine shipped skill bodies (`build-plugin.mjs` re-render).

  **Reconciling `b6b289a`'s commit type against its release:** that commit is typed `feat(gold-standard):`, and conventional-commits maps `feat` to a MINOR bump — but its change was classified `### Changed` here and shipped as PATCH `v3.14.1`. The CHANGELOG classification is the correct one: the carve gave existing rails (consent gates, prohibitions, degrade paths) a countable presentation and added no new user-facing capability — `refactor` or `docs` would have been the accurate commit type. Recorded here rather than rewritten into history.

## \[3.14.1] - 2026-08-04

### Fixed

* **The shared Escalation footer promised "offer the fix menu" in Hook Context — a promise defensible for only 3 of the nine canaries.** `rot-canary`'s own Fix mode section explicitly covers "hook-nudged auto-scan", so the promise held there. `resilience-audit` and `supply-chain-audit` state no lane at all for their own fix menus, so an unqualified promise held for them too. The other six did not: four (`drift-canary`, `scale-canary`, `telemetry-canary`, `testability-canary`) explicitly scope their fix menu to "In Agent Context, after the report..." — directly contradicting the footer's Hook-lane promise. `gold-standard` defines no fix menu at all — it gates ADOPT and CONFORM through `ask_question` instead, a different mechanism than a post-report menu. `source-grounding` defines neither a menu nor a gate the promise could refer to. Measured live: 2 of 3 weak-tier readers of `gold-standard`'s pre-carve body seated the phantom menu as a consent gate and dropped two real ones to make room for it. Reworded the footer's Hook Context sentence to stop asserting a fixed lane and noun, and defer to each skill's own Fix mode section instead: *"Interactive session (a user is present) → follow this skill's own Fix mode section, if it defines one, for what to offer after the report; non-interactive → report-only."* True for all nine simultaneously — the three where the old promise held keep the same behavior, and the four Agent-scoped canaries' hook lane is now **definitively offer-free** (previously undefined between what the footer said and what each skill's own body said), resolved in favor of each skill's own text. Reaches all nine shipped skill bodies (`build-plugin.mjs` re-render); `gold-standard`'s own shipped body grew by 60 chars from this shared change alone (13,220 → 13,280 chars, projecting to a **4,622–4,767 token band** at this skill's own measured ratio's rounding — **4.7%–7.6% headroom** against the \~5k ceiling, narrower than the carve baseline recorded below, from this shared edit, not a new carve). A same-day fix closed a contradiction this left behind: `gold-standard`'s own Degrade ledger still carried a numbered `D5 — offer the fix menu after the report` row the footer no longer states and this skill has no Fix mode section to justify — merged into the report-only branch, ledger renumbered D1–D6 (detail in the ledger entry below).
* **`rot-canary`'s fix-mode section never ordered the agent to read `.coalmine.json`, and a first attempt at the rail got the config's own cascade wrong.** The section made the fix menu conditional on `autoFixMode` and called the config "standing consent" without a step to actually read it, or a stated fallback when the file is absent. Added the missing rail: read the global `~/.claude/.coalmine.json` then the project `<gitroot>/.coalmine.json` (project wins per key, matching every other consumer of this file) before deciding fix mode; neither present → `autoFixMode` = `interactive`. A same-day fix corrected the rail's first wording, which claimed a bare repo-root read with "absent → every key defaults" — false whenever a global config exists, which would have silently overridden a user's standing `autoFixMode: off`/`safe` back to `interactive`. Found by an internal variance walk of a CoalMine canary.

### Changed

* **`gold-standard`'s consent gates, prohibitions, and degrade paths now have a countable home instead of scattered prose.** An internal variance walk (nine independent readers per lane, two lanes) measured those three rails wobbling 66.7%–77.8% while a fifth rail naming exactly one config key in one bolded sentence measured 0% — the same readers, the same file, at the same blob. Added three declared ledgers to the body: **Consent gates (G1–G6)** with a per-lane column (the Hook lane silently suppresses the model-tier question; that exception now has a column instead of a paragraph a reader had to resolve on their own, and a footnote states the column assumes an interactive Hook session) · **Prohibitions (P1–P15)** · **Degrade paths (D1–D6)**, split into 4 universal branches and 2 Hook-lane-only branches (the Hook lane's own extra degrade branches were previously ungrouped with the universal ones, which is why that rail read worse from inside the lane it addresses). The existing numbered Output section is now declared at 5 locations and scoped to the AUDIT report only, so a rule/tombstone/issue write elsewhere in the body can no longer be miscounted as a sixth output location. No rail was removed — every ledger row points back to (or restates) an existing sentence; three pairs of restated sentences (a retired-rule rule, a RE-VALIDATE choice-gate rule, an unsourced-exemplar rule) are called out as one row each rather than two, and a fourth rule (degrade to model tier + reasoning, restated at four sites) is one row, four mentions. A same-day fix added a degrade row (falling back to a numbered text menu when the host has no question tool) that the first pass had left untagged, and corrected the wobble range above (55.6% belonged to a fourth, uncarved rail, not to the three named here). A second same-day fix, downstream of the shared-footer wording change described above, merged what was a separate "offer the fix menu" Hook-lane row into the report-only branch — this skill has no Fix mode section, so once the footer stopped asserting a fixed offer, that row and the report-only row described the same outcome — and renumbered the ledger to D1–D6. **Carve baseline for the regrowth ratchet (skill-authoring.md §5(2)): 13,220 shipped chars, \~4.7k tokens projected on-invoke at this skill's own measured ratio (0.353 tok/char, `claude plugin details`) — \~7% headroom against the \~5k body budget. `gold-standard` is now the largest canary body in the suite; the next carve of this size on this skill would breach it.** The shared-footer defect noted here (the Hook lane's escalation footer promised a "fix menu" this skill does not define) is fixed in the shared-footer entry above, its own unit.

## \[3.14.0] - 2026-07-30

### Added

* **`scanExcludePaths` — a lab-tooling scan-scope exclude for the session-end auto-scan** (USER standing rule: skip throwaway lab tooling, never skip shipped/tracked code). A new `String[]` config key of path fragments/wildcards (`*`), filtered out of the touched-files list `hooks/rot-canary-stop.js` builds — **before** the `autoScanFileCap` slice, so an excluded scratch file never consumes a cap slot a real file could use. When anything is skipped, the nudge appends a translated skip-count clause (all 5 languages) — anti-cry-wolf: the user sees autopilot is still running, not dead, rather than a silently shorter file list. Global + project `.coalmine.json` layers **UNION** (a project may only add exclusions, never drop a global one — the same safer-value-wins direction as `updateMode`), and fragment matching normalizes `\` to `/` first so a POSIX-style fragment (the documented, portable form) still matches a Windows-separated touched path. Matching runs through a **linear segment matcher** (split the fragment on `*`, walk the literal segments with `String.indexOf`) rather than a hand-built regex — the standard O(n) algorithm for `*`-only globs, chosen specifically because it has no backtracking and therefore no ReDoS surface, unlike an earlier internal iteration of this feature. PowerShell fallback twins do not implement this key (named divergence, `alt/powershell/README.md` — same precedent as `memoryDriftNudge` and the `os.tmpdir()` exclude). +9 hermetic tests (suite 141 → 150).

### Fixed

* **The universal installer now writes its git hooks where git will ACTUALLY read them.** `installGitHooks` hardcoded `<gitDir>/hooks` and never consulted `core.hooksPath` — so on any repo that moves the hooks directory (husky v9+, lefthook, the pre-commit framework, or a project keeping tracked hooks in `.githooks/`) the installer printed `installed git hook: …` while git ran nothing: a success message over an inert gate. It now resolves `core.hooksPath` first (a relative value against the worktree root, which is where git runs hooks from) and falls back to `<gitDir>/hooks` when it is unset, unreadable, or no `git` binary is on PATH. `--uninstall` uses the same resolution, so it can find what it installed. Handling of a `.git` FILE (worktree/submodule `gitdir:`) is unchanged.
* **A CoalMine hook installed before v2.4.0 is no longer mistaken for the user's own.** The ownership check keyed on `# Generated by CoalMine`, which only exists since v2.4.0; every earlier hook carries `# CoalMine <name> hook` instead, so it was filed as foreign and copied to `<hook>.pre-coalmine` — and `--uninstall` would then hand that obsolete CoalMine gate back to the user as if it were theirs. Both install and uninstall now share one header-bounded ownership test that recognises every version we have shipped, uninstall discards such a backup rather than restoring it, and a hook that merely *mentions* CoalMine is still treated as the user's. Related: when the `.pre-coalmine` slot is already occupied the installer now refuses that hook and exits non-zero instead of overwriting a foreign hook with no backup (the same rule as the foreign-skill-dir guard). +3 integration tests (suite 138 → 141).

### Changed

* **\[developer tooling — nothing shipped changes] this repo's own git-hook source moved to the flock-canonical `.githooks/`** (`hooks/pre-commit.sh` → `.githooks/pre-commit`, `hooks/pre-push.sh` → `.githooks/pre-push`, tracked executable, pinned to LF in `.gitattributes`). Both mechanisms cost a contributor exactly one command per clone, but the previous one was the full multi-agent installer — disproportionate for someone who only wants the commit gate; `git config core.hooksPath .githooks` is now enough. Hook content is byte-identical, PowerShell-parity legs included. Side effect: `hooks/` now holds only the hooks that ship.

## \[3.13.0] - 2026-07-27

### Added

* **The size tripwire now honors a declared over-run** (`hooks/rot-canary-touch.js`): a source file crossing `tripwireMaxLines` with a top-of-file declaration comment — `// ponytail: <N> lines at declaration — <why>` (or the pre-existing `waiver:` idiom; the discriminator is the `<digits> lines` payload) — is no longer flagged. Implements the amended file-size rule (2026-07-26): **800 is a review signal, not a cap — the finding is an UNDECLARED over-run.** The N is history, deliberately never compared to the current count (a drifted number must not reopen the finding). The declaration must sit in the first 30 lines ("top-of-file"); it silences ONLY the size smell — merge-conflict detection and the `.touched` stop-scan recording are unchanged. An undeclared over-run is still flagged exactly as before. The declaration match is bounded to the first 2048 characters of a line (mirroring the conductor's `STAMP_WINDOW`): the pattern backtracks quadratically in line length, and a poison line was reachable at shipped defaults (a 99.2 KB file sits under the 100 KB scan cap) — measured 5424 ms unbounded through the real hook, against a Phoenix #3 budget of ≤100 ms including the scan. Bounded, the same fixture runs in \~59 ms, versus \~50 ms for a benign file of the same length.

### Fixed

* **\[developer tooling — nothing shipped changes] two more swallowed-enumeration holes closed, both surfaced by the review of the fix below.** (1) `scripts/lib/manifest.mjs`'s `hashInstalledTree` swallowed **two** failures while building the install manifest — a `readdir` failure (whole subtree omitted) and a per-file hash failure (one file omitted). The amplifier is that this walk writes its blindness into a **persistent artifact**: `verifyAgainstManifest` never walks the disk, it iterates only the recorded keys, so an omitted file sat permanently OUTSIDE the integrity net and later tamper on it reported `ok` with no trace at either end — a false "verified clean", the worst outcome an integrity check has. Both now propagate, and `writeManifest`'s existing catch turns the throw into the house `[warn] could not write install manifest` + `process.exitCode = 1` with **no manifest written** — which makes `verifyAgainstManifest` report an honest SKIP rather than a clean pass forever. Unlike the rule-home walk there is no legitimate-absence carve-out to preserve: every name reaching this walk was just written successfully. The check's other, structural ceiling is now stated where it lives — a hash list proves the RECORDED set only, so a file ADDED after install has no entry and is invisible to it. (2) `checkVersionPins` gives the `.github/ISSUE_TEMPLATE` `readdir` the same two-absences split: an **absent** template dir stays silent (the common case, and this check rides the commit gate) while an **unreadable** one now FAILs instead of passing the pin gate over templates never opened. +2 unit tests (suite 135 → 137).
* **\[developer tooling — nothing shipped changes] the doctrine-mirror check no longer reports agreement over a rule home it could not READ.** The previous fix hardened the `stat` PROBE; the directory walk still swallowed a `readdir` failure, so an unreadable tree yielded an empty file list and two genuinely diverged rule homes passed with **zero files compared** — the same fail-open hole one level down (POSIX `chmod 0100` is exactly that state: stat succeeds, readdir does not). A guard rests on TWO capability checks — the probe and the enumeration — and hardening one leaves the other. The walk now classifies the same way (ENOENT/ENOTDIR = the directory really is not there; anything else fails loud), and because both the mirror check and the rule-stamp check share one walker, the single guard covers both. A **dangling link** at a rule home is now malformed rather than absent as well: `statSync` follows the link and reports the missing target as ENOENT, so only `lstat` separates "no rule home here" from "a broken one" — the regular-file bypass closed above, wearing a different entry type. `scripts/consistency.mjs` sets `process.exitCode` instead of calling `process.exit()` (`node/runtime.md` §7 — an exit can truncate a pending stdout write, i.e. drop the very FAIL lines a gate exists to print) and renders a crash as a finding, so all three paths share one output shape. +2 unit tests (suite 133 → 135).
* **\[developer tooling — nothing shipped changes] `scripts/consistency.mjs`'s doctrine-mirror check now ENUMERATES the rule tree instead of comparing a hard-coded pair list.** The list named 2 files, so every other rule in a populated rule home — `common/`, `node/`, `typescript/`, the other two `domain/` files, `RETIRED.md` — was never compared and the check reported agreement over them. `.claude/rules/ecc/` and `.agents/rules/ecc/` are what Claude and the non-Claude agents respectively read, so an unmirrored rule means the two populations run different rulebooks, invisibly from both sides. The check distinguishes THREE outcomes, not two: a whole counterpart tree genuinely absent stays silent (a clone that never installed that rule home); a tree that is present FAILS on content divergence, and — hardened further in the two entries above — also FAILS if it cannot be read or enumerated at all (unreadable is not the same as absent, and was silently treated as absent before). Comparison runs both directions and stays CRLF-normalized. The rule tombstone ledger is no longer a mirror candidate: since 2026-07-27 (`skill-authoring.md` §6) it lives as one un-mirrored copy outside both trees, so a copy found INSIDE either tree is now intercepted BEFORE the mirror verdict and FAILs outright — the two verdicts this check could otherwise reach were both wrong (mirrored = passes silently, the invisible regression; one-sided = reads as UNMIRRORED, whose remedy is exactly backwards). +2 unit tests (suite 128 → 130); the tombstone intercept above is a separate, later fix with its own +1 (suite 137 → 138, the final count at this HEAD).

### Changed

* **Test files are out of scope for the size tripwire** (source files only, per the same amended rule — a test file's cohesion unit is the module under test): basename conventions (`.test.` / `.spec.` / `test_` / `_test.`, delimiter-anchored) and test-directory segments (`test`/`tests`/`__tests__`/`spec`/`specs`, consulted only below the project root so an unlucky ancestor path cannot retire the tripwire). The directory-segment compare canonicalizes both the project root and the file's directory (`realpathSync.native`) before comparing — a kernel-resolved spelling on one side only (macOS's `/private/var/...`, a Windows 8.3 alias) could otherwise make a file genuinely inside the root register as outside it, which is exactly how this exemption first shipped CI-red on macOS. Test files are still recorded and scanned at stop; only the size smell is exempt. The PowerShell fallback twins keep the plain count (named divergence, `alt/powershell/README.md` — and porting the declaration regex would replicate its ReDoS surface into a second implementation). +6 hermetic spawn tests (hooks suite 52→58), including an executed both-directions proof (declared → silent, undeclared → still flagged, accept-all sabotage reddens the suite), the suite's first latency assertion, and a reproduction of the macOS canonicalization gap on an unprivileged symlink (skips visibly where the volume cannot make one).

## \[3.12.4] - 2026-07-25

**PATCH** — a pattern-conform pass: doc claims re-grounded against the code, and one gold-standard rationale paragraph moved out of the always-loaded SKILL.md body. No behavior change in any hook or script.

### Changed

* **`skills/gold-standard/SKILL.md` FILL step slimmed — the 30d/90d RATIONALE moved to `references/method.md`** (skill-authoring §5: a rail stays in the always-loaded body, an explanation moves to the on-demand reference). The rail is intact and inline — stamp each rule, 30d for fast-moving surfaces, 90d for general engineering, advisory EVENT re-validates before the calendar — while the *why* (which surfaces ship weekly-to-daily; that 90d is stricter than OWASP \~4y and NIST/FISMA annual; that the 30d stamp is only the Dependabot-pattern staleness backstop) now lives in the reference behind a four-word pointer. Shipped `plugin/` dist rebuilt to match.

### Fixed

* **`CONTRIBUTING.md` no longer claims `consistency.mjs` is part of the automatic gate.** The installer wires `verify.mjs` + the `node --test` suite into `.git/hooks`; `consistency.mjs` is a separate on-demand check. Corrected in BOTH places that asserted it (the git-hooks paragraph and the release checklist), and the gate code-block gained the `node scripts/test.mjs` line it was missing — the block listed build/verify/consistency but omitted the one command CI actually runs. Project Layout gained the undocumented `commands/` and `alt/` rows, and the dev prerequisite is now **Node 22+**, matching the CI matrix (22/24 — 18/20 are EOL and untested).
* **`SECURITY.md` SkillSpector line-reference re-synced** — the `RA2 Session Persistence` finding pointed at `hooks/rot-canary-stop.js:160`, which the v3.12.x hook edits had left on an unrelated line; it now points at `:240`, the flagged opt-out text itself. **A line-ref re-sync, NOT a re-scan** — the scan version/score pin is deliberately untouched (a pin lagging the ship version is by design; re-scan only on a new SkillSpector version).
* **`SECURITY.md` commit-signing wording corrected** — Dependabot/CI commits were described as "unsigned by design", which is wrong: GitHub signs them with its own key, not the maintainer key. The verify-the-release-tag instruction is unchanged.
* **README Antigravity badge no longer overclaims** — `validated` became `validated canaries · wired auto-cadence`, matching the honest tier split already documented in the compat matrix (the canaries are validated on Antigravity; the hook-driven auto-cadence is wired, not yet confirmed end-to-end in a live AG session).
* **`ci.yml`** **`setup-node` version comment** corrected `# v6` → `# v7.0.0` to match the pinned SHA (comment only — the 40-char SHA pin is unchanged).
* **README benchmark headline corrected — four figures were wrong against `RESULTS.md`** (found by the org-level benchmark deputy's ingest audit, SWEEP-MARKS Event-1 mark 2, wrong for 16 days). "100% on 6 of 7 suites" → **5 of 7** (rot-canary's own median is 92% on opus/haiku/AG, not 100% — `RESULTS.md:19-24`). "drift-canary is *the* discriminating suite" → **drift-canary and rot-canary** are the two that separate engines (`RESULTS.md:119` names rot-canary's `f01:5` "the original discriminator"). "82 planted-defect fixtures" → **60 planted defects + 22 clean decoys** (all 82 were mislabeled as planted; counted directly from `fixtures/`, the ground truth over `RESULTS.md`'s own slightly-off corpus-size prose). "4 engines" listing 5 names → **Haiku 4.5 dropped** from the list (it ran only the original rot-canary benchmark, not the 7-canary matrix, which has exactly 4 columns — `RESULTS.md:92`). Now matches the org landing's framing (`profile/README.md:81`), which was already correct — the 2026-07-09 landing sweep fixed that surface and never touched this one, since `verify-landing.mjs` can't see across repos.

## \[3.12.3] - 2026-07-25

### Changed

* **The memory-drift exit-gate (v3.12.0) is now QUIET and non-reporting — it is not a canary finding and no longer rides the loud rot-canary scan report.** It was welded into the blocking Stop `reason` (which triggers the scan + fix-menu); it now rides the QUIET model-only `hookSpecificOutput.additionalContext` channel instead — no severity table, no "invoke rot-canary skill", no fix menu, no blocking Stop of its own. The loud code-scan report is UNCHANGED (still blocks on the touched files when they still exist). Decoupling: the scan report keys off files that still EXIST at stop time, the drift check keys off the recorded-edit fact — so a session that edited code, deleted it, and never updated MEMORY.md surfaces ONLY the quiet note, no loud block. Detection + guards unchanged (`memoryDriftNudge` off-switch default `true`, root-`MEMORY.md`-exists probe, fires only when code moved and no `.memmoved` was recorded). `hooks/rot-canary-stop.js` restructured; memory-drift hermetic tests updated (drift lands in the quiet channel; a drift-only stop emits the quiet note alone). On Antigravity the Stop hook still emits the no-op `{}` (no Stop inject channel there, so drift is silent on AG).

## \[3.12.2] - 2026-07-25

### Fixed

* **rot-canary no longer records/scans files living under `os.tmpdir()`.** Dogfood-found: a long IC campaign writes one-shot harness `.mjs` files under the session scratchpad, which sits INSIDE `os.tmpdir()` (`AppData/Local/Temp/claude/...`) — every Stop-scan nagged on them. `hooks/rot-canary-touch.js` now checks `normF` against `os.tmpdir()` (boundary-safe, case-insensitive on win32) right after resolving the edited path, BEFORE the MEMORY.md `.memmoved` marker branch and BEFORE the watched-extension gate — a tmpdir-resident file is recorded into neither `.touched` nor `.memmoved`. Scan-scope refinement, not a security boundary: lexical resolve-and-contain (no realpath) is correct here, since a missed symlinked-temp edge just means the file gets scanned (harmless). +3 hermetic tests in `hooks.test.mjs` (tmpdir-resident code + MEMORY.md excluded · a normal project file outside tmpdir still recorded · a sibling dir merely prefix-matching the tmpdir path is NOT wrongly excluded); 8 existing tests whose fixtures lived inside the sandbox's `os.tmpdir()` were relocated to a sibling project dir (`hooks.test.mjs`'s `runHook` gained an optional 5th `cwd` param, defaulting to the existing sandbox dir — every other call site unchanged).

## \[3.12.1] - 2026-07-24

### Security

* **\[CodeQL js/insecure-temporary-file, alerts #55/#56] the `.memmoved` marker write hardened to an atomic `wx` create.** The v3.12.0 memory-drift marker used an `existsSync` pre-check + plain `writeFileSync` — a TOCTOU window, and a plain create can write through a pre-planted symlink. Now a single `writeFileSync(..., { flag: 'wx' })` (O\_CREAT|O\_EXCL): EEXIST = already recorded this session (swallowed, same idempotence), a symlink at the path is refused. The name stays sid-scoped flat tmp like the sibling `.touched`/`.smells` session state (session-UUID = unpredictable — the dismissed-FP class of the `.scanned` marker). Behavior identical; hermetic tests unchanged-green.

## \[3.12.0] - 2026-07-24

**MINOR** — the rot-canary Stop hook gains a memory-drift exit-gate advisory: a session that edited code but never touched MEMORY.md gets a one-line nudge before it ends.

### Added

* **Memory-drift exit-gate advisory** (`hooks/rot-canary-touch.js` + `hooks/rot-canary-stop.js`): the touch hook records a MEMORY.md edit (any directory, basename match) as a 0-byte `.memmoved` marker — parsed BEFORE the watched-code-extension gate so a non-code MEMORY.md edit is still caught, behavior-neutral to the existing `.touched`/`.smells` recording. At session end, if code moved this session and no `.memmoved` marker exists, the Stop hook appends ONE localized advisory line (all 5 languages) to the existing rot-canary scan nudge. Three guards keep it a true advisory: **(1)** it fires only when the project actually uses the MEMORY.md convention — a read-only existence probe for `<gitroot>/MEMORY.md` (Phoenix #10, same access class as the root `.coalmine.json` read); **(2)** the new `memoryDriftNudge` config key (default `true`) is a standing off-switch; **(3)** it RIDES the existing scan nudge — it never fires standalone, and it never blocks on its own (on Antigravity the Stop hook still emits the no-op `{}`; nothing new blocks there). NAMED v1 limitations, tracked for a future cut, not fixed here: a version-bump-only session (no watched code file touched) emits nothing; the check is session-global, not per-repo; it rides rot-canary's `auto` mode (`manual`/`off` silence it too, same as the base nudge). +5 hermetic tests in `hooks.test.mjs`.
* **`memoryDriftNudge` config key** (`config-schema.mjs`, `platform-configs/.coalmine.json`, README Configure table): boolean, default `true` — the off-switch for the advisory above.

### Fixed

* `skills/rot-canary/references/cadence.md` and `skills/_shared/references/escalation.md` freshness stamps refreshed `verified 2026-06-12` → `verified 2026-07-23` (re-read against current hook behavior; no content drift found beyond the stamp).

## \[3.11.4] - 2026-07-23

### Fixed

* **The AG adapter emits the current Antigravity inject contract** (re-derived against the current AG build's hooks doc, 2026-07-23): the conductor's PreInvocation output is now `{"injectSteps":[{"ephemeralMessage":...}]}` — the pilot-era `{"additionalContext"}` key is a dead letter on the current engine (0 hits; it never delivered, so nothing depended on it). The Stop hook now emits the explicit no-op `{}` on AG — the current engine documents no Stop-output inject channel; its side effects (ack marker + stale sweep) still run, and AG users reach findings via the manual `/rot-canary` path. CoalMine still never blocks on AG.
* **AG payload reads follow the current spec's fields**: all three hooks read the session key `conversationId`-first (legacy `session_id`/`sessionId`/transcript-path fallbacks kept); conductor + touch resolve the workspace from `workspacePaths[0]`. Docs swept to the new-contract story (`platform-configs/hooks/README.md` · `references/cadence.md` · SECURITY.md); Gemini's nested `hookSpecificOutput.additionalContext` shape is a different platform's channel and is unchanged. Tier unchanged: **wired**, not live-validated — delivery into a real AG session is still pending.

## \[3.11.3] - 2026-07-17

### Fixed

* **\[HIGH] Installer no longer delete-then-writes a directory it does not own.** A no-manifest install fell back to the hard-coded skill names and `rmSync`'d each — pointed at a directory holding a same-named foreign file, it destroyed user data (violating CoalMine's own `resilience-audit` "never delete-then-write"). `install.mjs` now proves ownership before removing anything (absent/empty · listed in the package manifest · carries our `skill-meta.json` marker) and REFUSES a foreign collision fail-loud (exit 1); an owned install still upgrades. Found by a nasa-L3 CoalBoard audit — the root spanned three delete sites, not just the one the finding named.

## \[3.11.2] - 2026-07-16

### Changed

* **HOOK-LEAN — the onboarding offer self-silences on a worked repo.** The conductor auto-suppresses its onboarding line once ANY verified gold-standard stamp exists in the repo's rule roots (`hasVerifiedStamp` — a cheap first-hit existence scan; `skipOnboarding: true` still forces it off). Suppression is decided against the SAME cwd each mode's own revalidate scan already uses — payload `cwd` on AG/Gemini (whose hook process may start off-workspace), `process.cwd()` on CC/file-copy — via a shared `buildLines` helper (a wrong-cwd check could keep re-offering onboarding on an already-verified repo). CC output byte-identical for un-stamped repos; +1 hermetic regression test (payload-cwd suppression), 41 hook tests.
* `verify.mjs` gains the flock `DESC_CAP` gate: every `skills/*/SKILL.md` + `commands/*.md` frontmatter `description` (+ `when_to_use`) ≤ 1024 chars — cross-platform-safe cap (agentskills.io); CC's own listing truncation is 1536 combined (verified 2026-07-16). New `scripts/lib/desc-cap.mjs` + 7 unit cases (incl. the >1024 negative path), registered in `test.mjs`. USER lock, past/present/future — one flock with CT v1.2.3 / CB v1.7.5.

## \[3.11.1] - 2026-07-15

**PATCH** — security hardening follow-up to v3.11.0: closes the dir-symlink residual on the AG conductor's tmp marker.

### Security

* **Marker subdir hardened against a pre-planted symlink** (`hooks/coalmine-conductor.js`): `mkdirSync(recursive)` silently follows a symlink pre-planted at the marker subdir (the `0o700` mode is not applied to a pre-existing dir), so the `wx` marker could write through it into an attacker-controlled location. An `lstatSync` no-follow check now rejects a symlink subdir and fail-closes (skips the emit) — one-flock with CoalHearth v1.3.2 and CoalFace v0.3.2. Completes the CodeQL `js/insecure-temporary-file` mitigation the v3.11.0 wx-latch began. Tests 39/39.

## \[3.11.0] - 2026-07-15

**MINOR** — five more platforms gain hook-configs (GitHub Copilot CLI, Kiro, Augment, Devin CLI, JetBrains Junie), Gemini CLI reaches full SessionStart-driven auto-cadence, and file-copy installs get an honest `FileCopy` conductor mode.

### Added

* **Five new platform hook-configs** (`platform-configs/hooks/`): GitHub Copilot CLI (camelCase events, `{"version":1}` format, bash+PowerShell command pairs) · Kiro (merge-snippet into `.kiro/agents/{name}.json`, `agentSpawn`/`postToolUse`/`stop`) · Augment (settings.json cascade, PascalCase) · Devin CLI (`.devin/hooks.v1.json`, CC-shaped — explicitly Devin-CLI-only; Devin Desktop/Cascade is a separate system, documented as such) · JetBrains Junie (SessionStart-only, user-scope config; conductor-nudge tier only — no per-tool events exist there). All five carry the badge-tier works-with label; response channels are honestly marked where unverified.
* **`FileCopy` conductor mode**: a file-copy install (the five platforms above) gets KIND-2 rule-freshness only — the KIND-1 `claude plugin update` offer and the shared update-check stamp are excluded, both being CC-plugin machinery; this also stops a file-copy platform from consuming the update stamp a co-installed Claude Code's own nudge depends on.
* **`kiro` and `augment` install targets** in `scripts/lib/targets.mjs`.
* **Gemini CLI reaches full auto-cadence**: the conductor now wires through Gemini's genuine `SessionStart` event, emitting the dedicated nested `{"hookSpecificOutput":{"additionalContext"}}` shape — Gemini's only SessionStart injection channel (the flat `additionalContext` shape used elsewhere would be silently dropped). `geminiMain` honors a payload-supplied `cwd` (mirrors `agMain`). Wiring is labeled wired-not-validated.

### Fixed

* **Gemini CLI docs reconciled across 5 spots**: retires the stale "superseded by Antigravity CLI" framing — Gemini CLI is a business-tier product with an 11-event official hook surface; only the individual tiers ended (2026-06-18).
* **AG once-per-session marker hardened against a TOCTOU race** (`hooks/coalmine-conductor.js`): the conductor's marker is now an atomic `wx`-flag create in a private `0o700` `os.tmpdir()/coalmine/` subdir, replacing the old check-then-write — one-flock with the same-day fix in CoalHearth v1.3.1 and CoalFace v0.3.1.
* **Stale-marker sweep gained a second pass for the new subdir** (the legacy flat-root pass stays, for pre-fix installs) and now runs BEFORE the rot-canary mode gates: conductor markers are collected on every stop even when rot-canary is off/manual. Ownership split — the canary's own temp stays mode-gated.

### Security

* Closes the four 2026-07-14 CodeQL HIGHs (`js/insecure-temporary-file` ×2 + `js/file-system-race` ×2) at source; the shipped `plugin/` dist pair closes with this release's rebuild.

### Notes

* **Supersedes \[3.7.9]'s "sweep runs only on the active path" note.** True when written — the sweep only ever touched the canary's own temp. The AG port later added conductor markers (a separate advisory-payload class), so the sweep now runs on every stop to collect those too, even when rot-canary itself is off/manual; the canary's own temp stays mode-gated exactly as 3.7.9 described.

Gate: build + verify + `hooks.test.mjs` 38/38 + `conductor-update.test.mjs`/`consistency.test.mjs` 30/30 PASS.

## \[3.10.0] - 2026-07-14

**MINOR** — the full auto-cadence (conductor + rot-canary) runs on Antigravity 2.0's real hook engine (`hooks.json`; empirical pilot 2026-07-12 — which fired CoalMine's Stop cadence live on AG — corroborated against the official docs 2026-07-13). Honest scope: the Stop-hook FIRE is pilot-proven on AG; delivery of the injected context into the agent is emitted per spec, NOT yet validated end-to-end — the README tier is **wired**, not validated, until a real AG session confirms it.

### Added

* **The 3 Node hooks are dual-mode via an event-name argument** (the AG template runs `node <hook> <Event>`; Claude Code invokes with no argument — zero CC behavior change): the conductor rides the FIRST `PreInvocation` of a session (AG never fires `SessionStart`), guarded once-per-session by a tmp marker written BEFORE the emit (write-fail → no emit — an unguarded injection would repeat per MODEL call); the markers are swept by rot-canary-stop's stale sweep (a named Node-vs-PowerShell divergence). The stop hook emits `{"additionalContext"}` on AG (never `decision: block`); the touch hook reads AG payload shapes defensively (`tool_input`/`toolInput`/`toolCall.args` + path-key variants, resolved against the payload cwd — an unmapped shape is a no-op, never a wrong record).
* **KIND 1 self-update is deliberately NOT injected on AG** (`claude plugin update`/`configure.mjs` are CC plugin machinery, and an AG-side check would consume the CC throttle stamp — the CoalHearth precedent); the KIND 2 rule-freshness nudge rides along.
* `platform-configs/hooks/antigravity-hooks.json` rewritten to the verified AG spec (named-group wrapper, external-script commands, flat simple events / nested PostToolUse matcher, both install locations); the `platform-configs/hooks/README.md` Antigravity row updated to match.
* The session-id allowlist (`[A-Za-z0-9_-]+`) is unchanged for AG — the AG sid format is undocumented, so it stays fail-closed (a non-matching sid = a safe no-op; the 2026-07-12 pilot's live fire proves real AG sids pass).
* +6 hermetic AG spawn tests → 92 total.

### Fixed

* `skills/rot-canary/references/cadence.md` carried a stale pre-2.0 Antigravity line ("`PostToolUse`/stop-condition hooks") — now states the real AG story: the `hooks.json` engine, `PreInvocation` (once-per-session conductor guard) / `PostToolUse` / `Stop`, and the dual-mode event-name argument.

## \[3.9.3] - 2026-07-09

**PATCH** — board-audit fixes (the user's CoalBoard nasa audit, 2026-07-09): a real consent-escalation gap the v3.9.1 "monotonic config = FP" verdict wrongly cleared, plus a same-class config-floor miss and a conductor/doc drift gate.

### Fixed

* **\[HIGH] `updateMode` now safer-value-wins across the two-level config cascade — the v3.9.1 "FP" verdict was wrong to clear this key.** A project `<gitroot>/.coalmine.json` could set `updateMode: "auto"` while the user's global `~/.claude/.coalmine.json` said `"off"`; because the v3.9.0 cascade lets the project win per key with no exception, `hooks/coalmine-conductor.js` would then follow the project's `auto` directive and instruct the agent, on every SessionStart, to web-check the latest tag and offer `claude plugin update` — a standing-consent, network-touching action the user never approved at the global level, contradicting PRIVACY.md's "nothing expensive runs silently" framing. `updateMode` is not like `autoFixMode` (the one auto-EDIT key, read by the AGENT straight from the raw project file, never through the hook merge — the FP verdict was correct there): `updateMode` **is** hook-read via `loadCfg()` and drives hook behavior directly, so a project override there genuinely can weaken a global safety choice. Fixed with a safer-value-wins guard scoped to this one key: when **both** layers explicitly set `updateMode`, the project may only quieten it (move toward `off`), never loosen it toward `auto`; when either layer is silent, the other's explicit value applies unchanged (no forced escalation of an unset default). The doctrine comment ("NO safer-value-wins guard ... BY DESIGN") is corrected in both `hooks/_shared/node-config.js` (the live consumer — the conductor is Node-only) and `hooks/_shared/ps-config.ps1` (rationale parity; no PowerShell hook reads `updateMode` today, but the shared comment must not keep asserting the now-disproven blanket claim).
* **\[LOW] `tempSweepStaleDays` floor raised from `0` to `1`.** The v3.8.4 clamp (`Math.max(0, Math.floor(...))`, schema `min: 0`) treated an explicit `0` as valid, but `0` collapses `rot-canary-stop.js`'s sweep cutoff to exactly `Date.now()` at read time — by the time `sweepStale()`'s loop runs, every temp file's `mtime` (including the current session's own just-written `.touched`/`.scanned`/`.smells` markers) already sits in the past relative to that cutoff, so every `rot-canary-*` temp is deleted, including the live session's own. Same self-inflicted, own-`os.tmpdir()`-only, fail-silent blast radius as the v3.8.4 fix, one value further down the same class. Floor raised to `1` in `config-schema.mjs` (`min: 1`) and both clamp sites — `hooks/rot-canary-stop.js` (Node) and `alt/powershell/rot-canary-stop.ps1` (PS twin) — `Math.max(1, Math.floor(n))`; NaN/non-finite still falls back to the factory default (7).
* **\[MED] consistency.mjs now gates the conductor's own "9 quality canaries" string, not just `plugin.json`'s.** `checkCanaryCount` already cross-checked `plugin.json`'s `"<N> quality-canary"` description against the real `skills/` count, but `hooks/coalmine-conductor.js`'s hardcoded `CONDUCTOR_HEAD` line — "`[CoalMine] 9 quality canaries installed`", which fires on every SessionStart — had no gate at all. A canary added or removed with `plugin.json` updated but this string missed would ship silently wrong in the one place a user reads it every session. Closes the "About sat at 5 canaries for four versions" class of bug at its most-read surface.
* **The three `/coalmine:stats`-only freshness keys get an honest consumer note.** `platformRuleRevalidateDays`, `definitionRevalidateDays`, and `platformDefinitionRevalidateDays` are fully validated and CLI-settable (`config-schema.mjs`) and drive `/coalmine:stats`' on-demand freshness tables — but the SessionStart auto-nudge (`hooks/coalmine-conductor.js` `countPastDueStamps`) has never read them: it takes each stamp's OWN literal `revalidate Nd` as the past-due threshold directly, and reads `ruleRevalidateDays` only as a fallback for a malformed stamp that carries no parseable `Nd`. So a `revalidate 30d` (platform) or `revalidate 90d` (general) stamp is honored straight from the stamp text, and the three platform/definition keys never enter the SessionStart calculation at all. (`/coalmine:stats` differs — it maps a stamp's `Nd` to a config key, `30d → platformRuleRevalidateDays` / `90d → ruleRevalidateDays`, and applies that key's value, so the nudge and stats agree only while the keys sit at their factory 30/90 defaults.) A reader of the parallel-sounding `help` text could reasonably assume all four keys govern the same nudge. Documented the split explicitly rather than wiring the auto-nudge to the platform-specific keys too — `/coalmine:stats` already does the fine-grained key-customizable distinction on-demand; duplicating it into the hot SessionStart hook, where each stamp's own `Nd` is already an adequate threshold, would be scope the finding never asked for.

### Notes

* **Correction to the \[3.9.1] "Notes" verdict.** That entry generalized a true, narrow observation about `autoFixMode` ("read by the AGENT from the raw file, not by any hook via the merge") into a blanket claim that *no* hook-read config key needs a safer-value-wins guard. Half-wrong, not fully wrong: right for the key it actually traced, wrong to extend the conclusion to `updateMode`, which the merge feeds straight into a networked, consent-bearing hook decision. Credit to the user's CoalBoard nasa audit for catching the over-generalization. **Lesson: a false-positive verdict is scoped to the key it verified — it does not transfer to a sibling key just because both pass through the same merge.** Each config key needs its own consumer trace, not a verdict borrowed from its neighbor.

## \[3.9.2] - 2026-07-09

### Removed

* **`AGENTS.md` Rule 5 ("Work Execution Gate + Haldane Safety Protocol") destroyed outright, and its "Work Execution Gate" paragraph removed from all 4 shipped platform templates** — `platform-configs/clinerules.template`, `copilot-instructions.template`, `cursor.mdc.template`, `windsurf.md.template`. This was a dev-machine personal workflow (a 3-option Do-now/Add-to-plan/View-plan task gate, plus an in-flight-file spawn-safety protocol) that had leaked into shipped surfaces — never a CoalMine skill feature. Each template's canary proactive-offer paragraph (Run now / Queue / Skip for the 9 canaries) is kept — that is the skill's actual function, not the gate. The concern is owned by shipped skills instead: CoalFace's fan-out/in-flight discipline, and each conductor's consent-gated offers. v3.9.1 (earlier today) removed only the README's public-facing claim about this gate and kept Rule 5 as this repo's own local governance; the user then ordered full destruction, reversing that call — this release completes it. `AGENTS.md` (machine-local, gitignored, never shipped) is tombstoned the same day.

## \[3.9.1] - 2026-07-09

### Removed

* **README §"Work Execution Gate & Haldane Safety" — a false plugin-feature claim.** The public README advertised a Work Execution Gate + Haldane Safety Protocol as if the installed plugin performs them, but they are defined ONLY in the gitignored `AGENTS.md` (Rule 5 — this repo's local dev-governance / cross-agent instruction templates); the shipped `plugin/` has no code for them, so an installing user never got them. Removed from the README; Rule 5 stays as this repo's own governance. (Board-2 dogfood finding; the "false claim worse than none" class, same as the CoalFace wallet fix.)

### Fixed

* **Config read-time clamps on three raw numeric keys** — `ruleRevalidateDays` (conductor), `tripwireMaxFileSizeKb` + `tripwireMaxLines` (touch hook) were read raw; a negative / 0 / NaN value in a project `.coalmine.json` broke the gate (mass false past-due nagging / a silently-disabled smell tripwire). Now floored to a positive integer (`Number.isFinite` + `Math.max(1, Math.floor(...))`), matching the `tempSweepStaleDays` / `autoScanFileCap` clamps already in place. +1 hermetic regression (80 node tests).

### Notes

* Board-2 also flagged a "monotonic config" gap (a project override weakening a global safety choice, à la CoalWash's `mergeSafety`). On verification this is a FALSE POSITIVE for CoalMine and was NOT shipped: every hook-read config key is Phoenix-13 side-effect-free (report / nudge / scan — nothing deleted or auto-edited), and the one auto-EDIT key (`autoFixMode`) is read by the AGENT from the raw file, not by any hook via the merge — so a hook-side safer-value-wins guard would protect nothing. The finding pattern-matched CoalWash's memory-DELETE trust-boundary onto CoalMine's side-effect-free hooks. Rationale recorded in `hooks/_shared/node-config.js`.

## \[3.9.0] - 2026-07-09

**MINOR** — the two-level config cascade lands (one-flock key-parity with the 4 siblings; closes the dead-global-config finding the CoalFace sweep's QC surfaced: the user's tuned `~/.claude/.coalmine.json` was never read by any hook).

### Added

* **Global config layer:** every hook now reads `~/.claude/.coalmine.json` and overlays it per key with the project `<gitroot>/.coalmine.json` (project wins) — the same two-level cascade every sibling ships. Either file alone works; keys named `__proto__`/`constructor`/`prototype` are dropped at merge (an untrusted project config must not pollute the prototype). Shipped in the shared config partial, so all three Node hooks AND both PowerShell twins gain it in one place (Node≡PS parity).
* **`configure.mjs --global`:** targets the global layer (`~/.claude/.coalmine.json`, directory created if missing) instead of the project git-root file. Help + example added.
* Hermetic regressions: global-only honored · project-wins-per-key · proto-key dropped at merge (Node), global-honored + project-wins (PS twins), `--global` writes home-not-project (configure).

### Changed

* README Configure intro: from "there is no global layer" (true until this release) to the two-level cascade with the `--global` writer named.

## \[3.8.5] - 2026-07-09

**PATCH** — platform-landscape refresh + a Configure-intro truth fix (part of the flock doc-conform sweep, CoalFace-orchestrated).

### Changed

* **Platform refs refreshed (July 2026 landscape):** Windsurf → "Devin Desktop (ex-Windsurf)" (rebrand, Jun 2; mechanics/paths/CLI tokens kept verbatim); Gemini CLI mentions annotated "(superseded by Antigravity CLI, Jun 2026)" — kept, never removed (legacy installs remain real). Touches the shared escalation footer (renders into all 9 skills), the escalation reference, the rot-canary cadence reference, the README works-with badge + agent table, and the platform-configs hook snippets.
* **Configure intro now tells CoalMine's TRUE config model:** a single per-project `.coalmine.json` read from the git root — there is NO global layer (every hook reads `<gitroot>/.coalmine.json` only; verified in code) — with the per-project off-switch named (`enableConductor: false`; `disabledCanaries: ["all"]` for canary offers only). The previous wording implied the flock's two-level cascade, which CoalMine's code does not implement.
* Relicensed from MIT to Apache-2.0. `LICENSE` is now the Apache License 2.0 (verbatim); a new `NOTICE` carries the attribution; the `plugin.json` `license` field is `Apache-2.0`. No code or behavior change.

## \[3.8.4] — 2026-07-02

Board round-3 LOW: a config read-time clamp the earlier Board #2 clamp pass missed on the same-class sibling.

### Fixed

* **\[LOW] `tempSweepStaleDays` was read raw with no read-time clamp.** `hooks/rot-canary-stop.js` `getTempSweepStaleDays()` returned `cfg.tempSweepStaleDays` straight from an untrusted project `.coalmine.json`, unlike its in-file sibling `autoScanFileCap` (clamped `Math.max(1, Math.floor(n))` since v3.7.12, with a comment naming exactly this hazard). A **negative** value pushes the sweep cutoff into the future → `mtime < cutoff` holds for every `rot-canary-*` temp, so `sweepStale` would delete ALL of them, including a concurrent session's fresh temp; a fractional value skews the cutoff. Confined to CoalMine's own `os.tmpdir()` namespace + fail-silent + self-inflicted config → LOW, but a real same-class miss. Clamped to a non-negative integer (schema `min:0`, floor at 0; NaN/non-finite → the factory default 7) in **both** the Node hook and the PowerShell twin `alt/powershell/rot-canary-stop.ps1` (which read `$cfg.tempSweepStaleDays` into `$staleDays` with the identical gap — Node≡PS parity). New hermetic regression in `scripts/lib/hooks.test.mjs` (a negative override must not delete a fresh concurrent-session temp), mirroring the existing `autoScanFileCap` clamp tests.

## \[3.8.3] — 2026-07-02

Board-audit fixes (two parallel nasa/standard boards, every finding reproduced by a judge running the code). Headline is a shipped-hook ReDoS; the CI test-gate hole and doc-accuracy nits ride along.

### Fixed

* **\[HIGH] Conductor ReDoS — the SessionStart gold-rule scan was O(n²) on a poisoned rule file.** `hooks/coalmine-conductor.js` `countPastDueStamps` ran a global capturing `STAMP_RE` (two lazy `[\s\S]*?`) over every `.claude/rules/**/*.md` + `.agents/rules/**` + `AGENTS.md` whole-file on every session start; a rules file with many `<!-- coalmine: verified` openers and no closing `-->` made each opener's lazy match walk to EOF → N openers × O(N) = quadratic. Judge/independent timing on that shape: 1 MB ≈ 2.3 s, 2 MB ≈ 9.2 s (clean quadratic doubling), so a cloned/untrusted repo carrying a large poisoned `.md` could hang the session start (Phoenix #3 "≤100ms" violated \~100×; fail-silent doesn't help — the loop returns, it just stalls). Fixed by mirroring the exact guard the sibling `scripts/lib/consistency.mjs` already carries (v3.7.9 CM-1): a cheap non-backtracking `STAMP_OPEN` locates each opener, and the capturing pattern runs over only a bounded `STAMP_WINDOW = 2048`-char slice per opener → per-opener regex work is O(1), so O(n) over the file (2 MB ≈ 0.46 s). New hermetic regression in `conductor-update.test.mjs` fails loud on the old O(n²) (wall-clock ceiling) and pins that a real past-due stamp buried in a large file is still counted. (No PowerShell twin: the conductor is Node-only — the PS ports are the rot-canary touch/stop hooks, neither of which scans stamps.)
* **\[HIGH] CI test-gate had no missing-file guard.** `.github/workflows/ci.yml` passed a hardcoded 8-file list to `node --test`; on Node 24 a missing file arg alongside a present one is reinterpreted as a zero-match name filter → the run can exit 0 with a renamed/deleted test silently dropped from the authoritative `main` gate. The local pre-commit/pre-push hooks already guarded with `[ -f "$t" ]`, but CI did not. New **`scripts/test.mjs`** — the single guarded node-test runner (existsSync precheck + on-disk-orphan check + fail-loud, mirroring CoalTipple's `scripts/test.mjs`); CI and both git hooks now call it, so the test list lives in exactly one place and cannot drift.
* **\[MED] `PRIVACY.md` contradicted the code on where hooks write.** It said the hooks "write only to your OS temp directory and their own session markers," but the conductor writes a persistent `~/.claude/.coalmine-update-check` date stamp and rot-canary reads `~/.claude/.rot-canary-mode` (SECURITY.md already disclosed these). Reworded to name the `~/.claude/` update-stamp + mode/opt-out files, all local, none transmitted.
* **\[LOW] `disabledCanaries` example overstated the mechanism.** The `platform-configs/.coalmine.json` comment and the README `--disable` example listed `drift-canary` (a skill-canary the key honors only advisorily) as if disable-able like `rot-canary`. Reworded: the key is mechanically enforced for the hook-driven canaries (`rot-canary`, `conductor`) plus `all`; skill-canaries honor it advisorily.
* **\[LOW] `commands/stats.md` said "two sections" but delivers three** (canary activity · rule freshness · definitions freshness). Count corrected.
* **\[LOW] Dead sub-expression in the rot-canary merge-conflict tripwire.** `hooks/rot-canary-touch.js` tested `/^(<<<<<<< |>>>>>>> |=======$)/` only after a `<<<<<<<` /`>>>>>>>` bracket was already confirmed present, so the `=======$` alt could never independently fire. Collapsed to the single bracket test.

### Notes

* Reproducibility: the conductor ReDoS was confirmed by independent hermetic timing (not lens assertion); the CI gate hole was reproduced on Node 24.17.0. No `plugin/` runtime capability was added — this is a bug-fix + gate-hardening PATCH.

## \[3.8.2] — 2026-06-21

Gold-standard wizard CHANGE-path correctness (parity with CoalBoard v1.4.2). This touches the shipped `plugin/`, so it earns the bump; the previously-unreleased PowerShell/CI repo fixes below ride along on this tag.

### Fixed

* **Wizard `change` now recomputes the bill + re-consents (`bill → change → bill → pay`).** The programmer-path confirm in `references/wizard.md` said "go / change / cancel → spawn only on go" but never specified that `change` must loop back → RECOMPUTE the bill → re-present a FRESH consent box — so a change (→ Heavy fan-out · +FILL · +CONFORM) could score/spawn on a stale, un-reconsented bill. Now spelled out; spawn only on a `go` of the CURRENT bill. (Same consent-integrity fix as CoalBoard v1.4.2.)
* **\[CRITICAL] PowerShell `disabledCanaries` kill-switch was dead for single-element arrays.** `{"disabledCanaries":["rot-canary"]}` / `["all"]` / legacy `{"disable":["all"]}` failed to disable the canary on Windows PowerShell 5.1 (the Windows default) — it still recorded + nudged + swept. Root cause: an `if`-expression assignment enumerated a single-element `Object[]` into a scalar `String`, then a `-is [array]` guard dropped the scalar to `@()`. Fix: `$disabledArr = @($disabled)` (force-array) in both `rot-canary-touch.ps1` + `rot-canary-stop.ps1`; the old in-code comment misdiagnosed it as `ConvertFrom-Json` unwrapping (it preserves single-element arrays — `watchedExtensions` was always safe) — comment corrected.
* **\[HIGH] PowerShell merge-conflict tripwire false-fired on a bare `=======` banner.** Ported the Node co-occurrence guard (CHANGELOG \[3.7.11]): flag `=======` only when a `<<<<<<<` /`>>>>>>>` bracket co-occurs.
* **\[HIGH] CI ran fewer tests than the local hooks** (`ci.yml` ran 6, the git hooks run 8). Added `jsonc.test.mjs` + `conductor-update.test.mjs` (+ a PowerShell parity step) to CI; corrected the false "same gate" comment.
* **`configure.mjs`** now fails loud (exit 1) on a malformed `.coalmine.json` (was a silent exit 0) — scripts-quality §1.
* **`install.mjs`** guards against writing a root `.coalmine.json` into the source repo (self-pollution).
* **`consistency.mjs`** JSONC-sync gate notes the 3rd (PowerShell) stripper copy it cannot byte-compare.
* New **`scripts/lib/ps-hooks.test.ps1`** (10 hermetic PowerShell spawn assertions covering both bugs above) + 3 new node tests (configure fail-loud, install self-pollution ×2) → **72 node tests** + 16+10 PowerShell.

## \[3.8.1] — 2026-06-21

Gold-standard wizard flow-correctness + token-minimization — the v3.8.0 wizard shipped with an embed defect and a latent double-ask. Two adversarial loop-until-correct dogfood passes (trace every action → fix → re-verify; then squeeze tokens → re-verify all bars).

### Fixed

* **EMBED (the headline) — layman `go deeper` now firmly binds the rules.** It was `AUDIT + FILL` (rules written to disk but NOT in force this session → the layman then hit a separate redundant ADOPT gate to make them binding). Now `AUDIT + FILL + ADOPT` in that one consent — the gaps are written into the project's rules home AND made binding, so gold rules firmly bind without a second prompt. CONFORM (existing-code retrofit) stays a separate explicit gate.
* **Latent double-ask on the layman path closed.** The layman box now states the Standard default IS the resident escalation-footer's tier — so the footer fires no second tier question on that path (the programmer box already folded it).

### Changed

* **Token-minimized.** A second loop squeezed the on-demand wizard 1551 → 1449 ch; every cut was re-verified against all 4 correctness bars (flow · no-double-ask · embed · no-dup), and 11 further cuts were rejected as bar-breaking = maximally lean. `SKILL.md` (resident) untouched.

Gate: build + 69 node tests + consistency + verify PASS.

## \[3.8.0] — 2026-06-21

Gold-standard gains an interactive setup wizard (manual `/gold-standard` only — the auto/keyword path is untouched and pays nothing for it).

### Added

* **`gold-standard/references/wizard.md` — dual-audience interactive setup**, read on-demand when a user runs `/gold-standard` manually:
  * **Layman** (bare `/gold-standard` / "audit my rules"): AI picks safe defaults (relevant dimensions from one cheap scan · Standard · AUDIT), then asks ONE plain jargon-free question (`go` · `go deeper` · `cancel`).
  * **Programmer** ("advanced" / `ACTION=`): one batched question-box (ACT · DIMENSIONS · TIER) → bill computed FROM the picks → `go`/`change`/`cancel`. Order → bill → pay (bill after picks = never a stale default).
* A one-line manual-entry pointer in `gold-standard/SKILL.md`; the Triggers/auto path skips the wizard, so the always-resident body and the cheap auto path stay unchanged.

### Notes

* Token-economy dogfood (a sub simulated both paths + dumped every step): the wizard's TIER question is **folded into** the resident escalation-footer's existing tier-pick — never asked twice — removing a duplicate round-trip and a contradictory instruction; the ADOPT/CONFORM choice-gate is stated once (pointer to SKILL.md) instead of three times. Net \~27% leaner than the first draft.

Gate: build + 69 node tests + consistency + verify PASS.

## \[3.7.12] — 2026-06-21

Board-audit round-2 fixes (sub4-reproduced) — config-clamp hardening; behavior unchanged on valid config.

### Fixed

* **rot-canary stop-hook clamps `autoScanFileCap` / `autoScanFileCapSlice` at READ (#2).** `{0}` no longer emits an empty-list "scan these (capped at 0)" nudge; `{-1}` no longer silently drops the last touched file; the PowerShell twin clamps identically (`Select-Object -First -1` no longer throws → Node≡PS parity restored). `Math.max(1, Math.floor(n))` at read time.
* **`updateCheckDays` parity (#4/#5).** config-schema adds `max:365` (had `min:1` only); the conductor guard is now `Number.isInteger(v) && 1<=v<=365` (was `>=1` only — accepted `1.5`/`99999`). Parity with CoalBoard + CoalTipple.
* +5 hermetic regression tests (64 → 69 node tests) proving each clamp closes its edge case.

Gate: build + 69 node tests + consistency + verify PASS.

## \[3.7.11] — 2026-06-21

Board-audit fixes (verify-triaged from the whole-Colliery nasa board, 28/139 confirmed) — bugfixes only, no behavior change to the canaries.

### Fixed

* **PS-config parity test now gated** — `scripts/lib/ps-config.test.ps1` wired into pre-commit + pre-push (skip-if-absent pwsh; fail-loud when present). Closes the scripts-quality §2 gap where a PowerShell-side regression could ship silently.
* **JSONC-stripper sync gate** — new `consistency.mjs checkJsoncRegexSync` fails if the comment-stripper regex in `scripts/lib/jsonc.mjs` and `hooks/_shared/node-config.js` ever diverge (was hand-duplicated, ungated).
* **merge-conflict tripwire FP** — `rot-canary-touch.js` flags a `=======` line only when a real `<<<<<<<` /`>>>>>>>` marker co-occurs (a bare 7-equals banner no longer false-positives).
* **`>maxLines` off-by-one** — a file at exactly the cap (with or without a trailing newline) is no longer flagged.
* **platform hook templates** — the 5 `platform-configs/hooks/*.json` now quote the node script path (no word-split on a path with spaces; hooks-safety §3).
* **fix-mode safety guard** — the scale/telemetry/testability/drift "Apply safe …" bullets now carry the checkpoint → build/test → revert-if-red guard the other canaries already have.
* **`install.mjs`** — `copyDefaultConfig` warn path sets `process.exitCode = 1` (fail-loud, scripts-quality §1).
* **README** — the Configuration-Schema table is completed to all 23 keys (was 6), sourced from `config-schema.mjs`.
* **issue-template** — Antigravity added to the agent dropdown (it is a supported install target).

Gate: build + 64 node tests + consistency (+ new jsonc-sync) + verify PASS.

### Notes

* **Erratum to the \[3.7.8] "Platform-fact accuracy (M9)" entry.** That entry's wording ("Dropped the scrapped Antigravity entry from the orchestration escalation footer") over-scoped. The Antigravity removal landed in the references/escalation.md Heavy-lever table only; the always-resident `ask_question` alias footer still names Antigravity, which is correct — **Antigravity remains a fully-supported install target.** Antigravity is not "scrapped"; future CHANGELOG entries will not imply it is.

## \[3.7.10] — 2026-06-21

SKILL.md load-path carve (token economy) — every canary's behavior unchanged.

### Changed

* **#9 / #18 carve** — the escalation footer (the 2 shared partials injected ×9) compressed −27.6% (4170 → 3019 chars/injection = **−10,359 resident chars across the 9 skills**); the per-platform Heavy-lever table + Heavy-durability prose moved to a NEW shared `references/escalation.md` (build-injected into all 9, loaded ON-DEMAND, off the Light/auto/Stop-hook path). Plus per-skill body trims. Total always-loaded resident cost **−17%** (68,343 → 56,695 chars). The always-resident footer keeps every auto/hook-path behavior (the tier rubric, the +1 scoring, the `ask_question` gate, Hook Context, the entanglement domain-map, self-error-report). Rolls the CoalBoard load-path carve (skill-authoring §4) to CoalMine.
* New `scripts/lib/render.mjs` shared-references mechanism (writes the one source verbatim into each skill's `references/`, propagated to dist + every install target + manifest hashing) + a `scripts/verify.mjs` `checkSharedReferences` byte-sync check (with a negative test: a tampered dist shared-ref fails the gate). +3 render tests (61 → 64 node tests).

Gate: build + 64 node tests + consistency + verify PASS.

## \[3.7.9] — 2026-06-21

Round-2 dogfood audit (CoalBoard whole-Colliery, the user as customer) — CLI + consistency-gate bugfixes. The shipped skill runtime is unchanged.

### Fixed

* **CM-2:** `scripts/configure.mjs` `parseValue` now reuses `validateValue` (enforces `max`, not just `min`; `Number` not `parseInt`) — the CLI no longer silently accepts out-of-bounds (`--autoScanFileCap 1001` is now rejected). Kills the two-parser drift + the float-truncate footgun.
* **CM-1:** `scripts/lib/consistency.mjs` runs `STAMP_RE` over a bounded `STAMP_WINDOW` (2048 chars) per opener — the quadratic backtracking on a poisoned rules `.md` (reachable via manual `node scripts/consistency.mjs`) is now O(1)/opener → linear over the file.
* **sweepStale guard:** `hooks/rot-canary-stop.js` runs the housekeeping sweep ONLY on the active (auto) path — behind the disabled/manual/off guards, matching the PowerShell twin (Node≡PS parity). +2 hermetic tests.
* **CM-DM (doc):** `checkDoctrineMirrors` comment corrected to its honest scope (it mirrors the in-repo `.claude`/`.agents` copies; the org `.github` copy is a separate repo, out of runtime reach — org-sync is a release-time concern).

Gate: build + verify + 61 tests + consistency PASS.

## \[3.7.8] — 2026-06-20

CoalBoard-audit hardening (dogfood) — PowerShell-parity fixes + doc/config accuracy.

### Fixed

* **PowerShell hooks — session\_id sandbox allowlist (H3).** `rot-canary-stop.ps1` / `rot-canary-touch.ps1` now enforce the same `^[A-Za-z0-9_-]+$` allowlist as the Node twins (Phoenix #10), so a traversal-shaped `session_id` cannot escape `$env:TEMP`. Centralised in `hooks/_shared/ps-config.ps1`.
* **PowerShell JSONC stripper (H4).** Ported the Node string-aware `stripJsonc` to PowerShell — the old `^\s*//` regex stripped only full-line comments, so a legal inline `// comment` broke `ConvertFrom-Json` and the ENTIRE `.coalmine.json` was silently ignored on PS hooks (Node honoured it). Now inline comments are stripped and `//` inside strings is preserved. + a Node≡PS stripper-equivalence assertion and a hermetic PS test (`scripts/lib/ps-config.test.ps1`).
* **`config-schema.mjs` int validation (M4).** Ints validate with `Number.isInteger` (rejects `1.5`/`NaN`/`Infinity`) + `min`/`max` bounds on every int key — e.g. `tempSweepStaleDays` ≥ 0 (a `-1` made the PS sweep `AddDays(+1)` delete live-session temp files); `autoScanFileCapSlice` ≥ 1.
* **PowerShell/Node parity (LOW).** `.touched` dedup lowercases on win32 (matching Node — no case-duplicate entries); a scalar `"all"` from `ConvertFrom-Json` no longer disables (matching Node's array guard).

### Changed

* **SECURITY.md scan provenance (M5).** Synced the version-transition guard comment to the prose (SkillSpector v2.2.3 · v3.7.7 · 100/100 · 10 FP · 2026-06-20).
* **Platform-fact accuracy (M9).** Dropped the scrapped "Antigravity" entry from the orchestration escalation footer (rendered into all 9 skills) and the issue-template dropdown; removed the unverifiable "Windsurf (now Devin)" attribution (the churn disclaimer already covers it).

### Removed

* **Stray root `.coalmine.json`.** The maintainer's tuned machine config had been tracked at the repo root since v3.7.1; removed. Config is create-if-absent and `platform-configs/.coalmine.json` is the shipped template — no project-level config ships.

## \[3.7.7] — 2026-06-19

Path-safety hardening — defense-in-depth on the hook + installer file paths.

### Fixed

* **rot-canary hooks (`rot-canary-touch.js`, `rot-canary-stop.js`)** — the `session_id` used to build the temp-state path is validated (`/^[A-Za-z0-9_-]+$/`) before use, so a malformed/crafted session\_id cannot traverse outside `os.tmpdir()` (Phoenix #10 sandbox containment).
* **`install.mjs safeSkillNames`** — a skill name about to be `rmSync`'d must be a plain basename (`/^[A-Za-z0-9_-]+$/` AND `s === path.basename(s)`); anything else is dropped, never deleted. + a hermetic traversal test in `hooks.test.mjs`.

## \[3.7.6] — 2026-06-19

Doc-accuracy pass: honest security-scan provenance, clearer config help, a named dead-code heuristic, and a churn-resilient per-platform escalation footer.

### Changed

* **SECURITY.md — honest scan provenance.** The NVIDIA SkillSpector section now PINS the last actual scan (v3.7.3, 2026-06-17) and states scanning is periodic, not per-release — instead of implying the bump "covers" later versions (an unscanned version's security is unverified). The maintainer comment carries the same rule: never bump the pin without a real re-scan.
* **`autoScanFileCapSlice` help clarified** — it is a file COUNT (the most-recently-modified files kept when `autoScanFileCap` is exceeded), not a fraction.
* **rot-canary "dead code" heuristic named** — *"Dead = zero-reference reachability across ALL entry routes (reflection, DI, events, public API, tests) — not a single-file grep."*
* **Escalation footer refreshed + churn-resilient.** Dropped the now-stale *"no concurrent fan-out: Gemini CLI · Cline · Windsurf"* claim — all three shipped parallel subagents through 2026 (Cline read-only · Gemini CLI · Windsurf→Devin) — and replaced the fixed list with a "verify your platform's current capability; these churn" note.

## \[3.7.5] — 2026-06-18

Self-Updating — an opt-in, consent-gated update system, silent by default.

### Added

* **Self-Updating (two kinds), silent until due.** New config `updateMode` (`ask`|`auto`|`remind`|`off`, factory `ask`) + `updateCheckDays` (factory `14`). The conductor (SessionStart) stays silent until `updateCheckDays` elapse since the last check — a crash-safe stamp at `~/.claude/.coalmine-update-check`, throttled to once per window — then:
  * **kind 1 (plugin version):** `ask` prompts once how to handle updates (auto / remind / off, saved via `configure --update-mode`); `auto` has the agent compare the latest tag to the installed version and offer `claude plugin update` (standing consent — the only token-spending path, \~1–2K/check); `remind` is a free periodic reminder; `off` is silent. **The hook itself never networks or spends** — the version-check lives only in the new `/coalmine:update` agent procedure, gated on `auto`/explicit consent, with a graceful offline fallback.
  * **kind 2 (rule freshness):** a free, local SessionStart nudge when any gold-standard `coalmine: verified … revalidate Nd` stamp is past due — lifting `/coalmine:stats` past-due detection from pull-only to automatic. Consent-gated (the user runs `/gold-standard`).
* `/coalmine:update` command (the agent-side procedure) + hermetic conductor tests (13) + configure tests (2). 56 tests.

### Note

* `claude plugin update` does not auto-detect plugin staleness for community marketplaces (auto-update is opt-in per marketplace, off by default) — this fills that gap for users who keep auto-update off, without a fake offline version-check: the **agent** verifies, the **hook** only schedules.

## \[3.7.4] — 2026-06-18

Ships the #12 config-loss fix to users: earlier post-3.7.3 commits kept the version at 3.7.3, so `claude plugin update` (which keys on the version) never delivered them.

### Fixed

* **`.coalmine.json` no longer silently reverts to defaults (#12).** The JSONC comment-stripper desynced on a string value ending in a backslash right before a later `//`: it leaked escape state, mis-stripped a later `//`-bearing string, `JSON.parse` threw, the `catch` swallowed it, and the whole config fell back to defaults — and a WRITE path in `configure.mjs` wiped user config the same way. The stripper is now one shared, string-aware `scripts/lib/jsonc.mjs` (consumes `\\.` or a non-quote/non-backslash char), used by the hook, `configure.mjs`, and `verify.mjs`, with `scripts/lib/jsonc.test.mjs`.
* **Thai text no longer leaks into two skills (#13).** `drift-canary` and `gold-standard` `SKILL.md` carried Thai parentheticals; removed. Skills stay English; runtime output still mirrors the user's language.
* **The installer sweeps a retired skill name even without a manifest.** A very old install (before the `rotcanary` -> `rot-canary` rename in v3.0.0, predating the install manifest) left the stale `rotcanary` skill dir behind on upgrade -- it was in neither the manifest nor the current set, so `cleanPreviousInstall` never reached it, and agents that read `.agents/skills` (e.g. Antigravity) kept listing a duplicate `/rotcanary` command. `cleanPreviousInstall` and uninstall now always sweep `RETIRED_SKILL_NAMES`.

### Changed

* **Docs.** Added `CONTRIBUTING.md`; trimmed README/SECURITY for word count and heading order; moved the `eval/` benchmark to the series umbrella; restored the "measurement" design principle. SECURITY.md's NVIDIA SkillSpector section now records an ACTUAL run (v2.2.3 via `uvx`, 2026-06-17, 58/100, 3 false positives) with full scan provenance, replacing the earlier "binary not executed" note.

## \[3.7.3] — 2026-06-15

A CodeQL/security hardening pass, the series-doctrine move to the org, and a CI cleanup.

### Changed

* **The series doctrine moved to the org.** The Phoenix-13 (hooks-safety) and scripts-quality docs are now hosted canonically at [`TheColliery/.github`](https://github.com/TheColliery/.github) alongside DESIGN-PRINCIPLES, so the umbrella holds the whole constitution. CoalMine dropped its `docs/` copies; `SECURITY.md` links the Phoenix-13 doc at the org, and the doctrine-mirror gate now checks the two machine-local rule homes (a missing public copy was already tolerated).

### Fixed

* **CodeQL `security-and-quality` (`js/file-system-race`).** `install.mjs` (upsertConfig/uninstallConfig) and `configure.mjs` read-and-handle-ENOENT instead of existsSync-then-read/write; the `rot-canary-touch.js` tripwire does fstat+read on one file descriptor instead of statSync(path) then readFileSync(path) — still skipping large files before reading (Phoenix #3). All benign in context, but the read-and-handle idiom is cleaner. The remaining by-design findings are dismissed with documented reasons in the Security tab.
* **markdownlint MD060.** markdownlint-cli2-action v23 ships markdownlint v0.40.0 with the new MD060 table-column-style rule; disabled it (compact tables are valid GFM).
* **Detection benchmark dated.** The README states the eval run date (2026-06-13, skill v3.4.0) inline, not only behind a click-through to `eval/RESULTS.md`.

### Security

* **Workflow actions pinned to commit SHAs** (with a `# vX` comment), superseding the floating major tags; closes the OpenSSF Scorecard PinnedDependencies findings. Dependabot still tracks them.

## \[3.7.2] — 2026-06-14

### Changed

* **The Design Principles (Quantum 11) moved up to the series level.** They are series doctrine — every tool in TheColliery obeys them — so the canonical copy now lives at the umbrella, [`TheColliery/.github/DESIGN-PRINCIPLES.md`](https://github.com/TheColliery/.github/blob/main/DESIGN-PRINCIPLES.md), generalized tool-agnostically. CoalMine's repo-local `DESIGN-PRINCIPLES.md` is removed and its README links the series doc.
* **The README now cross-links the series** (CoalMine ↔ CoalTipple). The link was one-directional before — CoalTipple pointed here, but not the reverse.

### Fixed

* **`rot-canary` fix-mode no longer assumes git.** The safe-fix checkpoint is now `git stash/commit in a git repo; else copy the file aside`, and the auto-revert restores whichever was used — a non-git user gets the same safe auto-revert. This enforces the new series rule **no external assumption**: no shipped feature HARD-requires git, GitHub, a network, or a CLI the user may not have (they are optional enhancements with a graceful fallback).

## \[3.7.1] — 2026-06-14

### Added

* **Version-pin drift gate** (`scripts/lib/consistency.mjs` → `checkVersionPins`, wired into the verify gate): any doc line carrying a `version-pin:` marker (the issue-template version placeholders today) must quote the current `plugin.json` version, or `verify.mjs` fails — a stale hardcoded version can no longer ship (the "`git tag -v v2.4.0` example went stale" class, mechanized). The colon-marker form means a prose mention of the word `version-pin` is never treated as a pin; CHANGELOG history and the machine-local governance files are out of scope. Where a version can be dropped entirely it still should (e.g. SECURITY.md's verify example uses `git describe`); the gate covers the spots where a concrete version genuinely aids the reader. Gate suite now 34.

### Changed

* **Config-honesty pass — every documented `.coalmine.json` key now has a real consumer.** Seven keys that were defined and documented but never read are now wired into the canaries and the conductor: `defaultTier` (the shared escalation footer pre-sets the route tier — Light/Standard/Heavy — unless the user requests one for that run), `autoFixMode` (rot-canary treats it as standing consent: `off` = report only · `safe` = auto-apply reversible fixes, still checkpoint→build/test→revert if red · `interactive` = show the menu), `schemaPaths` / `migrationDirs` (drift-canary scans those globs/dirs), `packageManifests` (supply-chain-audit scans exactly those manifests), `trustedDomains` (source-grounding treats them as additional authoritative sources), and `skipOnboarding` (the conductor drops only the gold-standard onboarding line). Mirrors CoalTipple's config-honesty discipline; adds a conductor `skipOnboarding` test (gate suite now 35).

### Removed

* **Tombstoned the `skillUpdateCheckDays` config key** (`scripts/lib/config-schema.mjs`, both `.coalmine.json` factory files): no consumer, and offline skill-staleness is not something a fail-silent hook can verify — the marketplace/host owns update checks. Do not re-add without a real consumer.

### Security

* **SkillSpector scan refreshed to v2.1.4** (`SECURITY.md`): the static pass scores 58/100 (HIGH) and raises 3 findings, each re-reviewed and confirmed a false positive (an HTML-comment freshness stamp, the consent-gate line itself, a session-scoped temp file). The LLM semantic pass that would contextualize them does not complete on the available API tier (v2.1.3 hit HTTP 429, the v2.1.4 run timed out), so the headline falls back to the pessimistic static number. The real assurance remains structural (Phoenix-13).

## \[3.7.0] — 2026-06-13

### Added

* **`install.mjs all` — auto-detect, install to every agent in the repo** (`scripts/install.mjs`, `scripts/lib/targets.mjs` → `detectPresentAgents`): one command installs CoalMine to each agent already configured in the current project — detected by its marker dir (`.cursor/`, `.agents/`, `.github/`, `.gemini/`, `.junie/`) — and skips the rest, printing what it detected vs skipped (fail-loud, never a silent no-op). Claude Code and Cline (both rooted at `.claude/`) are excluded from auto-detect so it can never double a plugin install; install those by name. This is the low-risk form of "install everywhere": it keeps every source-grounded vendor path (no silent coverage drop) while covering the convergent majority in one shot — unknown or brand-new agents route to platform-report, not a path map that quietly rots. Adds a `detectPresentAgents` unit test and an `all` integration test (gate suite now 33).
* **Agent-count drift gate** (`scripts/lib/consistency.mjs` → `checkAgentCount`, wired into the verify gate): the README agent-table row count must equal the number of targets in `scripts/lib/targets.mjs`, or `verify.mjs` fails. The supported-agent count now lives in exactly one place — the table == `targets.mjs`; every other surface (badges, About, prose, org profile) is number-free "major agents", so a stale count can no longer ship. Skips gracefully when no README is present (partial copies).

## \[3.6.0] — 2026-06-13

### Removed

* **Dropped the Roo Code target** — Roo Code's upstream repo was archived 2026-05-15 (the team pivoted to Roomote, stating IDEs aren't the future of coding). Dead vendor → drop support. Removed from `scripts/lib/targets.mjs`, `scripts/install.mjs`, the README agent table, and the platform-report issue template. Existing Roo forks can still copy a conformed `SKILL.md` manually. Supported targets: 12 → 11.

### Fixed

* **Corrected the Cline skills path** (`scripts/lib/targets.mjs`, README): was `.agents/skills`, but Cline does **not** read `.agents/` — it reads `.cline/skills`, `.clinerules/skills`, and `.claude/skills`. Now targets `.claude/skills` (the cross-agent path Cline honors). Source: docs.cline.bot. Re-source-grounded all agent skill-paths against agentskills.io (Jun 2026).

### Changed

* **Platform-aware Escalation Tiers** (`skills/_shared/orchestration.md`): tiers are now explicit **capability targets** with a **degrade-gracefully rule** — platforms without concurrent-worker fan-out (Gemini CLI, Cline, Windsurf in-session) stay single-agent and escalate via model + reasoning only, never faking parallelism — plus a per-platform Heavy-lever map (Claude Code Dynamic Workflows/`ultracode`, Codex `xhigh`+Cloud, Cursor Max Mode+Cloud Agents, Antigravity Agent Manager, Amp Oracle, etc.). Map deliberately keys on stable mode names, not volatile model IDs.

## \[3.5.1] — 2026-06-13

### Fixed (security — caught by rot-canary auto-scan on the v3.5.0 code, same day)

* **Manifest integrity path-traversal guard bypass on Windows** (`lib/manifest.mjs`): the guard split keys on `/` only, so a hand-crafted manifest key using Windows backslashes (`..\..\evil`) slipped past and `verify.mjs <target>` would resolve and hash a file outside the install target (read-only info-disclosure oracle). Replaced the segment scan with a resolve-and-contain check (`path.resolve` + `path.relative`), which handles `/`, `\`, absolute, and drive-relative keys uniformly. Escape-attempt test now covers both separators. Same path-traversal class as the v2.6.1 `safeSkillNames` fix — surfaced again the moment fresh security code shipped.

## \[3.5.0] — 2026-06-13

### Added (principle 10 — distrust your own non-code artifacts; "Windows-grade" hardening)

* **Self-consistency layer** (`scripts/consistency.mjs` + `lib/consistency.mjs`): mechanical cross-checks on the artifacts an agent trusts but never verifies — canary-count agreement (`skills/` vs `plugin.json`), **byte-identical doctrine mirrors** across `docs/` and every rule home (a diverged copy is the fingerprint of a stale sync or a tampered rule), and well-formed stamps. Tracked-file checks run in the verify gate; the full check (incl. machine-local rule home) is on-demand.
* **SFC-lite installed-artifact integrity** (`lib/manifest.mjs`): the installer records a SHA-256 of every file it writes; `node scripts/verify.mjs <target>` re-hashes the installed tree and flags any file changed after install (tamper) or missing — a surface git never sees. Path-traversal-guarded.
* **Memory/rule poison detection** in the gold-standard RE-VALIDATE pass: now flags a memory/decision-log or rule-register entry that contradicts a binding rule or another decision, or names a file/flag/command that no longer exists — the class proved live when a planted "fix" re-prescribed a Phoenix-#8-forbidden randomized sweep.
* DESIGN-PRINCIPLES #10 extended: the machine verifies what it *trusts* (installed copies, doctrine mirrors, memory), not only what it *ships*.
* Gate suite 25 → 30 tests.

### Added (cross-model convergence runs)

* **Second eval engine**: Antigravity ran the rot-canary corpus blind — recall 13/13, 0 decoy false-positives, severity 12/13; the sole disagreement (one CRITICAL rated HIGH) sits exactly in the predicted severity-judgment band. README and eval/RESULTS.md now show the two-engine table.
* hooks-safety doctrine gains **section 7 — Hermetic Hook Testing** (spawn the real hook, sandboxed env, assert exit/silence/state) — proposed by an independent Antigravity RE-VALIDATE whose verdicts and exemplars matched the existing stamps 2/2 (the lifecycle's anti-churn + exemplar-anchor mechanics held across models); published copy in docs/.

### Fixed

* configure.mjs: a trailing boolean flag with no value now errors instead of silently writing false (same fail-loud contract as the strArr fix); eval scorer drops a confusing no-op exit line.

### Added

* **Eval harness** (`eval/`) — AV-Comparatives-style detection-rate measurement: 16 rot-canary fixtures (12 with planted, line-labeled defects across all 7 categories + 4 clean decoys), a mechanical scorer (`eval/score.mjs`, match = fixture+file+category, line ±3), and model-stamped results. Baseline (claude-fable-5, self-run regression floor): recall 13/13, 0 decoy false-positives. README gains a "Measured detection quality" section.
* `docs/` — the Phoenix Commandments (hooks-safety) and scripts-quality doctrine are now published in-repo; DESIGN-PRINCIPLES.md previously linked them at `.claude/rules/...`, a gitignored path nobody on GitHub could open (caught by the user). Repo-wide link audit found no other broken links.

## \[3.4.0] — 2026-06-12

### Changed (principle 4 — minimum necessary power: the fat-trim release)

* **Conductor injection −37%** (1930 → 1218 chars) — same rules, fewer tokens in every session's context; the per-key list is replaced by a pointer to the commented config file.
* **Stop-nudge tail trimmed \~40% in all 5 languages** — the tail duplicated what the invoked skill already instructs; now just: confirmed-only report, offer the fix menu, kill-switch path.
* **Shared regions for standalone hooks**: the duplicated config plumbing (findGitRoot/loadCfg + PowerShell twins) lives once in hooks/\_shared/ and is synced into all 5 hook files between coalmine-shared markers by build-plugin — single source, each hook still copy-one-file portable (Phoenix #9); verify FAILs on drift. The conductor now uses the same cached loader.
* **Table-driven config tooling**: the 22-key schema moved to scripts/lib/config-schema.mjs, shared by verify.mjs (a validation loop replaces 23 hand-written if-blocks) and configure.mjs (flags, parsing, validation, and --help generate from the table) — the "help forgot a flag" bug class can no longer ship; a gate test asserts help documents every key.
* Escalation heading + table header deduplicated into the ORCHESTRATION partial (9 SKILL.md sources; rendered output unchanged).
* Gate suite 22 → 25 tests (shared-region sync/drift, configurator help completeness).

## \[3.3.0] — 2026-06-12

### Added (principle 9 — calibration: the config release, PR #7)

* **Categorized .coalmine.json config system**: 22-key schema in 4 commented groups (interactive behavior, scan & watch limits, re-validation cadences, enterprise paths). The installer drops a fully documented copy at the project root — zero-config defaults for everyone, overrides for programmers. scripts/configure.mjs edits it with validation + legacy-key migration; verify.mjs type-checks every key; hooks read it dynamically.
* Hooks resolve .coalmine.json from the **git root**, not the cwd — config honored when the agent works from a subdirectory (issue #5 C2). Installer supports worktree/submodule .git files via gitdir: resolution (C5) and refuses to target CoalMine's own skills/ source (C1).
* Auto-scan token cap: more than autoScanFileCap touched files → newest autoScanFileCapSlice scanned, localized notice appended (all 5 languages).

### Fixed (issue #5 + Fable-5 review of the PR)

* Touched files recorded as **absolute paths** in Node + PowerShell touch hooks — subdirectory edits no longer vanish from the stop-hook scan (C4).
* **Temp sweep made deterministic** (C3 + Phoenix #8): the shipped Math.random() throttle is replaced by a 24h marker-file gate in Node + PowerShell; tempSweepProbability retired (configurator migrates it away); PowerShell sweep now also clears legacy rotcanary-\* files.
* **Legacy v3.0.0 config keys honored again**: disable/mode/conductor work alongside the new names in all five hooks; README schema table now documents the canonical keys it contradicted.
* **Hook stdin BOM-hardened** — some shells prepend a BOM when piping; JSON parsing now survives it (S1, found when the test probe itself hit it).
* Config read once per hook invocation (was 4–5 reads with git-root walks each); dead CODE\_EXT set removed; hooks.test.mjs had raw NUL+SOH bytes that made git treat it as binary — escaped, the file diffs as text again.
* Skill docs conform to the canonical mold (Fix mode before Output, one heading name — S2); /coalmine:stats restores the claude plugin update advice; gate suite 20 → 22 tests (configurator covered).

## \[3.2.1] — 2026-06-12

### Fixed

* Tier rubric freshness cap: when the scope was already audited >=Standard this session (criterion 5 = 0), the recommendation is now capped at Light regardless of total score — previously size criteria alone could recommend Heavy for freshly-audited ground, contradicting the rubric's own caveat. Caught live during first dogfood of the plugin-served skill.

## \[3.2.0] — 2026-06-12

### Added

* **Self error-report (offer-gated)**: when a CoalMine component itself misbehaves, the agent OFFERS to file it at github.com/HetCreep/CoalMine/issues with a user-reviewed summary — never auto-submitted, never includes unapproved code/paths, zero tokens to send (browser form). Wired in the conductor and the shared footer (all 9 skills, all platforms).
* skills.sh one-line install (npx skills add HetCreep/CoalMine) in README; GitHub repo topics set for discovery.

## \[3.1.1] — 2026-06-12

### Added (principle 4 of the antivirus model — definition freshness)

* Every references/\*.md now carries a coalmine: verified stamp (30d for the platform-coupled cadence file, 90d for pattern tables) — the shipped pattern DB ages visibly, like antivirus definitions.
* /coalmine:stats section 3: definitions-freshness dashboard. Overdue definitions advise updating CoalMine itself (plugin update / fresh install) — never a local re-ground of shipped files.

## \[3.1.0] — 2026-06-12

The power-button release: install is the only command a user must know.

### Added

* **Conductor hook** (SessionStart, plugin route): injects the offer rules into every Claude Code session — gold-standard onboarding (offered once when a project has no golden rules), specialist offers by conversation domain, consent and per-project-config rules. Silenceable via .coalmine.json (conductor:false). The plugin route now carries the full always-on layer that previously existed only in installer trigger templates.
* **gold-standard onboarding offer** in all 4 trigger templates (installer route) — same first-encounter rule.
* README: One button - the suite drives itself (who fires when, and where consent lives).

## \[3.0.1] — 2026-06-12

### Fixed

* Hook-nudged auto-scans now end by OFFERING the fix menu when a user is present (all 5 nudge languages, Node + PowerShell, skill body, shared footer) — previously the report-only rule written for unattended contexts suppressed the menu in interactive sessions, so findings arrived with no next step. Fixing still requires a chosen option; truly non-interactive runs stay report-only.

## \[3.0.0] — 2026-06-12

**The Quantum Computer Spec is complete — all 11 design principles implemented.**

### Changed (principle 3 — single brand; BREAKING, softened by aliases)

* **`rotcanary` renamed to `rot-canary`** everywhere — skill dir, frontmatter, triggers, hook filenames (`rot-canary-touch.js`/`rot-canary-stop.js`), temp-file prefix, config files, docs, templates, snippets. Migration is automatic: the install manifest removes the old skill dir, the plugin cache swaps wholesale, legacy triggers (`/rotcanary`) stay as documented aliases, legacy config names (`~/.claude/.rotcanary-off`/`-mode`) are still honored, and the temp sweep cleans legacy-prefix files.
* Canonical SKILL.md structure ("the mold") documented in `skills/_shared/README.md`; every skill now ships a `references/` dir (9/9 — gold-standard `method.md`, source-grounding `sources.md`, resilience-audit `checks.md` added).

### Added (principle 9 — measurement & calibration)

* **Rule lifecycle** in gold-standard: FILL stamps every rule (`verified · exemplar · revalidate 30|90d`); repeat AUDIT re-validates stamped rules (re-stamp / rewrite / RETIRE with a tombstone that blocks resurrection); trigger templates offer `/gold-standard` when a stamp is past due. Cadence grounded against live sources (Jun 2026): platform surfaces ship weekly-to-daily → 30d backstop; OWASP/NIST anchors are annual+ → 90d is strict early warning; CVE rules re-validate on advisory events first.
* **`.coalmine.json`** per-project calibration — `disable` (canary list) and `mode` honored by both hook implementations; `defaultTier`/`language` honored by skills via the trigger templates.
* **`/coalmine:stats`** bundled command — canary activity this session + rule-freshness dashboard with an overdue re-validation offer.

### Added (principle 11 — entanglement)

* Shared footer hand-off map: after any report, findings in another canary's domain trigger a one-line offer of that canary (perf→scale, contract→drift, failure-path→resilience, logging→telemetry, coupling→testability, deps→supply-chain, unverified claims→source-grounding, rule gaps→gold-standard).

### Infrastructure

* `build-plugin.mjs`/`verify.mjs` generalized to ship and byte-check bundled extras (`agents/`, `commands/`) both directions; suite now 14 tests (project-disable test added).

## \[2.8.0] — 2026-06-11

The Quantum Computer Spec release — part 1 of 3 (principles 4 & 5; naming uniformity and measurement/entanglement follow).

### Added

* `DESIGN-PRINCIPLES.md` — the 11 binding principles (5 machine properties, 5 sustaining disciplines, 1 power source) every component is judged against; links the Phoenix Commandments and scripts-quality layers under one spec.

### Removed (principle 5 — only essential accessories)

* `skills/_shared/contexts.md` — orphan partial never injected by any template; its content (Work Gate, proactive offers) ships in the trigger templates. Render core no longer knows a CONTEXTS marker.
* `USE-WITH-ANY-AGENT.md` — merged into README (portability table, conformed-copy fallback warning, frontmatter quirks, also-reads notes); one installation document instead of two.

### Changed (principle 4 — minimum necessary power)

* Shared block (language header + escalation footer) editorially tightened — same semantics, fewer tokens on every skill load; suite-wide dist bodies −8% on top of the v2.2.0 diet (43.8 KB total).

## \[2.7.1] — 2026-06-11

### Fixed

* `build-plugin.mjs` copies `agents/` recursively (`cpSync`) — the flat `copyFileSync` loop would EISDIR on any future subdirectory, the same defect class fixed in `installSkillDir` at v2.1.0.
* `verify.mjs` checks the bundled agents both directions: a `plugin/agents/` left behind after the source `agents/` is removed now fails the gate instead of shipping silently.

## \[2.7.0] — 2026-06-11

User-driven improvement loop, modeled on what makes living rule-sets (e.g. ECC) improve from real usage.

### Added

* **Feedback funnel**: GitHub issue forms — platform field report (per-agent works/breaks), bug report, and a security contact link routing through SECURITY.md. Templates only; no CI workflows.
* **`coalmine-scanner` bundled agent** (Claude Code auto-discovers `agents/`): read-only scan worker for Standard/Heavy fan-out — one dimension per spawn, compressed findings-table output, no prose. Shared footer points Heavy runs at it; `build-plugin.mjs` ships it and `verify.mjs` byte-checks it both directions.
* Work Execution Gate now ships the `task.md` format (`| # | Task | Detail | Tier |`, auto-create if absent) in all 4 trigger templates.

## \[2.6.1] — 2026-06-11

### Fixed (security hardening, found by rotcanary QUICK on the fresh manifest code)

* Manifest skill names are sanitized to plain basenames before any `rm` (no separators, `.`/`..`, dotfiles, or absolute paths) — a corrupt or hand-edited `.coalmine-manifest.json` can no longer delete outside the install target or wipe the whole skills directory. Covered by an escape-attempt integration test (suite now 13).
* Installer integration tests get a 60 s spawn timeout so a hung installer can't hang the git gate.

## \[2.6.0] — 2026-06-11

### Added

* **Install manifest** (`.coalmine-manifest.json`, written at every install target): the installer now works like a package manager — it records exactly what it installed, removes that set before installing the new version, and uninstall reads the same list. Skills renamed or removed in future versions can never leave orphan copies behind; skills from other vendors sharing the target directory are never touched. Covered by an integration test (fresh install → simulated rename → reinstall → uninstall) wired into the git gates (suite now 12 tests).

## \[2.5.0] — 2026-06-11

### Added

* **Work Execution Gate** in all 4 auto-trigger templates with a deterministic significance test (>3 files, multi-step plan, or destructive action → offer Do now / Add to plan / View plan via the platform's question tool) — replaces per-session model judgment that made the gate fire inconsistently.
* Multi-language policy strengthened in the shared language header (all 9 skills): every runtime artifact — questions, answer options, menu labels, recommendations, report narrative — must be in the user's language; English is allowed only for technical terms (commands, paths, identifiers, severity and tier labels).

### Fixed

* PowerShell stop hook ported to the v2.4.0 acknowledgement semantics (`.scanned` stores the `.touched` timestamp; unknown/legacy content re-nudges) — Node/PS1 parity restored.
* README universal-installer steps no longer instruct `cd CoalMine` before installing (project targets resolve against the current directory — following the old steps installed skills into the clone itself); install steps now disclose the git-hook write + `.pre-coalmine` backup.
* Grammar/typo pass across docs, skill templates, and trigger templates; CHANGELOG 2.4.0 duplicate entries collapsed; SECURITY.md tag example bumped; Antigravity hook snippet now listed everywhere the snippet set is enumerated; Claude Code row distinguishes plugin cache from the installer path; installer usage header points Claude users to the plugin route.

## \[2.4.0] — 2026-06-11

First release with changes authored by a second agent platform: Google Antigravity ran the rotcanary skill against this repo and submitted both PRs — live cross-platform validation of the canary suite.

### Added

* `--uninstall | -u` flag for `install.mjs` (PR #3): removes installed skills, strips the COALMINE trigger block (deletes the file if empty), removes CoalMine git hooks, and restores any `.pre-coalmine` backup.
* `SECURITY.md` — published SSH signing public key + `git verify-commit`/`tag -v` instructions, dist-integrity reproduction steps, and reporting channel.

### Fixed (PR #4 — 11 rotcanary findings, plus review follow-ups)

* Stop hook: language detection reads only the first 4 KB of project docs; `.scanned` marker stores the `.touched` mtime captured at nudge time, closing the same-mtime-tick acknowledgement race; review fix: legacy empty markers re-nudge instead of being silently swallowed.
* Touch hook: `path.normalize` on all recorded/compared paths (separator-variant dedup).
* Both hooks: trailing `process.exit(0)` removed — natural exit keeps exit code 0 AND guarantees the stdout JSON nudge is fully flushed; Phoenix #4 wording updated to match.
* `verify.mjs`: real frontmatter parsing (between `---` delimiters), `plugin/` root orphan check, try-wrapped directory reads; `install.mjs`: case-insensitive agent target, fail-loud `listSkills`, empty-file append without stray separator, backup detection via explicit `# Generated by CoalMine` marker.

### Added (trigger layer, pre-merge)

* All 4 auto-trigger templates (Antigravity/agents-group, Cursor, Cline, Copilot) upgraded from rotcanary-only to the full 9-canary keyword table + 6 proactive offer-conditions (deps→supply-chain, schema→drift, async→resilience, loops→scale, tests→testability, logging→telemetry) + session-end rule — ships the always-on layer both flagship platforms read.
* `platform-configs/hooks/antigravity-hooks.json` — rotcanary auto-cadence snippet for Google Antigravity (PostToolUse + stop-condition hooks; verify-in-install note).
* `hooks/settings.snippet.json` — Node hooks wiring for Claude Code installs WITHOUT the plugin route (parity with the existing PowerShell snippet).

## \[2.3.0] — 2026-06-11

### Added

* Deterministic tier rubric in the shared escalation footer (all 9 skills): five concrete +1 signals (scope size/reach, category breadth, release/security context, will-drive-changes, not-recently-audited) map to Light 0–1 / Standard 2–3 / Heavy 4–5 — same scope always yields the same recommendation, the score is shown to the user, and an explicit user tier request overrides.

## \[2.2.1] — 2026-06-11

DEEP rotcanary sweep over the whole repo (27 findings fixed).

### Fixed

* Cursor cadence snippet now actually works: the stop command wraps `rotcanary-stop.js` output into Cursor's `{followup_message}` (Cursor cannot consume Claude-style `decision:block`); docs no longer claim Copilot auto-wires — only the Claude Code plugin does.
* Git gate hardening: missing test files now fail the pre-commit/pre-push gate loudly (`node --test` silently ignores missing path args); disconnected `pre-commit.ps1`/`pre-push.ps1` removed (git never executes `.ps1` hooks and nothing installed them).
* `installGitHooks` backs up a pre-existing non-CoalMine hook to `<hook>.pre-coalmine` before overwriting, and `chmod`s after write (the `mode` option only applies on creation).
* `verify.mjs`: every per-item read is try-wrapped (one unreadable input now yields a clean `FAIL` line and the run continues); aux dist files (`references/`, `skill-meta.json`) are now byte-compared both directions against source.
* `installSkillDir` clears the target skill dir before copying so renamed/deleted source files can't linger at install targets; `inject()` uses function-form replacements so `$&`-style sequences in partials can't corrupt output.
* Hooks: `.touched` lines that aren't real paths are filtered from the nudge; multi-smell entries are one line per file (`'; '` join); files >1 MB skip the tripwire scan (latency budget); recording without a `session_id` no longer writes orphan `nosession` state; `.scanned` marker content is empty (only mtime was ever used). PowerShell pair kept in sync; its README now documents cleanup/sweep and the EN-only nudge difference.
* Docs truth: README's Ultra-Short format section now describes the real per-skill severity-table output; CHANGELOG 2.2.0 wording corrected (6 of 9 skills gained `references/`; footer −28% bytes); scale-canary "J-Join" typo; drift-canary Style-Drift rule scoped to Fix mode; cadence.md points to the shipped wiring snippets.

### Changed (token diet — every skill body slimmer, depth preserved)

* All 9 skill bodies slimmed for progressive disclosure; 6 of them (rotcanary, supply-chain, telemetry, testability, scale, drift) gained per-skill `references/*.md` holding the per-stack tables and platform matrices, loaded only when the skill actually runs a scan. Dist SKILL.md total 51.4 KB → 38.6 KB (−25%; rotcanary −36%) while adding depth.
* Removed per-skill "Contexts & Execution Modes" and "Before starting / Recommendation logic" boilerplate — the shared escalation footer now carries the gate once; tier intents live in the escalation table.
* Shared escalation footer compressed (−28% bytes, \~40% fewer lines) while keeping the per-platform `ask_question` alias map, hook-context rule, and Heavy-durability guidance.

### Added

* `references/checks.md` for telemetry, testability, scale, and drift canaries — concrete per-stack/per-ORM detection procedures (the four newest canaries now match rotcanary's audit depth).
* `references/tooling.md` (rotcanary, supply-chain-audit) and `references/cadence.md` (rotcanary) — moved from skill bodies.
* "Use when …" situational clause in the 5 keyword-only skill descriptions (telemetry/testability/scale/drift/resilience) for better auto-trigger accuracy on all platforms.
* Hook regression tests `scripts/lib/hooks.test.mjs` (5 cases: touch record, case-insensitive dedup, fail-silent, stop nudge, acknowledged-batch cleanup) — hermetic via sandboxed TEMP/USERPROFILE; wired into pre-commit/pre-push (now 11 tests total).
* `platform-configs/hooks/` — rotcanary auto-cadence wiring templates for GitHub Copilot, Cursor, Gemini CLI, and Codex CLI, from vendor-doc-verified event names.

## \[2.1.0] — 2026-06-11

### Fixed

* `install.mjs` exits non-zero on partial failure (skill, config, or git-hook step) instead of reporting success.
* `installSkillDir` copies nested skill subdirectories (`references/`, `scripts/`) recursively instead of throwing `EISDIR`/`EPERM`.
* `verify.mjs` reports a clean per-skill `FAIL` on corrupt `skill-meta.json` instead of crashing with a raw stack trace.
* `upsertConfig` re-runs no longer duplicate template content outside the COALMINE markers (Cursor `.mdc` frontmatter grew on every install).
* rotcanary hooks now delete their session temp files once an edit batch is acknowledged, and sweep `rotcanary-*` files older than 7 days (Phoenix #1 zero garbage) — both Node and PowerShell variants.

### Added

* `verify.mjs` reverse check: orphan dirs in `plugin/skills/` with no source now fail the gate.
* Zero-dep unit tests for the render core (`scripts/lib/render.test.mjs`, `node --test`), including a stale-dist negative-path test; wired into pre-commit/pre-push hooks.
* `skills/_shared/README.md` documenting the SHARED marker and intent-placeholder conventions.
* Heavy Durability guidance in the shared escalation footer (all 9 skills): chunk long multi-agent runs into short phases; recover dead runs from the journal/transcripts instead of re-running everything.
* `CHANGELOG.md` (this file) and release tagging per the adopted release-bookkeeping rule.

### Fixed (cross-agent compatibility, source-grounded Jun 2026)

* Codex install target corrected to `.agents/skills/` (was `~/.codex/skills/`, which Codex never reads — per developers.openai.com/codex/skills.md; `agents/openai.yaml` is optional).
* Junie install target corrected to `.junie/skills/` (was `.agents/skills/`, which Junie does not read).
* rotcanary cadence claims now state per-platform truth: auto-wired on Claude Code (plugin) and GitHub Copilot (same hooks format); equivalent events on Cursor/Gemini CLI/Codex/Goose (manual wiring); manual-only on Cline/Junie. Kill-switch documented as Claude-specific.
* Shared escalation footer defines `ask_question` as an alias for each platform's real question tool (AskUserQuestion / ask\_question / ask\_followup\_question / askQuestions / ask\_user / request\_user\_input / suggested\_responses) with text fallback where none exists (Goose); Heavy Durability wording made platform-neutral.
* README agent table: choice-tool column updated to verified reality (9 native, 3 text-fallback), Roo Code upstream-archived note, Agent Skills spec link.
* USE-WITH-ANY-AGENT.md rewritten with the verified per-agent path matrix; Letta removed (no documented skills support); fallback section now advises copying from `plugin/` (conformed) instead of `skills/` (templates).

### Changed

* `TARGETS` agent→path map hoisted to `scripts/lib/targets.mjs` — single source of truth for `install.mjs` and `verify.mjs`.
* `installGitHooks` installs `hooks/pre-commit.sh` / `pre-push.sh` verbatim so `.git/hooks` copies cannot drift from source.
* `rotcanary-stop.js`: `TRANSLATIONS` and `detectLang()` hoisted to module scope (`main()` back under the 50-line guideline).

## \[2.0.0] — 2026-06-11

### Added

* 4 new canaries: `telemetry-canary`, `testability-canary`, `scale-canary`, `drift-canary` — suite now 9.
* Committed `plugin/` dist + `scripts/build-plugin.mjs` so the Claude Code marketplace route serves fully conformed skills (shared sections injected).
* `scripts/lib/render.mjs` render core shared by install/build/verify — all routes ship byte-identical content.
* `verify.mjs` gates: source must keep SHARED markers, dist must be byte-in-sync, marketplace must serve `./plugin`.

### Fixed

* YAML frontmatter in all 9 SKILL.md files converted to folded block scalars (`>-`) — strict parsers (`claude plugin validate`) now pass.

### Changed

* `marketplace.json` `plugins[0].source` moved from `./` to `./plugin`; manifests updated to 9 canaries.
* GitHub Actions/Dependabot generation removed from the installer — local-git-only alignment; templates remain in `platform-configs/`.

## \[1.0.0] — 2026-06-09

### Added

* Initial CoalMine collection (5 quality meta-skills): `rotcanary`, `gold-standard`, `source-grounding`, `supply-chain-audit`, `resilience-audit`.
* Cross-platform rotcanary auto-cadence hooks (Node + PowerShell fallback).
* Universal installer/verify scripts (`scripts/install.mjs`, `scripts/verify.mjs`) for 12 agents.


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter, and the optional `goal` query parameter:

```
GET https://docs.thecolliery.org/tools/changelog.md?ask=<question>&goal=<endgoal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
