claudemd-cli 0.68.1 → 0.68.4
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 +69 -0
- package/README.md +2 -2
- package/bin/claudemd-lint.js +64 -1
- package/package.json +1 -1
- package/scripts/lib/lint.js +53 -20
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,75 @@ 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.4] - 2026-08-22
|
|
12
|
+
|
|
13
|
+
0.68.3's own CI went red on all three runs, on a suite that was green locally — and the red was the P1-2 budget gate failing to reach the hook it was extended to cover. Shipped runtime code is unaffected: the defect is in the gate's fixture, and `npm-publish` gated on the failing suite, so 0.68.3 never reached npm. This releases the fix and supersedes 0.68.3 on the marketplace channel, which has no such gate.
|
|
14
|
+
|
|
15
|
+
**The fixture's premise was decided by readdir order.** `sandbox-disposal-check` runs `platform_find_newer <dir> <ref> | head -n 50`, so which entries survive the cut is find's output order. The fixture made all 6,000 entries newer than the reference — 5,950 plain files and the 50 `tmp.probe*` directories the hook actually reports on — so whether any directory landed inside the first 50 results was luck. It held on this maintainer's ext4 and did not hold on `ubuntu-latest`: the probe measured an empty scan and the reach assertion failed with "no stdout, no rule-hits row and no state write".
|
|
16
|
+
|
|
17
|
+
- **fix: the bulk fixture entries are now aged** (`tests/hooks/hook-budget.test.sh`), with the session reference dated between them and the 50 directories, so `find -newer` returns exactly those 50 and the cut is order-independent. `find` still walks all 6,000 — the scaling cost the gate measures is unchanged — and the shape is closer to production: mostly old entries, a few fresh. The probe's stderr went from 165B to 526B, i.e. it now reports all 50 rather than however many happened to survive the cut.
|
|
18
|
+
- **fix: the premise has its own assertion.** A fixture self-check runs before anything depending on it and fails with the diagnosis rather than letting the reach proof quietly measure an empty scan. Verified in both directions: removing the aging pass turns it red (`6000 entries newer than the session ref … expected 50/50`).
|
|
19
|
+
|
|
20
|
+
This is the same lesson as the release below, one layer down — a gate whose green depended on something it never asserted. It is also why 0.68.3's CI is the first time that gate ran on CI at all: the P1 batch commit sat unpushed while the two review rounds ran against it.
|
|
21
|
+
|
|
22
|
+
## [0.68.3] - 2026-08-22
|
|
23
|
+
|
|
24
|
+
The 2026-08-22 audit (ninth full round over this repo: 83/100, 0 P0 / 5 P1) is closed here — all five P1 items, each fixed RED-first, no P2/P3 folded in. One is a data-loss path that had been closed once already and was reopened through a different command. The other four are instruments that reported wider coverage than they had: a budget gate that reached 8 of the 11 hooks its own header named, a lint heuristic that switched itself off on a commit body ending in three comment lines, two spec tables giving opposite instructions on the most-travelled routing path, and a blocking gate whose spill path could fail without saying so.
|
|
25
|
+
|
|
26
|
+
Two of these are the same root causes the 2026-08-16 audit named — *a gate narrower than its subject* and *per-session semantics over global state* — recurring inside the v0.67.0..HEAD increment. That recurrence is why this release does not claim the class is converged. The pre-tag review then found the first root cause a third time, inside the fix for it, plus a regression the P1 batch introduced; both are folded in below rather than deferred.
|
|
27
|
+
|
|
28
|
+
- **fix: `/claudemd-update` could hand back the old spec and evict the user's personal backup** (`scripts/lib/backup.js`, `scripts/update.js`, `scripts/install.js`, `scripts/doctor.js`). `update.js` and `install.js` both wrote `backup-<stamp>`, so an update pushed a spec-only backup on top of the personal `~/.claude/CLAUDE.md` backup in the same namespace: `CLAUDEMD_SPEC_ACTION=restore` reads `listBackups()[0]` and returned the **old spec**, and `pruneBackups(5)` evicted the personal content after five updates — the v0.23.11 data-loss mode, reopened through the update path, while `install.js`'s own comment still claimed the personal backup was the sole one. Fixed with a single-source `BACKUP_LABELS` (`backup` / `spec-backup` / `handhook-backup`); `listBackups` / `pruneBackups` take `{label}` and default to the personal namespace, so install/uninstall behavior is unchanged. The hand-hook migration dir was in the restore path too: a run that took no personal backup minted a newest-but-depth-1-empty `backup-` dir, and restore then returned zero files. `doctor`'s inventory and `--prune-backups` now walk every namespace — reporting only the default label made the update dirs invisible to the one command a user runs to see what claudemd has left in `~/.claude`.
|
|
29
|
+
- **fix: the hook-budget gate covered 8 hooks while `perf-baseline`'s header and stdout told the reader it covered the filesystem-scaling family and the SessionStart chain** (`tests/hooks/hook-budget.test.sh`, `scripts/perf-baseline.sh`). `DATA_RE` now also derives `residue-audit` / `sandbox-disposal-check` / `version-sync` — **8 → 11 hooks**, each with a 6,000-entry fixture and a probe assertion. `REACHED` no longer accepts bare stderr (a crashed probe writes there too) and a non-zero exit fails outright. Network-blocking hooks get a static gate rather than a timing probe that would either hit the network or measure the offline early-exit: the call must be wrapped in `platform_timeout N` with N under the `hooks.json` budget (`session-start-check` 3s < 5s, `ship-baseline-check` 2s < 5s), and no remote command may be left unwrapped. `perf-baseline`'s claims now enumerate per class, naming what neither instrument times.
|
|
30
|
+
- **fix: `hasGitTemplate` stripped a §10-V violation from any commit body ending in three comment lines** (`scripts/lib/lint.js`). "Three or more trailing `#` lines" was read as a git-template signal, so a `git commit -F` body of that shape had its violation removed before the scan ever ran: **exit 1 at two such lines, exit 0 at three**. The heuristic was removed — and see the pre-tag review section below, which found that removing it opened a false DENY on a git shape the six-shape table never measured.
|
|
31
|
+
- **fix: the shared memory-tag matcher's spill path failed silently on a blocking gate** (`hooks/lib/memory-tags.sh`). `mktemp` and write failures went quiet with no fail-open row on the DENY path — the same "a gate going quiet exactly when it stops working" shape 0.68.2 shipped to remove. The trap is now registered before `mktemp`, and the spill file carries the `claudemd-` prefix `clean-residue` collects, joined to that reaper by a test that reads the template out of the shell source rather than restating it.
|
|
32
|
+
- **spec v6.25.1 → v6.25.2** (patch; no rule added or removed; enforcement partition unchanged at 6/16/2/1). Extended attributed a quotation to a core §1 clause core does not carry — an external citation the v6.25.0 compression unsourced, leaving a §-attributed quote the reader cannot verify; re-attributed to its real source. Core §2.1 routed `feat L2 (additive)` at `sp:test-driven-development RED-first` while extended §4 told the same case to skip the full ceremony — and L0–L2 load core **only**, so the most-travelled routing path had two tables giving opposite instructions; core now marks the skill optional, which is what §7-EXT's Additive exception always said. Both directions are now gated by joins in `tests/scripts/spec-structure.test.js`. Full entry: `spec/CLAUDE-changelog.md`.
|
|
33
|
+
|
|
34
|
+
Scoped precisely, because the first draft of this line said "wording, identical behavior" and the pre-tag review was right to reject that: for an agent that loads core only, §2.1 went from mandating a skill to marking it optional, and at L2-additive that is a different instruction. It ships as a patch because it resolves a contradiction **toward** the reading §7-EXT already carried rather than granting new latitude — the §2 bugfix exclusion, not an identical-behavior claim.
|
|
35
|
+
|
|
36
|
+
### Pre-tag review (two rounds, folded in, not deferred)
|
|
37
|
+
|
|
38
|
+
Two independent review rounds ran before this tag: one over the P1 batch, one over the repairs that round produced. Between them they found 4 HIGH defects and 8 mutations of the new gates that stayed green. The second round is the one worth reading — **every defect it found was in the repair, not in the original batch**, including a data-loss path opened by the data-loss fix. That is the third consecutive release where the pre-tag review caught the release repeating the class it shipped to remove, and it is the reason this entry ends with what is still open rather than a convergence claim.
|
|
39
|
+
|
|
40
|
+
- **fix: removing `hasGitTemplate`'s second signal opened a false DENY on `commit.template`** (`scripts/lib/lint.js`, `bin/claudemd-lint.js`). The six-shape table measured `commit.status=false` with **no commit template configured**, which is why its row reads "no comment lines, none to strip". Configure one and git hands the `commit-msg` hook a buffer that is entirely git-authored comment lines — no `#\t` status prefix, no cut line — and discards every one of them before storing the commit. Reproduced through a real `git commit` on git 2.43.0: HEAD **exit 1**, v0.68.2 **exit 0**, stored message clean. A §10-V checklist — a template line asking whether the claim was verified or merely looks plausible, phrased with one of the hedges §10-V bans — is exactly what a claudemd user puts in a commit template, so the false positive landed on this project's own audience. The repair is not a third shape heuristic: the CLI now resolves `commit.template` and the cleanup drops those lines **by exact match**, so author-typed `#` lines still get scanned and the P1-3 fix does not regress. `looksLikeSpec`-style single-sourcing applies here too — install-time and cleanup-time both ask one predicate.
|
|
41
|
+
- **fix: the "no unwrapped remote command" gate never inspected the one call it was written for** (`tests/hooks/hook-budget.test.sh`). `REMOTE_RE` matched the literal `git ls-remote`, but the only invocation in the tree is spelled `"${ls_remote_args[@]}"` (`hooks/session-start-check.sh:267`) — so both of the wrapped sites it counted came from `ship-baseline-check`, and a blanket `*':-'*` exclusion dropped any line containing `:-`. Two mutations stayed green: an unwrapped call in the tree's own spelling, and an unwrapped literal on a `:-` line. The gate now **derives the invocation spelling from the source** — pass 1 finds definition-shaped lines and captures the variable they feed, pass 2 requires every expansion of it to be wrapped — with vacuity floors on both passes, and it tells a command-holding variable from an output-holding one (`RUN_JSON=$(… gh run list …)` is a call whose result is data). All three mutations now fail; the control confirms the harness applies them.
|
|
42
|
+
- **P1-1 is forward-only, and stays that way on purpose.** Giving `update.js` its own label stops **new** spec backups landing in the personal namespace; on a machine that ran updates before this release, the old ones are still there, so `CLAUDEMD_SPEC_ACTION=restore` still returns a spec and they still count against `pruneBackups(5)`. A migration for them was written, tested, and **withdrawn before tagging** when the delta review disproved the invariant it rested on: the discriminator was "install.js only backs up non-spec files, so a spec-shaped `backup-` dir came from update.js", which is true of today's `install.js` and false of the one that wrote those dirs — the first `install.js` (`cc36e2b`) backed up unconditionally, and this changelog's own v0.23.11 entry records the whole pre-v0.23.11 window "backed up the spec itself". Moving on it would have carried sibling user files out of a restore path that reads the personal label only, and landed them in a namespace `update.js` prunes on every run: deletion after five updates. A data-loss path opened by a data-loss fix. **`/claudemd-doctor` now reports the condition instead** (`backup-namespace-legacy`, naming each dir and its sibling files) and the user decides. Constraints a real migration must satisfy: `tasks/legacy-spec-backup-migration.md`.
|
|
43
|
+
- **fix: one dangling symlink in `~/.claude` took out `install`, `uninstall`, `doctor` and `update`** (`scripts/lib/backup.js`). `dirSize` statSync'd every entry of every backup dir unguarded, and `listBackups` is on all four paths. The trigger is routine rather than exotic: `createBackup` uses `renameSync`, and `rename(2)` on a symlink moves the **link**, so anyone who symlinks `~/.claude/CLAUDE.md` into a dotfiles repo gets that symlink stored inside a backup dir — dangling the moment the source moves. A plain file whose name matches the backup grammar hit the same code with `ENOTDIR`. Both are now handled; an unreadable entry counts as 0 bytes rather than refusing the run.
|
|
44
|
+
- **fix: `doctor`'s inventory was widened by P1-1; its remediation text was not.** Both places that tell a user how to clear backups still named `~/.claude/backup-*` — a glob matching neither `spec-backup-*` nor `handhook-backup-*`, so following the printed instruction against a reported count of 7 removed 2. The string is now derived from `BACKUP_LABELS` (`scripts/lib/backup.js`, `scripts/doctor.js`, `README.md`).
|
|
45
|
+
- **fix: `/claudemd-doctor` reported success while doing nothing** (`scripts/doctor.js`, `scripts/status.js`). Neither top-level `.then()` had a `.catch()`, so a throw inside became an unhandled rejection: a stack instead of check output, and — the part that matters — **exit 0**, so a CI step or hook gating on `node scripts/doctor.js` read a crashed run as a pass. Both now name the failure and exit non-zero. With the symlink fix above, that handler is a backstop rather than the primary path.
|
|
46
|
+
- **fix: three more spellings rode through the new remote-command gate** (`tests/hooks/hook-budget.test.sh`). The first cut of the HIGH-2 repair enumerated two expansion spellings (`${v[@]}`, `"$v"`), so an unwrapped call spelled `$v` or `${v[*]}` still passed, as did a line that both defined the seam and called it. Pass 2 now matches a `$`-anchored variable reference instead of an enumerated list — which also excludes the pure definition line for free, since it never expands the variable. The vacuity floor became **per variable**: a shared counter let a seam with zero call sites pass as long as some other seam supplied one, which is how all three mutations stayed green. A correctly-wrapped `local -a` / `declare -a` seam also produced a false RED pointing at the definition line; flags are now tolerated on both definition shapes.
|
|
47
|
+
- **fix: a property `lint.js` documents as load-bearing had no test** (`tests/scripts/lint-commit-msg.test.js`). A template's **non-comment** lines are the author's text — git stores them verbatim — so they must stay scannable while its comment lines are dropped. The property was guarded in two places, so no single-guard mutation was observable and removing both left every test green. Now pinned at the library and CLI level.
|
|
48
|
+
- **fix: `perf-baseline`'s self-check warned on stderr and exited 0** (`scripts/perf-baseline.sh`). A scripted caller therefore collected the numbers alongside a success code, from a run the script already knew was an underread. The output is unchanged; the exit code now carries the verdict, gated by a new case 5 in `perf-baseline-hermetic.test.sh` that forces the failure through the real kill switch.
|
|
49
|
+
- **fix: two comments asserted things that were not true** (`hooks/lib/memory-tags.sh`). One claimed the 128 KiB branch records a fail-open row — it does not, and needs none, because it routes to the spill rather than failing open; the two spill calls are the only such sites in the file. The other justified trap-before-`mktemp` by a window it does not close (a signal between file creation and variable assignment fires the handler with an empty variable either way). The ordering is still right, for the windows after the assignment — which is where the process spends its time — and the comment now says that instead.
|
|
50
|
+
|
|
51
|
+
**Not fixed, and why.** Three things, stated rather than left to be discovered:
|
|
52
|
+
|
|
53
|
+
- The probe assertion in `hook-budget.test.sh` shows a hook ran and did observable work, but for `version-sync` and `residue-audit` it does not prove the data-scaling walk itself ran: deleting either walk leaves the gate green, because a rule-hits row still arrives from another branch. `sandbox-disposal-check`'s walk **is** discriminated, and a crude early `exit 0` in any of the three is caught. Closing the gap needs a differential probe (empty vs populated fixture, per hook) — `tasks/hook-budget-reach-discrimination.md`. The gate's claim was narrowed to what it proves rather than left implying more.
|
|
54
|
+
- Legacy spec backups in the personal namespace are reported, not migrated — see the P1-1 bullet above and `tasks/legacy-spec-backup-migration.md`.
|
|
55
|
+
- An unreadable or directory `commit.template` makes the CLI fall back to the shape signals, which restores the false DENY it fixes, and nothing tells the user why. `--allow-empty` on a clean tree is likewise still scanned. Both are named in the source rather than covered.
|
|
56
|
+
|
|
57
|
+
Suite: node **790 → 810 pass / 0 fail**; bash **608 → 611 PASS / 0 FAIL**; `OVERALL: all suites passed` on every run since the last repair; shellcheck clean over 57 files. (The 790/608 baselines are this release's own pre-review state — the P1 batch measured 780 → 790 node and 589 → 608 bash against v0.68.2. The +20 node tests are the two review rounds': 6 lint template, 2 lint template-property, 8 backup contract + robustness, 3 doctor/status handler, 1 relocated.)
|
|
58
|
+
|
|
59
|
+
## [0.68.2] - 2026-08-17
|
|
60
|
+
|
|
61
|
+
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.
|
|
62
|
+
|
|
63
|
+
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.
|
|
64
|
+
|
|
65
|
+
**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.
|
|
66
|
+
|
|
67
|
+
- **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.
|
|
68
|
+
- **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.
|
|
69
|
+
- **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.
|
|
70
|
+
- **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 `(—|-)`.
|
|
71
|
+
- **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.
|
|
72
|
+
- **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.
|
|
73
|
+
- **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.
|
|
74
|
+
- **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.
|
|
75
|
+
- **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.
|
|
76
|
+
- **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.
|
|
77
|
+
- **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.
|
|
78
|
+
- 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.
|
|
79
|
+
|
|
11
80
|
## [0.68.1] - 2026-08-16
|
|
12
81
|
|
|
13
82
|
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/README.md
CHANGED
|
@@ -111,7 +111,7 @@ Per-hook timeout (3-5s in `hooks.json`); timeout = treated as exit 0 (pass) per
|
|
|
111
111
|
| `/claudemd-refresh` | v0.48.0 — one-shot plugin refresh (marketplace update → uninstall → install via the `claude` CLI). Restart Claude Code afterwards; spec + manifest sync is automatic. Fired by the SessionStart upgrade banner. |
|
|
112
112
|
| `/claudemd-audit [N]` | Aggregate rule-hits over last N days (default 30). Top banned-vocab patterns, per-hook deny counts. Slash form takes a bare number (`/claudemd-audit 90`); direct script invocation takes `--days=N` (= form only). |
|
|
113
113
|
| `/claudemd-toggle <hook-name>` | Enable/disable a specific hook by toggling `DISABLE_*_HOOK` in `settings.json` env. |
|
|
114
|
-
| `/claudemd-doctor [--prune-backups=N]` | Health checks; optionally prune `~/.claude/backup-*`
|
|
114
|
+
| `/claudemd-doctor [--prune-backups=N]` | Health checks; optionally prune each backup namespace (`~/.claude/backup-*`, `spec-backup-*`, `handhook-backup-*`) to its N newest. v0.7.1+ also flags rule sections whose bypass:deny ratio > 50% (R-N6 §0.1 demotion candidates). |
|
|
115
115
|
| `/claudemd-rules [N]` | v0.8.0+ — audit `spec/hard-rules.json` manifest over last N days (default 30 — lowered from 90d in v0.13.1 after the 90d gate was structurally unreachable under typical log retention). Surfaces `demoteCandidates` (hook-enforced rules with 0 hits) and `staleReviews` (rules whose `last_demote_review` is null/old). |
|
|
116
116
|
| `/claudemd-sparkline [--days=A,B,C]` | v0.8.4+ R-N9 — per-`spec_section` cumulative counts of signal events across 3 windows (default 30/60/90d). Trend arrow compares per-period rate; `(newly active)` / `(silenced)` annotations flag activation/deactivation transitions. Markdown block suitable for CHANGELOG header pre-release. |
|
|
117
117
|
| `/claudemd-clean-residue [--apply]` | Dry-run-by-default cleanup of stale `claudemd-sync-*` sentinels and historical `claudemd-(mockgh\|work).*` test sandboxes. |
|
|
@@ -358,7 +358,7 @@ claudemd/
|
|
|
358
358
|
├── commands/ # 16 slash-command markdown files
|
|
359
359
|
├── bin/ # standalone CLI entrypoint (claudemd-lint.js → `npx claudemd-cli` on npmjs.org)
|
|
360
360
|
├── scripts/ # 18 Node.js scripts + scripts/lib/ (single-source registry, lint, etc.)
|
|
361
|
-
├── spec/ # shipped v6.25.
|
|
361
|
+
├── spec/ # shipped v6.25.2 CLAUDE*.md trio + OPERATOR.md + hard-rules.json manifest
|
|
362
362
|
├── tests/ # hook shell tests + Node.js tests + integration + fixtures
|
|
363
363
|
├── docs/ # ADDING-NEW-HOOK.md + RULE-HITS-SCHEMA.md + superpowers/
|
|
364
364
|
└── .github/workflows/ # ci.yml (ubuntu+macOS × node 20/22/24) + npm-publish.yml (tag-triggered)
|
package/bin/claudemd-lint.js
CHANGED
|
@@ -15,7 +15,9 @@
|
|
|
15
15
|
// node bin/claudemd-lint.js audit transcript.jsonl
|
|
16
16
|
|
|
17
17
|
import fs from 'node:fs';
|
|
18
|
+
import os from 'node:os';
|
|
18
19
|
import path from 'node:path';
|
|
20
|
+
import { spawnSync } from 'node:child_process';
|
|
19
21
|
import { fileURLToPath } from 'node:url';
|
|
20
22
|
import {
|
|
21
23
|
scan,
|
|
@@ -81,6 +83,65 @@ function readPackageVersion() {
|
|
|
81
83
|
}
|
|
82
84
|
}
|
|
83
85
|
|
|
86
|
+
// Resolve the repo's `commit.template` and return its lines, so cleanup can
|
|
87
|
+
// drop template text by exact match instead of by shape.
|
|
88
|
+
//
|
|
89
|
+
// Why this exists (0.68.3 pre-tag review, HIGH-1): `commit.template` +
|
|
90
|
+
// `commit.status=false` hands a commit-msg hook a buffer that is entirely
|
|
91
|
+
// git-authored comment lines — no `#\t` status prefix, no cut line — and git
|
|
92
|
+
// discards all of them. Both shape signals miss it, so lint scanned a checklist
|
|
93
|
+
// the author never committed and denied a clean commit. A §10-V checklist in a
|
|
94
|
+
// commit template is exactly what this project's own users write.
|
|
95
|
+
//
|
|
96
|
+
// Best-effort by construction: no git, no repo, unset config, unreadable file
|
|
97
|
+
// → null, and the caller falls back to the shape signals unchanged.
|
|
98
|
+
function readCommitTemplate(sourcePath) {
|
|
99
|
+
// A commit-msg hook runs with cwd at the work tree root, so that is the first
|
|
100
|
+
// place to ask. The message file's own directory is the fallback — and it is
|
|
101
|
+
// usually `.git/`, where `rev-parse --show-toplevel` refuses to answer
|
|
102
|
+
// ("this operation must be run in a work tree"), so the work tree is derived
|
|
103
|
+
// from `--absolute-git-dir` instead of assumed.
|
|
104
|
+
const starts = [process.cwd()];
|
|
105
|
+
if (sourcePath) starts.push(path.dirname(path.resolve(sourcePath)));
|
|
106
|
+
|
|
107
|
+
for (const cwd of starts) {
|
|
108
|
+
try {
|
|
109
|
+
const git = (...a) => spawnSync('git', a, {
|
|
110
|
+
cwd, encoding: 'utf8', timeout: 5000, windowsHide: true,
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
const cfg = git('config', '--get', 'commit.template');
|
|
114
|
+
if (cfg.status !== 0 || !cfg.stdout || !cfg.stdout.trim()) continue;
|
|
115
|
+
let tmpl = cfg.stdout.trim();
|
|
116
|
+
|
|
117
|
+
// git expands a leading `~/`; it does not expand shell variables.
|
|
118
|
+
if (tmpl === '~' || tmpl.startsWith('~/')) {
|
|
119
|
+
const home = os.homedir();
|
|
120
|
+
if (home) tmpl = path.join(home, tmpl.slice(1));
|
|
121
|
+
}
|
|
122
|
+
if (!path.isAbsolute(tmpl)) tmpl = path.resolve(workTreeOf(git, cwd), tmpl);
|
|
123
|
+
|
|
124
|
+
return fs.readFileSync(tmpl, 'utf8').split('\n');
|
|
125
|
+
} catch {
|
|
126
|
+
// fall through to the next candidate
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// The work tree a relative `commit.template` resolves against.
|
|
133
|
+
function workTreeOf(git, cwd) {
|
|
134
|
+
const top = git('rev-parse', '--show-toplevel');
|
|
135
|
+
if (top.status === 0 && top.stdout.trim()) return top.stdout.trim();
|
|
136
|
+
// Called from inside the git dir: the work tree is its parent. `.git` for a
|
|
137
|
+
// normal repo, `.git/worktrees/<name>` for a linked one — hence common-dir.
|
|
138
|
+
const common = git('rev-parse', '--git-common-dir');
|
|
139
|
+
if (common.status === 0 && common.stdout.trim()) {
|
|
140
|
+
return path.dirname(path.resolve(cwd, common.stdout.trim()));
|
|
141
|
+
}
|
|
142
|
+
return cwd;
|
|
143
|
+
}
|
|
144
|
+
|
|
84
145
|
// Strict-validate flag-shaped args + normalize `--key=value` → `--key value`
|
|
85
146
|
// pairs so the existing space-form parsing below works on either shape.
|
|
86
147
|
// Catches the same antipattern the slash-command CLIs hit in v0.9.16/0.9.17:
|
|
@@ -305,7 +366,9 @@ function lintCmd(rawArgs) {
|
|
|
305
366
|
// sitting in a `#` line git will discard is not in the commit message either.
|
|
306
367
|
const commitMsgCleanup = !denyCommitMsg && (forceCommitMsg || looksLikeGitMessageFile(sourcePath));
|
|
307
368
|
if (commitMsgCleanup) {
|
|
308
|
-
text = stripGitCommitComments(text, commentChar
|
|
369
|
+
text = stripGitCommitComments(text, commentChar, {
|
|
370
|
+
templateLines: readCommitTemplate(sourcePath),
|
|
371
|
+
});
|
|
309
372
|
}
|
|
310
373
|
|
|
311
374
|
// Per-commit escape hatch — mirrors hooks/banned-vocab-check.sh:36. Without
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claudemd-cli",
|
|
3
|
-
"version": "0.68.
|
|
3
|
+
"version": "0.68.4",
|
|
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": {
|
package/scripts/lib/lint.js
CHANGED
|
@@ -275,7 +275,7 @@ export function looksLikeGitMessageFile(filePath) {
|
|
|
275
275
|
// editor shapes stored none. Stripping unconditionally therefore muted a
|
|
276
276
|
// real violation in `git commit -F release-notes.md` or
|
|
277
277
|
// `-m "$(cat notes.md)"` whenever the body carried a markdown heading.
|
|
278
|
-
export function stripGitCommitComments(text, commentChar = '#') {
|
|
278
|
+
export function stripGitCommitComments(text, commentChar = '#', { templateLines } = {}) {
|
|
279
279
|
if (!text) return text;
|
|
280
280
|
const c = (typeof commentChar === 'string' && commentChar.length === 1) ? commentChar : '#';
|
|
281
281
|
const esc = c.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
@@ -287,31 +287,64 @@ export function stripGitCommitComments(text, commentChar = '#') {
|
|
|
287
287
|
const lines = cutAt === -1 ? all : all.slice(0, cutAt);
|
|
288
288
|
const sawCutLine = cutAt !== -1;
|
|
289
289
|
|
|
290
|
-
// 2.
|
|
291
|
-
|
|
292
|
-
|
|
290
|
+
// 2. Drop lines git copied verbatim out of `commit.template`. This is an
|
|
291
|
+
// exact match against the template file's own comment lines, not a shape
|
|
292
|
+
// heuristic: git knows they are template lines because it copied them, and
|
|
293
|
+
// it discards them under the editor path's cleanup=strip. Author-typed
|
|
294
|
+
// comment lines are absent from the template and stay in scope (P1-3).
|
|
295
|
+
const fromTemplate = templateComments(templateLines, c);
|
|
296
|
+
const body = fromTemplate.size
|
|
297
|
+
? lines.filter(l => !(l.startsWith(c) && fromTemplate.has(l)))
|
|
298
|
+
: lines;
|
|
299
|
+
|
|
300
|
+
// 3. Strip the remaining comment lines only when git wrote a status block or
|
|
301
|
+
// a cut line here.
|
|
302
|
+
if (!sawCutLine && !hasGitTemplate(body, c)) return body.join('\n');
|
|
303
|
+
return body.filter(l => !l.startsWith(c)).join('\n');
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// The comment lines of a resolved `commit.template`, as an exact-match set.
|
|
307
|
+
// Non-comment template lines are deliberately excluded: git KEEPS those in the
|
|
308
|
+
// stored message, so they are the author's text and must stay scannable.
|
|
309
|
+
function templateComments(templateLines, c) {
|
|
310
|
+
const set = new Set();
|
|
311
|
+
if (!templateLines) return set;
|
|
312
|
+
const arr = Array.isArray(templateLines) ? templateLines : String(templateLines).split('\n');
|
|
313
|
+
for (const l of arr) if (typeof l === 'string' && l.startsWith(c)) set.add(l);
|
|
314
|
+
return set;
|
|
293
315
|
}
|
|
294
316
|
|
|
295
|
-
// Locale-proof template detection.
|
|
296
|
-
//
|
|
297
|
-
//
|
|
298
|
-
//
|
|
317
|
+
// Locale-proof template detection. The signal is structural — git localizes the
|
|
318
|
+
// LABELS ("Changes to be committed", "Please enter the commit message…") but
|
|
319
|
+
// never the `<commentChar>`+TAB status prefix that introduces each file it
|
|
320
|
+
// lists. (The cut line is the other signal, handled by the caller.)
|
|
299
321
|
//
|
|
300
|
-
// Deliberately conservative: when
|
|
322
|
+
// Deliberately conservative: when it does not fire we scan MORE text, so a
|
|
301
323
|
// misdetection costs a false positive (visible, bypassable) rather than a
|
|
302
|
-
// silent miss.
|
|
303
|
-
//
|
|
324
|
+
// silent miss.
|
|
325
|
+
//
|
|
326
|
+
// A second signal used to live here — "≥3 contiguous comment lines ending at
|
|
327
|
+
// EOF" — meant to catch git's intro paragraph. Removed (audit-2026-08-22 P1-3)
|
|
328
|
+
// because of what it reached in the other direction: a `git commit -F notes.md`
|
|
329
|
+
// body (cleanup=whitespace — git KEEPS those lines) ending in three `#` lines
|
|
330
|
+
// had them stripped before the scan, muting any §10-V violation inside. Same
|
|
331
|
+
// violation at 2 trailing `#` lines denied, at 3 it exited 0 — a silent miss
|
|
332
|
+
// that grew MORE likely the longer the commented block got, on the shipped
|
|
333
|
+
// pre-commit/CI entry point.
|
|
334
|
+
//
|
|
335
|
+
// That removal was shipped with the claim that no git shape needs the signal.
|
|
336
|
+
// The 0.68.3 pre-tag review refuted it: the six-shape table measured
|
|
337
|
+
// `commit.status=false` with no `commit.template` configured. Configure one and
|
|
338
|
+
// git hands the hook a buffer of pure template comment lines — no `#\t`, no cut
|
|
339
|
+
// line — and discards every one of them. The replacement is not a third shape
|
|
340
|
+
// heuristic but the template's actual content, matched line-for-line; see
|
|
341
|
+
// `templateComments` and the `templateLines` option on the caller above.
|
|
342
|
+
// The `--allow-empty`-on-a-clean-tree shape (git status prose, no `#\t`,
|
|
343
|
+
// no cut line) is still scanned and is NOT covered here — stating it rather
|
|
344
|
+
// than implying the set is closed, which is the error this comment shipped.
|
|
304
345
|
function hasGitTemplate(lines, c) {
|
|
305
346
|
// git's status file list: `#\tmodified: path`
|
|
306
|
-
|
|
307
|
-
// The intro paragraph: ≥3 contiguous comment lines ending at EOF (trailing
|
|
308
|
-
// blanks ignored). A hand-written `-m` message carries one such line, not a
|
|
309
|
-
// run of three terminating the file.
|
|
310
|
-
let i = lines.length - 1;
|
|
311
|
-
while (i >= 0 && lines[i].trim() === '') i--;
|
|
312
|
-
let run = 0;
|
|
313
|
-
while (i >= 0 && lines[i].startsWith(c)) { run++; i--; }
|
|
314
|
-
return run >= 3;
|
|
347
|
+
return lines.some(l => l.startsWith(c + '\t'));
|
|
315
348
|
}
|
|
316
349
|
|
|
317
350
|
export function scan(text, { excludeRatio = false, patterns, sanitize = false } = {}) {
|