claudemd-cli 0.9.22 → 0.9.24

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,57 @@ 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.9.24] - 2026-05-10
12
+
13
+ **Patch — doc-vs-code drift fixes from a 5-round self-iteration dogfood pass.** Spec v6.11.8 unchanged. Zero runtime behavior change — pure `bin/` USAGE strings + README §Per-hook list nudged into lockstep with the actual code, plus two new regression anchors that fail loudly on future drift.
14
+
15
+ ### Fixed
16
+
17
+ - `[fix]` **`bin/claudemd-lint.js` USAGE references `claudemd-cli` (not `claudemd`)** — the npm `bin` key is `claudemd-cli`, but the help text shown by `--help` listed every subcommand as `claudemd lint <text>` / `claudemd audit <jsonl-path>` / `claudemd --version`. A user copying the documented command verbatim hit `command not found: claudemd`. Pre-fix discrepancy isolated to the help-text surface (10 occurrences across USAGE block + header + Notes + one inline comment); README, CHANGELOG, and the pre-commit hook example already used the correct name. Affects v0.9.0+ (the surface where `claudemd-cli` became the published bin).
18
+ - `[fix]` **README §Per-hook kill-switch list lockstep with `hook_kill_switch <NAME>` calls in `hooks/*.sh`** — three `DISABLE_*_HOOK` env vars that real hooks DO honor were undocumented (`DISABLE_MEM_AUDIT_HOOK`, `DISABLE_TRANSCRIPT_VOCAB_SCAN_HOOK`, `DISABLE_TRANSCRIPT_STRUCTURE_SCAN_HOOK`), and one documented var carried a wrong owning-hook annotation: README labeled `DISABLE_USER_PROMPT_SUBMIT_HOOK` as disabling `transcript-vocab-scan`, but the `hook_kill_switch USER_PROMPT_SUBMIT` call lives in `version-sync.sh`, so the env actually disables version-sync. A user trying to silence transcript-vocab-scan via the documented var would silence the wrong hook. Drift introduced incrementally through v0.9.4 / v0.9.10 (mem-audit + transcript-structure-scan additions) without a corresponding README update.
19
+
20
+ ### Tests
21
+
22
+ - `[test]` **`tests/scripts/help-discoverability.test.js` +1 case**: `bin/claudemd-lint.js --help` USAGE references the actual npm `bin` key (read from `package.json`), and never the bare `claudemd <subcommand>` form. Catches future bin renames + USAGE edits in either direction.
23
+ - `[test]` **`tests/scripts/kill-switch-doc-drift.test.js` (new, 2 cases)**: parses `hook_kill_switch <NAME>` arg from every `hooks/*.sh`, derives `DISABLE_<NAME>_HOOK`, and asserts each is documented in README; pins all 12 `DISABLE_*_HOOK → <hook-file>` mappings so a future rename can't re-introduce the env-name-vs-owning-hook ambiguity.
24
+
25
+ ### Why no L3 / pre-ship-review chain
26
+
27
+ Two doc/test edits, zero runtime behavior change, zero hook/CLI/spec touch. Suite stays at 350 node tests + 18 bash hook tests + 2 integration suites — all pass. Five rounds of self-iteration (67 scenarios across CLI bounds, hook fail-open, lifecycle anomalies, declared-vs-actual consistency, state-machine coordination, npm packaging dogfood) found these 2 P1 doc-drift items + zero P0/P1 elsewhere; the 2 carryover P2s are tracked inline (sparkline 「3 windows」 README phrasing vs code's `≥2`; corrupt `last-session-summary.json` not auto-cleaned but recoverable on next Stop).
28
+
29
+ ## [0.9.23] - 2026-05-10
30
+
31
+ **Patch — observability + structural-enforcement upgrade.** Spec v6.11.8 unchanged. 6 fixes from a deep dogfood pass on the `~/.claude/logs/claudemd.jsonl` telemetry channel and the lint-argv structural detector. The unifying theme: **resilience without observability** — hooks fail-open silently, jsonl parser silently skips bad rows, sparkline annotations virtual-fire under insufficient log span, status disconnects from settings.json, hard-rules-audit accepts windows shorter than the §0.1 quarterly cadence with no warning, and lint-argv only catches *wrong-shape* argv reads (not "main block doesn't read argv at all" — the v0.9.x silent-fallback family that recurred 9× across 5 dogfood rounds and bit destructive lifecycle scripts in Round 5). Each fix surfaces the silent path so §13.1 reviewers see the full picture.
32
+
33
+ ### Added
34
+
35
+ - `[feat]` **`hooks/lib/hook-common.sh#hook_record_failopen <hook> <reason>`** — rate-limited (60s per (hook,reason) via `~/.claude/.claudemd-state/failopen-*.ts`) emission of `event:"fail-open"` rows to rule-hits.jsonl with `extra.reason ∈ {jq-missing, bad-event, patterns-missing, prereq-missing}` and `spec_section:"§hooks-fail-open"`. Inline JSON construction guards against the case where the missing prerequisite IS jq (the helper would otherwise self-fail-open silently).
36
+ - `[feat]` **`hooks/banned-vocab-check.sh` 3 fail-open exits wired** — `jq-missing` (jq not on PATH), `bad-event` (empty/unreadable stdin), `patterns-missing` (banned-vocab.patterns unreadable). First hook to surface fail-open visibility; pattern is reusable by the remaining 5 hooks (`pre-bash-safety`, `memory-read-check`, `residue-audit`, `sandbox-disposal`, `session-summary`) — out-of-scope for this patch but ≈3 LOC × 3 hooks to extend.
37
+ - `[feat]` **`scripts/lib/rule-hits-parse.js#byFailOpen(hits)`** — aggregates fail-open events by (hook, reason). Surfaced in `audit.js` output as `byFailOpen` segment. Empty `{}` when no fail-open events in window.
38
+ - `[feat]` **`scripts/doctor.js` advisory `hook-fail-open` check** — `[✗] N fail-open event(s) in 30d (hook:reason=count, ...); enforcement silently bypassed. Investigate the named prerequisite.` Always advisory; never blocks (resilience-first design preserved).
39
+ - `[feat]` **`scripts/sparkline.js` log-span defense** — new JSON fields `logSpanDays` / `insufficientSpan` / `windowCoverage`. Markdown banner `[insufficient log span: Xd — trend annotations suppressed; need ≥Yd]` when log doesn't reach the shortest window; `[partial coverage: log spans Xd; ≥Yd windows are not fully covered]` when shortest window is covered but longer ones aren't. The `↗ (newly active)` annotation is suppressed under `insufficientSpan` because `prior bucket = 0 AND recent > 0` is structurally satisfied for EVERY rule when the log is too short — pre-fix, every section trivially flagged "newly active" on fresh-log environments, biasing §0.1 promote/demote inputs.
40
+ - `[feat]` **`scripts/lib/rule-hits-parse.js#readHits` returns `{ hits, totalLines, parsed, skipped }`** (was `Array`). 6 call sites updated (audit×2, sparkline, hard-rules-audit, doctor, test). `audit.js` surfaces `dataIntegrity: { totalLines, parsed, skipped, skipRatio }`. `doctor.js` adds `rule-hits-integrity` check that fires `[✗]` when `skipRatio > 0.01` (1% threshold — below is normal race-write noise). Pre-fix, a corrupt jsonl with 33% bad rows reported the under-counted hits with zero operator visibility — biased §13.1 demote-candidate decisions.
41
+ - `[feat]` **`scripts/status.js#pendingKillSwitches`** — dual-source kill-switch view. `killSwitches.<name>` stays the boolean "effective in this process" (back-compat); `pendingKillSwitches.<name>` shows `{ effective, persisted }` for any hook whose `process.env` and `~/.claude/settings.json env` block disagree. Closes the dogfood confusion where `node scripts/status.js` directly after `/claudemd-toggle X` reported `X: false` because settings.json takes effect at next CC session start, not at file-write time.
42
+ - `[feat]` **`scripts/hard-rules-audit.js#cadenceWarning`** — when `--days < 90`, output JSON includes `--days=N is shorter than the §0.1 quarterly cadence (90d); demote signals may not reflect the spec contract`. Non-blocking (some debugging flows want narrow windows); the wrapper `/claudemd-rules` defaults to 90d so normal usage is unaffected.
43
+ - `[feat]` **`scripts/lint-argv.js#scanMainBlockMissingArgv`** — structural detector for the v0.9.x silent-fallback family. For every `.js` under `bin/` + `scripts/` (excluding `lib/`): if the file has the main-block guard `if (import.meta.url === \`file://${process.argv[1]}\`)`, the body MUST call EITHER `parseStrict(` OR `printHelpAndExit(` OR `validateAndExpandFlags(`. Reports `main-block-without-argv-validation` antipattern when none of the three are present. Closes the structural blind-spot the 3 regex-based PATTERNS couldn't catch — pre-Round-5 `install.js` / `uninstall.js` / `update.js` had main blocks that ignored argv entirely, so `--help` ran the destructive operation silently.
44
+
45
+ ### Changed
46
+
47
+ - `[change]` **`event` taxonomy in `docs/RULE-HITS-SCHEMA.md`** — adds `fail-open` event with reason taxonomy + `§hooks-fail-open` plugin-internal taxonomy entry. `tests/scripts/hard-rules-drift.test.js#KNOWN_HOOK_SECTIONS` synced to add `§hooks-fail-open` (per the taxonomy-sync contract; never targeted by `spec/hard-rules.json` because it's not a spec rule).
48
+ - `[change]` **`scripts/lib/rule-hits-parse.js#readHits` return shape** — Array → `{ hits, totalLines, parsed, skipped }`. Internal change; `bin/claudemd-lint.js` (the npm-published surface) does not import `readHits` and is unaffected. All 6 callers updated in this commit.
49
+
50
+ ### Tests
51
+
52
+ - `[test]` **`tests/hooks/fail-open.test.sh`** (new, 4 cases): empty stdin → emits `fail-open` with `reason=bad-event`; rate-limit suppresses second call within 60s; distinct reason emits separately; `DISABLE_RULE_HITS_LOG=1` suppresses emission.
53
+ - `[test]` **`tests/scripts/rule-hits-parse.test.js`** (+2): `readHits` surfaces skipped count for malformed rows (5 valid + 3 corrupt → `skipped:3 parsed:5 totalLines:8`); missing file returns zero counters.
54
+ - `[test]` **`tests/scripts/sparkline.test.js`** (+2, modified 2): insufficient-span suppresses `newly active` annotation; markdown emits `insufficient log span` banner. Existing `newly active` and `formatMarkdown` tests updated with span-sentinel `daysAgo:95` event so the `>=longest window` check passes.
55
+ - `[test]` **`tests/scripts/status.test.js`** (+2): `pendingKillSwitches` surfaces when `settings.json` env block disagrees with `process.env`; empty `pendingKillSwitches` when env + settings agree.
56
+ - `[test]` **`tests/scripts/lint-argv.test.js`** (+5): structural detector flags `main-block-without-argv-validation` on a synthetic destructive script; passes on scripts that call `parseStrict`, `printHelpAndExit`, or `validateAndExpandFlags`; ignores files without main-block guard. **Live-repo gate stays clean (0 hits)** — all 14 `bin/` + `scripts/` (non-lib) main blocks now have argv contracts thanks to Rounds 1-5 fixes.
57
+
58
+ ### Why no L3 / pre-ship-review chain
59
+
60
+ `feat:` adds additive observability fields + a structural lint check + a new event type in the rule-hits taxonomy (`fail-open` is plugin-internal, not a spec rule). Per spec §2: not architecture, not breaking-schema (additive event class), not migration, not infra. L2 ceiling. Diff summary: 8 source files modified (4 scripts + 1 lib + 1 hook + 1 hook-lib + 1 doc), 1 hook test file added (4 cases), 4 unit test files modified (+11 cases). Total test count: 281 → 351 net of Round 1-6 (+70 cases across 5 dogfood rounds). All argv-lint structural + pattern checks clean.
61
+
11
62
  ## [0.9.22] - 2026-05-10
12
63
 
13
64
  **Patch — `claudemd-doctor` now detects production-hook drift between source-of-truth and `~/.claude/plugins/marketplaces/claudemd/`.** Spec v6.11.7 → v6.11.8 (clarity-only wording fixes; see `spec/CLAUDE-changelog.md` v6.11.8 entry — `[fix]` §10 four-section "Lead with incomplete" disambiguation, `[fix]` §7 L2 evidence example annotated with absolute delta).
package/README.md CHANGED
@@ -164,15 +164,18 @@ export DISABLE_CLAUDEMD_HOOKS=1
164
164
  **2. Per-hook.** Disable one, leave others active:
165
165
 
166
166
  ```bash
167
- export DISABLE_BANNED_VOCAB_HOOK=1 # or
168
- export DISABLE_PRE_BASH_SAFETY_HOOK=1 # or
169
- export DISABLE_SHIP_BASELINE_HOOK=1 # or
170
- export DISABLE_RESIDUE_AUDIT_HOOK=1 # or
171
- export DISABLE_MEMORY_READ_HOOK=1 # or
172
- export DISABLE_SANDBOX_DISPOSAL_HOOK=1 # or
173
- export DISABLE_SESSION_START_HOOK=1 # or
174
- export DISABLE_SESSION_SUMMARY_HOOK=1 # v0.8.0+ — Stop hook writing summary
175
- export DISABLE_USER_PROMPT_SUBMIT_HOOK=1 # transcript-vocab-scan
167
+ export DISABLE_BANNED_VOCAB_HOOK=1 # or
168
+ export DISABLE_PRE_BASH_SAFETY_HOOK=1 # or
169
+ export DISABLE_SHIP_BASELINE_HOOK=1 # or
170
+ export DISABLE_RESIDUE_AUDIT_HOOK=1 # or
171
+ export DISABLE_MEMORY_READ_HOOK=1 # or
172
+ export DISABLE_SANDBOX_DISPOSAL_HOOK=1 # or
173
+ export DISABLE_SESSION_START_HOOK=1 # or
174
+ export DISABLE_SESSION_SUMMARY_HOOK=1 # v0.8.0+ — Stop hook writing summary
175
+ export DISABLE_USER_PROMPT_SUBMIT_HOOK=1 # version-sync (mid-session upgrade re-install)
176
+ export DISABLE_TRANSCRIPT_VOCAB_SCAN_HOOK=1 # PostToolUse §10-V advisory scan
177
+ export DISABLE_TRANSCRIPT_STRUCTURE_SCAN_HOOK=1 # v0.9.10+ — Stop §10 four-section advisory
178
+ export DISABLE_MEM_AUDIT_HOOK=1 # v0.9.4+ — Stop Why:-less citation advisory
176
179
  ```
177
180
 
178
181
  **2a. Per-sub-feature** (v0.4.0+). Sub-flags inside an enabled hook, named without the `_HOOK` suffix:
@@ -6,9 +6,9 @@
6
6
  // so enforcement is consistent across surfaces.
7
7
  //
8
8
  // Once published to npm:
9
- // npx claudemd lint "your commit message here"
10
- // npx claudemd lint --stdin < message.txt
11
- // npx claudemd audit ~/.claude/projects/.../session.jsonl
9
+ // npx claudemd-cli lint "your commit message here"
10
+ // npx claudemd-cli lint --stdin < message.txt
11
+ // npx claudemd-cli audit ~/.claude/projects/.../session.jsonl
12
12
  //
13
13
  // Pre-publish (this repo, dev mode):
14
14
  // node bin/claudemd-lint.js lint "..."
@@ -31,22 +31,22 @@ const REPO_ROOT = path.resolve(HERE, '..');
31
31
  const USAGE = `claudemd-lint — §10-V banned-vocab + transcript scanner
32
32
 
33
33
  Usage:
34
- claudemd lint <text> Scan text for banned-vocab.
35
- claudemd lint --file <path> Scan the contents of a file.
36
- claudemd lint --stdin Read text from stdin.
37
- claudemd audit <jsonl-path> Scan all assistant turns in a CC transcript.
38
- claudemd --version Print plugin version.
39
- claudemd --help Print this message.
34
+ claudemd-cli lint <text> Scan text for banned-vocab.
35
+ claudemd-cli lint --file <path> Scan the contents of a file.
36
+ claudemd-cli lint --stdin Read text from stdin.
37
+ claudemd-cli audit <jsonl-path> Scan all assistant turns in a CC transcript.
38
+ claudemd-cli --version Print plugin version.
39
+ claudemd-cli --help Print this message.
40
40
 
41
41
  Flags:
42
- --json Emit machine-readable JSON instead of text.
43
- --include-ratio (audit only) Include @ratio patterns.
44
- Default OFF — chat prose has different
45
- baseline conventions from commit messages.
42
+ --json Emit machine-readable JSON instead of text.
43
+ --include-ratio (audit only) Include @ratio patterns.
44
+ Default OFF — chat prose has different
45
+ baseline conventions from commit messages.
46
46
 
47
47
  Notes:
48
48
  A bare \`lint <arg>\` whose only positional is an existing regular file
49
- is auto-treated as \`--file <arg>\` so \`claudemd lint .git/COMMIT_EDITMSG\`
49
+ is auto-treated as \`--file <arg>\` so \`claudemd-cli lint .git/COMMIT_EDITMSG\`
50
50
  works as expected in pre-commit hooks. Pass --stdin or quote literal
51
51
  text to opt out.
52
52
 
@@ -152,6 +152,21 @@ function lintCmd(rawArgs) {
152
152
  process.stderr.write(`lint: file not found: ${filePath}\n`);
153
153
  process.exit(2);
154
154
  }
155
+ // Pre-fix, `lint --file <dir>` fell through to readFileSync and surfaced
156
+ // a raw Node `EISDIR: illegal operation on a directory, read` — asymmetric
157
+ // with the positional path which already rejects directories cleanly
158
+ // (line ~191 below). Keep the friendly error shape consistent across both
159
+ // entry shapes.
160
+ try {
161
+ const st = fs.statSync(filePath);
162
+ if (!st.isFile()) {
163
+ process.stderr.write(`lint: '${filePath}' is not a regular file (got ${st.isDirectory() ? 'directory' : 'special file'})\n`);
164
+ process.exit(2);
165
+ }
166
+ } catch (e) {
167
+ process.stderr.write(`lint: failed to stat ${filePath}: ${e.message}\n`);
168
+ process.exit(2);
169
+ }
155
170
  try {
156
171
  text = fs.readFileSync(filePath, 'utf8');
157
172
  } catch (e) {
@@ -161,7 +176,7 @@ function lintCmd(rawArgs) {
161
176
  } else if (positional.length > 0) {
162
177
  // Auto-detect: a bare single positional that is an existing regular file
163
178
  // is overwhelmingly the user's intent (they're piping a commit-msg path
164
- // from a git pre-commit hook). Without this, `claudemd lint message.txt`
179
+ // from a git pre-commit hook). Without this, `claudemd-cli lint message.txt`
165
180
  // silently scans the LITERAL STRING "message.txt" → exits 0 even when
166
181
  // the file contents would deny.
167
182
  //
@@ -206,7 +221,32 @@ function lintCmd(rawArgs) {
206
221
  process.exit(2);
207
222
  }
208
223
 
209
- const hits = scan(text);
224
+ // Per-commit escape hatch — mirrors hooks/banned-vocab-check.sh:36. Without
225
+ // this, `claudemd-cli lint --file=.git/COMMIT_EDITMSG` in a git pre-commit
226
+ // hook silently disagreed with the in-CC bash hook: the same commit message
227
+ // with `[allow-banned-vocab]` would pass the bash gate (exit 0) but the CLI
228
+ // would still exit 1 and block the commit. Same input → different verdict =
229
+ // contract violation across surfaces of the same feature.
230
+ const ESCAPE_HATCH = '[allow-banned-vocab]';
231
+ if (text.includes(ESCAPE_HATCH)) {
232
+ if (json) {
233
+ process.stdout.write(formatJSON({ scope: 'lint', text, hits: [], bypass: 'allow-banned-vocab' }) + '\n');
234
+ } else {
235
+ process.stdout.write(`OK: §10-V scan bypassed via ${ESCAPE_HATCH}.\n`);
236
+ }
237
+ process.exit(0);
238
+ }
239
+
240
+ // Baseline-context exemption — mirrors banned-vocab-check.sh:65-75. When
241
+ // the text carries an explicit before-after anchor (digit ... → / -> / =>
242
+ // ... digit) OR the literal word `baseline`, ratio-class patterns (tagged
243
+ // `@ratio` in their reason column) are suppressed. Non-ratio hedges /
244
+ // adjectives still match.
245
+ const HAS_NUMERIC_ARROW = /\d\S*\s*(?:→|->|=>)\s*\d/;
246
+ const HAS_BASELINE = /baseline/i;
247
+ const baselineExempt = HAS_NUMERIC_ARROW.test(text) || HAS_BASELINE.test(text);
248
+
249
+ const hits = scan(text, { excludeRatio: baselineExempt });
210
250
  if (json) {
211
251
  process.stdout.write(formatJSON({ scope: 'lint', text, hits }) + '\n');
212
252
  } else {
@@ -232,6 +272,19 @@ function auditCmd(rawArgs) {
232
272
  process.stderr.write(`audit: file not found: ${transcriptPath}\n`);
233
273
  process.exit(2);
234
274
  }
275
+ // Pre-fix, `audit <dir>` crashed with raw Node EISDIR + Node stack trace
276
+ // and exit 1 — colliding with the documented "1 = hits found" semantic so
277
+ // CI scripts couldn't tell a usage error from a real banned-vocab hit.
278
+ try {
279
+ const st = fs.statSync(transcriptPath);
280
+ if (!st.isFile()) {
281
+ process.stderr.write(`audit: '${transcriptPath}' is not a regular file (got ${st.isDirectory() ? 'directory' : 'special file'})\n`);
282
+ process.exit(2);
283
+ }
284
+ } catch (e) {
285
+ process.stderr.write(`audit: failed to stat ${transcriptPath}: ${e.message}\n`);
286
+ process.exit(2);
287
+ }
235
288
 
236
289
  const jsonl = fs.readFileSync(transcriptPath, 'utf8');
237
290
  const turns = parseTranscript(jsonl);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claudemd-cli",
3
- "version": "0.9.22",
3
+ "version": "0.9.24",
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": {