claudemd-cli 0.68.0 → 0.68.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +32 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,38 @@ All notable changes to the `claudemd` plugin. This changelog tracks plugin artif
|
|
|
8
8
|
- **Canonical spec version source**: `spec/CLAUDE.md` top-line title (`# AI-CODING-SPEC vX.Y.Z — Core`) + `spec/CLAUDE-changelog.md` top `##` entry.
|
|
9
9
|
- **Plugin semver vs spec semver** are independent: plugin patch (0.2.0 → 0.2.1) may ship when spec is unchanged (this release); plugin minor (0.1.9 → 0.2.0) ships when spec minor updates (v0.2.0 shipped spec v6.10.0).
|
|
10
10
|
|
|
11
|
+
## [0.68.2] - 2026-08-17
|
|
12
|
+
|
|
13
|
+
A hook that misses its `hooks.json` timeout is killed before it can emit — so a **blocking** gate fails open, and cannot log that it did. `memory-read-check.sh` (the §11 ship-time DENY) was at 1.9s of a 3s budget on this maintainer's machine and 3.7s against a 750-tag index; its sibling `memory-prompt-hint.sh` had already crossed the line in a live session and stopped emitting. The cause is one shared loop forking three processes per MEMORY.md tag, measured at 5.1 ms/tag on an idle box (7.6–8.2 ms/tag when the independent reviewer re-ran it on a loaded one — same linear shape, absolute values are machine-relative). No spec change. Nothing about matching behavior changes — the release is the cost, plus the two instruments that should have caught it and did not.
|
|
14
|
+
|
|
15
|
+
Tag counts here are by **tag-block content**: the comma-split contents of the `` `[…]` `` block on each index line. Counting every bracketed token on the line instead — markdown link titles included — gives 368 for the same file. The 336 figure is the first.
|
|
16
|
+
|
|
17
|
+
**The pre-tag review found that the first cut of this release reintroduced the very defect it ships to remove**, and its findings are folded in below rather than deferred. Worth stating plainly because the release is about instruments that cannot see their subject: three of the review's mutations against the *new* gates stayed green, and one of them was a mutation of `mem-audit.sh` — the hook this entry claims to have sped up.
|
|
18
|
+
|
|
19
|
+
- **perf: MEMORY.md tag matching is now one `awk` pass, shared by both §11 hooks** (`hooks/lib/memory-tags.sh`). `memory-prompt-hint.sh` and `memory-read-check.sh` each carried their own copy of a loop spending `echo | tr`, `printf | sed` and `echo | grep` per tag. Cost was linear in tag count — 83 tags 0.44s, 153 → 0.81s, 245 → 1.28s, 336 → 1.71s, 672 → 3.50s — against a 3s budget, i.e. unconditional timeout at roughly 590 tags. Against the same 750-tag fixture: hint **3.73s → 0.096s**, deny **3.71s → 0.066s**, and the cost no longer scales with the index. Semantics are a port, not a redesign: the old sed spellings' **greedy** `.*` anchored the tag block on the rightmost `.md)` whose remainder matches (a description citing another memory file parses differently under leftmost-match), and `tr -d ' '` strips spaces *anywhere* in a tag. Interval quantifiers are avoided — macOS ships BWK awk, which has no `{0,2}` — so declension tolerance is spelled `([a-zA-Z]([a-zA-Z])?)?`; output verified byte-identical under mawk and busybox awk.
|
|
20
|
+
- **fix (found by the pre-tag review): the new matcher had a silent 128 KiB cliff — the release's own defect, reintroduced.** The haystack was handed to `awk` through the environment, and Linux caps a single `execve` string at `MAX_ARG_STRLEN` = 128 KiB. Past that `awk` is never exec'd, `2>/dev/null` swallows *Argument list too long*, the empty output reads as "no matches", and **no fail-open row is written** — a blocking gate going quiet exactly when it stops working. Reproduced on the pre-fix code: haystack 131000 → DENY, **131072 → ALLOW, 300000 → ALLOW**, while the v0.68.1 loop it replaced (grep reading stdin, unbounded) denied at every size. Both call sites reach it: the hint hook's haystack is the raw user prompt, and a pasted log or transcript clears 128 KiB routinely. Fixed by spilling through a file above the bound; the bound is set at 30000 *characters* rather than the true byte limit because `${#hay}` counts characters under a UTF-8 locale and the kernel counts bytes. Pinned at 131000 / 131072 / 300000 bytes — nothing else in the parity corpus was above ~1 KB, which is why the cliff was invisible.
|
|
21
|
+
- **fix (review): tag-block precedence was by position, not by form.** The shell version ran the whole backtick sed over the line and fell back to the plain sed only when it matched nowhere; the awk walked `.md)` offsets right-to-left trying backtick-then-plain at *each* offset. On `- [A](x.md) `[tag1]` and (y.md) [tag2] — desc` the oracle yields `y.md tag1`; the one-pass form yielded `y.md tag2` — a **lost deny** for one tag and a **gained deny** for another. Now two passes. The corpus's "second link in desc" line carried no bracketed token after the second link, so it could not see this; the shape is now in the corpus.
|
|
22
|
+
- **fix (review): two narrowings that changed which entries are tagged at all.** Whitespace was `[ \t]` where the sed used `[[:space:]]`, so a vertical tab or CR between the link and the tag block silently untagged the entry (now `[ \t\r\v\f]`). And `[—-]` is a **byte** class on a byte-oriented awk: em-dash is three bytes, so mawk and busybox accepted any separator starting with `0xE2` — en-dash, `→`, `≥` — as a tag-block terminator, while a character-oriented awk (gawk, recent macOS awk) reads the same source as a two-element class and rejects them. **That is the ubuntu and macos CI legs parsing the index differently from identical source**, and the previous entry's "verified byte-identical under mawk and busybox awk" cross-checked two byte-oriented engines, so it structurally could not see it. Now an alternation `(—|-)`.
|
|
23
|
+
- **fix (review): a truncated `memory-tags.sh` allowed every push.** Both hooks guarded on `source` returning non-zero, but a file truncated mid-heredoc **sources cleanly** and simply never defines the function — the deny hook then hit `memtags_match: command not found`, matched nothing, allowed, and logged nothing. `user-journey.test.sh` already exercises a truncated marketplace cache, so the shape is in scope. Both now assert the symbol with `declare -f`, and the suite pins that the truncated file still sources with exit 0 — otherwise the fixture would stop reproducing the thing it exists to catch.
|
|
24
|
+
- **test (review): three mutations against the new gates stayed GREEN and now go RED.** (a) The consumer-enumeration check was satisfied by a **comment** — both hooks name `memtags_match` in their rationale prose, so the real call could be replaced by a no-op and the assertion held. Fixing that surfaced a second one immediately: the `declare -f memtags_match` prereq guard added above is itself a non-comment mention, which re-fed the assertion; the check now strips comments *and* drops the guard line, so only an invocation counts. (b) The parity corpus could not see the rightmost-`.md)` anchoring the header calls load-bearing — flipping the walk to leftmost kept the suite green. (c) `hook-budget.test.sh` accepted a state-dir write as reach proof for `mem-audit`, which touches its sentinel **before** scanning by design, so injecting `exit 0` right after the touch — zero work — passed both its reach and its budget assertion. Its fixture files now carry real content that produces a real finding, and its stderr banner is the proof. All four mutations (plus the 128 KiB revert) were replayed against the patched tree: baseline green, every mutation red.
|
|
25
|
+
- **test: `memory-tags-parity.test.sh` keeps the extraction honest.** Performance work that quietly changes matching would swap a visible timeout for an invisible behavior change, and a gate that stops denying looks exactly like a gate with nothing to deny. The **old loop is retained verbatim as an oracle** and compared byte-for-byte over 14 shapes (both tag-block forms, regex-metachar tags like `v6.9` / `printf-%b`, leading-dash tags that `grep` would read as flags, CJK, internal spaces, a decorative `[token]` in the description, a second `.md` link in the description). Then it **breaks the awk on purpose and requires the same comparison to notice** — the first draft of that control used `sed` on a metacharacter-heavy anchor, silently failed to substitute, and reported parity against an unmodified copy of itself. The consumer set is derived from source rather than named, no hook may carry the private declension regex again, and the doctor's independent JS parser (`scripts/lib/memory-tags.js`, whose comments claim to mirror the hook) is now diffed against the shell one.
|
|
26
|
+
- **test: `hook-budget.test.sh` — every data-scaling hook must finish inside half its declared timeout.** Two instruments already existed and neither could see this. `timeout-guard.test.sh` matches on name only: it guards the **test runner's** wall clock. `scripts/perf-baseline.sh` measured hook cost inside a bare `mktemp -d`, so no MEMORY.md exists for that cwd, `memory-read-check.sh` took its `[[ -f "$MEM_INDEX" ]]` fail-open exit, and the tool reported **0.03s for a hook that costs 1.91s** — a 60× underread, structural rather than unlucky, because the fixture omitted the data the cost scales with. The new gate derives its subject set **from source** (any hook reading MEMORY.md, a transcript, or the rule-hits log — 8 today), so a new data-scaling hook without a probe fails rather than going silently uncovered; and every probe must **prove it reached data-dependent code** (stdout, stderr, a rule-hits row, or a state write), because a probe timing a fail-open exit passes forever while measuring nothing. That assertion caught two of the gate's own probes, whose opt-in env was set inside a command substitution and lost with the subshell. Fixture is 150 entries / 750 tags, a 5MB transcript and a 1.9MB rule-hits log — above today's real numbers so it speaks before the next growth step, not after.
|
|
27
|
+
- **perf: `mem-audit.sh` dropped its per-file forks** — found by the new gate at 0.93s of a 3s budget, the same defect class in a third place: `basename` + `wc -c` + two `grep` per memory file, plus a `printf | grep -qFx` per index entry in each direction. Its own header already documented the consequence being worked around (the sentinel is touched *before* the scan because the loop was outrunning the timeout). One awk pass over the directory plus `ENVIRON`-carried lookup tables: **0.93s → 0.105s** against a fixture whose files actually have bodies to scan. (An earlier 0.039s for this hook was measured against 150 *empty* files, i.e. against the probe blindness the review found and (c) above fixes — the honest number is the slower one.) macOS runners are ~4× slower at process creation, so at 0.93s the new gate would have gone red on CI for a hook that was not its subject. The set-difference rewrite deliberately avoids the idiomatic two-file `NR == FNR` form: when the first stream is **empty**, awk never reads a record from it, so `NR == FNR` still holds for the first record of the second stream and swallows it — a memory dir holding only `MEMORY.md` is exactly that shape, and `mem-audit.test.sh` case 9 caught the `index_orphan` going silent. Selection, the 400-byte floor, both marker punctuations and find-order sampling are unchanged.
|
|
28
|
+
- **fix: `perf-baseline.sh` measures against a populated fixture, and checks that its probe arrives.** Sandbox HOME now carries an 80-entry MEMORY.md, a 2.4MB transcript and a logs dir, with `cwd`/`session_id` in the event envelopes so the memory and transcript hooks resolve it; the **UserPromptSubmit chain — the one that actually blew its timeout — was not probed at all** and now is. Before timing anything the script drives `memory-read-check` with a command that must DENY and warns on stderr when it does not; `perf-baseline-hermetic.test.sh` asserts that stderr stays clean, so fixture drift fails the suite instead of quietly halving the numbers. Also replaces `date +%s%N` with bash's `time` builtin: `%N` is a GNU extension, so on the macOS leg every subtraction was arithmetic on a literal `N`. Chains still not probed there (SessionStart / Stop / SessionEnd / PostToolUse) are now named in the script rather than left to be discovered — `hook-budget.test.sh` covers them.
|
|
29
|
+
- **fix: `perf-baseline-hermetic.test.sh` case 3 was racy against a live session.** It counted **every** row in the real `~/.claude/logs/claudemd.jsonl`, and this repo is developed from inside Claude Code, whose own hooks append to that file — it went red during the review (10828 → 10829, written by the reviewer's own hooks) and passed on a standalone re-run. From the assertion's side that is indistinguishable from the probe pollution it exists to catch. It now counts only rows whose `hook` is one of the six perf-baseline actually drives. Pre-existing, not new in 0.68.2, but it fires on exactly the path this project ships from.
|
|
30
|
+
- Shell hook suites 24 → 26; shellcheck 54 → 57 files clean at warning+; `mem-audit` 12/12, `memory-prompt-hint` 23/23, `memory-read-check` 41/41 unchanged; bash 3.2 construct gate, fail-open-mktemp gate and `version-cascade-check` all clean; full suite green. Real-index control: the independent reviewer ran **all 331 distinct tags** of this maintainer's live 76-entry index through both the old loop and the new matcher — 0 divergences — while noting that the live index contains no line with two `.md)` groups, so it cannot exercise the precedence class above; that one needed a built fixture, and now has one.
|
|
31
|
+
|
|
32
|
+
## [0.68.1] - 2026-08-16
|
|
33
|
+
|
|
34
|
+
Patch found by dogfooding the *user journey* rather than the scripts: a sandbox HOME, a versioned marketplace cache dir, real event JSON piped into the hooks from that dir, and the detached background bootstrap polled for. Sixteen phases — install, health check, enforcement, auto-upgrade, stale-registration, upstream notice, self-heal, uninstall, collision with another plugin, truncated cache, degraded machine, kill switches, concurrency — land as `tests/integration/user-journey.test.sh`. Two of the three defects share one root: a precondition validated at its *point of use*, deep inside a mutation sequence, instead of in the pre-flight block that exists for exactly that. No spec change; no behavior change on any healthy install.
|
|
35
|
+
|
|
36
|
+
- **fix: an unparseable `~/.claude/settings.json` left a half-installed claudemd that never self-healed.** `install.js` pre-flight validated the PLUGIN side — spec files complete, `hooks/hooks.json` present and non-empty — under a comment reading *"Fail before touching anything the user owns"*. The one USER-side precondition was not in that block: `readSettings()` ran ~80 lines later, **after** `createBackup()` had `renameSync`'d the user's personal `~/.claude/CLAUDE.md` into `backup-<ts>/` and the spec had overwritten it. A trailing comma from a hand-edit — the common shape for this file, which is shared real estate with Claude Code's own settings — therefore produced: personal `CLAUDE.md` moved, spec installed, **manifest never written**. The manifest is what the SessionStart bootstrap keys on, so every subsequent session re-spawned the same doomed background install. The check now sits with its two siblings, before anything moves. What changes for the user: no file of theirs is touched by the refused install, and the message naming the actual repair reaches `~/.claude/logs/claudemd-bootstrap.log` — the file the in-session banner already points at. The banner text itself is unchanged and still leads with `/claudemd-refresh`, which does not fix JSON syntax; `/claudemd-doctor` names the real cause, as it did before. RED-verified: pre-fix the refused install overwrote the personal file and left an orphan `backup-*` dir; post-fix both are untouched and repairing the JSON is sufficient to get unstuck.
|
|
37
|
+
- **fix: the same file made the plugin impossible to uninstall.** `uninstall.js` called `readSettings()` at the top of its side-effect sequence, so the same trailing comma aborted the whole run: exit 1, manifest still present, state dir still present, spec disposition never reached — with an error that named the file but offered no way forward. Since v0.1.5 the hooks live in the plugin's own `hooks/hooks.json` and `settings.json` normally carries **zero** claudemd entries, which makes that eviction the least load-bearing step in the function. It now degrades to a **reported** skip — new `settingsWarning` field in the JSON result (an additive field, hence still a patch) plus a stderr `[claudemd] WARN:` line — and the manifest / state / spec disposition completes. The user's unparseable file is left byte-for-byte as they wrote it; a silent skip would have been worse than the abort. The pre-tag review caught that the first cut of this was **honest about the wrong half**: `removeStatusline()` reads the same file and fails on the same input, so claudemd kept owning the statusLine while a warning that named only "hook entries" implied otherwise. Both residues are now named, and a `statusline.action === 'error'` — which previously existed only in the returned JSON, surfaced nowhere on the human path — emits its own stderr WARN. `commands/claudemd-uninstall.md` was pointing the slash command at a field name that does not exist (`warning`, not `settingsWarning`) and is corrected to enumerate all three, with the note that `specAction: "keep"` alone does not mean the uninstall was complete.
|
|
38
|
+
- **fix: `X=$(cd "$(mktemp -d)" && pwd -P)` fails OPEN, and five tracked shell files used it.** The one-liner reads as "make a sandbox, resolve its physical path" (the `pwd -P` is needed because macOS `mktemp` returns a `/var` symlink). But when `mktemp` fails, the inner substitution is empty, `cd ""` is a bash **no-op returning 0**, and `pwd -P` prints the *current* directory — so the variable becomes the repo root and the suite's own `rm -rf "$X"` or EXIT trap deletes the working tree. A `[[ -n "$X" ]]` guard is inert against it: the string is non-empty, just wrong. Verified: `TMPDIR=/nonexistent bash -c 'X=$(cd "$(mktemp -d)" && pwd -P); echo "$X"'` prints the cwd. The trigger is not hypothetical — this repo's own `/tmp`-writes gate cites *"Read-only file system wherever /tmp is not writable (agent sandbox, hardened CI image)"* as an observed condition, and `mktemp` honors `TMPDIR`. It is also the exact §8 clause the plugin's `pre-bash-safety-check.sh` denies for everyone else. Three of the five sites pre-date this release (`upgrade-lifecycle.test.sh` ×2, `session-end-check.test.sh`); all five are now two statements with an explicit `|| exit 1`, and `run-all.sh` gains a class gate over every tracked `.sh` so the next person reaching for the one-liner is stopped. Control-verified: the gate fires on a planted occurrence and ignores it inside a comment.
|
|
39
|
+
- **fix: every `npm test` run leaked 4 sandbox dirs into the user's real `TMPDIR`.** Two in `sampling-audit.test.js` (DRIFT-1 / DRIFT-1b `mkdtempSync` with no disposal at all, while the test immediately above them disposes in a `finally`), two in `toggle.test.js` — where the `mkdtempSync` was inlined into a `spawnSync` env literal, so the path was never bound to a name and could not be removed even in principle. They accumulate forever; 48 had piled up in one working scratchpad. This is the §8.V4 residue class the plugin's own Stop hook exists to flag, shipped inside the plugin's own test suite.
|
|
40
|
+
- **test: `run-all.sh` now runs the Node leg under an isolated `TMPDIR` and fails on anything left behind.** Counting `mkdtempSync` against `rmSync` per file does **not** find this class — a `beforeEach`/`afterEach` pair covers many tests, so the counts legitimately disagree, and the two worst offenders sat in files whose ratios looked fine (`sampling-audit` read 6 vs 25). Running the suite in a known-empty dir and inventorying what survives is the only reading that means anything. Node's own `node-compile-cache` is allowlisted by **exact name**: the first cut allowlisted `node-*`, which — as the pre-tag review demonstrated with a planted `node-fixture-XXXX` — silently ignores anything a real leak could be named, the gate being wider than its stated subject. `find -printf` is deliberately avoided (GNU-only; on the macOS leg it would have produced an empty, always-passing list). Control-verified in both directions: a planted leftover turns the gate red, and the real suite went 4 leftovers → 0.
|
|
41
|
+
- Tests 776 → 780 (+4, 0 regressions); integration suites 3 → 4; shellcheck 54 files clean at warning+; bash 3.2 construct gate clean; fail-open-mktemp gate clean over 54 files; `version-cascade-check` ok. The new E2E suite emits 152 assertions over 16 phases. An independent pre-tag review of the staged diff returned one blocker (the fail-open `mktemp` above) and three should-fixes, all folded in here rather than deferred; it also established by reverting `install.js` + `uninstall.js` in a repo copy that the new Phase 15 genuinely goes RED on the pre-fix code, and re-derived the test counts and leak reproduction independently. Two of its findings were assertions in the new suite that **could not fail** — a residue check matching `*.tmp` when the product only ever writes `*.tmp-<pid>` / `*.tmp.<pid>` / `*.tail.$$`, at a depth the check did not reach; and a warm-vs-cold hook sweep whose comment claimed a distinction the measurements do not show. Both are corrected rather than removed.
|
|
42
|
+
|
|
11
43
|
## [0.68.0] - 2026-08-16
|
|
12
44
|
|
|
13
45
|
End-to-end dogfooding pass driven from the *external* surfaces — the npm CLI and a real `git commit-msg` hook — rather than from the test suite. Two of the four defects were unreachable from inside Claude Code, which is why five audits had not seen them: the CLI's documented pre-commit entry point had never been exercised against a **real** `COMMIT_EDITMSG`, and the Node sanitizer had never been handed a file large enough to expose its complexity class. Minor, not patch: two banned-vocab patterns now deny shapes that previously passed (released-artifact checklist below), and `lint` gains flags.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claudemd-cli",
|
|
3
|
-
"version": "0.68.
|
|
3
|
+
"version": "0.68.2",
|
|
4
4
|
"description": "Standalone CLI for §10-V banned-vocab + transcript scanning. Companion to the claudemd Claude Code plugin (github.com/sdsrss/claudemd) for use in git pre-commit hooks, GitHub Actions, and other agents.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|