claudemd-cli 0.68.1 → 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.
Files changed (2) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -8,6 +8,27 @@ 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
+
11
32
  ## [0.68.1] - 2026-08-16
12
33
 
13
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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claudemd-cli",
3
- "version": "0.68.1",
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": {