claudemd-cli 0.68.2 → 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 CHANGED
@@ -8,6 +8,54 @@ 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
+
11
59
  ## [0.68.2] - 2026-08-17
12
60
 
13
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.
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-*` dirs older than N. v0.7.1+ also flags rule sections whose bypass:deny ratio > 50% (R-N6 §0.1 demotion candidates). |
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.1 CLAUDE*.md trio + OPERATOR.md + hard-rules.json manifest
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)
@@ -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.2",
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": {
@@ -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. Strip comment lines only when git wrote a template here.
291
- if (!sawCutLine && !hasGitTemplate(lines, c)) return lines.join('\n');
292
- return lines.filter(l => !l.startsWith(c)).join('\n');
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. Both signals are structural — git localizes
296
- // the LABELS ("Changes to be committed", "Please enter the commit message…")
297
- // but not the `#`+TAB status prefix, and the intro paragraph is ≥3 comment
298
- // lines in every translation.
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 neither signal fires we scan MORE text, so a
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. `commit.status=false` editor commits emit zero comment lines
303
- // (measured), so the undetected case there strips nothing anyway.
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
- if (lines.some(l => l.startsWith(c + '\t'))) return true;
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 } = {}) {