claudemd-cli 0.9.21 → 0.9.23

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.23] - 2026-05-10
12
+
13
+ **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.
14
+
15
+ ### Added
16
+
17
+ - `[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).
18
+ - `[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.
19
+ - `[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.
20
+ - `[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).
21
+ - `[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.
22
+ - `[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.
23
+ - `[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.
24
+ - `[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.
25
+ - `[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.
26
+
27
+ ### Changed
28
+
29
+ - `[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).
30
+ - `[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.
31
+
32
+ ### Tests
33
+
34
+ - `[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.
35
+ - `[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.
36
+ - `[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.
37
+ - `[test]` **`tests/scripts/status.test.js`** (+2): `pendingKillSwitches` surfaces when `settings.json` env block disagrees with `process.env`; empty `pendingKillSwitches` when env + settings agree.
38
+ - `[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.
39
+
40
+ ### Why no L3 / pre-ship-review chain
41
+
42
+ `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.
43
+
44
+ ## [0.9.22] - 2026-05-10
45
+
46
+ **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).
47
+
48
+ Surfaced during a dogfood pass simulating fresh-user spec adherence. Symptom: `~/.claude/logs/claudemd.jsonl` simultaneously holding two project encodings for the same cwd `/mnt/data_ssd/dev/projects/claudemd` — `-mnt-data-ssd-...` (post-v0.9.15 `tr '/._' '-'`, 46 entries) and `-mnt-data_ssd-...` (pre-v0.9.15 `tr '/.' '-'`, 196 entries). Root cause: source repo at v0.9.21 but `~/.claude/plugins/marketplaces/claudemd/.claude-plugin/plugin.json` reported v0.9.11 with three hook scripts (`hooks/lib/rule-hits.sh`, `hooks/memory-read-check.sh`, `hooks/session-summary.sh`) still on pre-fix code. `/plugin update` is a silent no-op in current Claude Code versions (memory: `reference_plugin_update_manual_refresh.md`), so the canonical refresh path is `uninstall → install → /reload-plugins` — but nothing flagged that the user *needed* to do this. Concretely: the §11-memory-read HARD rule was a silent no-op for any project with `_` in its cwd path, even after the v0.9.15 fix shipped, because the running hook code was older than the source. The prior `spec-hash` doctor check only compared the spec MD files; hook scripts had no drift signal.
49
+
50
+ ### Added
51
+
52
+ - `[feat]` **`scripts/lib/install-drift.js#compareHooks(sourceRoot, marketRoot)`** — recursively SHA-256-compares every `.sh` under `hooks/` between two roots. Returns `{skipped, skippedReason?, driftCount, diffs[]}`. Skip cases (not flagged as drift): `self-compare` (sourceRoot ≡ marketRoot by realpath, e.g. `/claudemd-doctor` running FROM the marketplace install), `market-root-missing` (no marketplace install on this machine), `no-hooks-in-source` (claudemd-cli npm package ships only `bin/`, no hooks). `.patterns` / `.json` configs deliberately excluded — that drift is `/claudemd-update` territory, not hook-CODE drift.
53
+ - `[feat]` **`hook-drift` check in `scripts/doctor.js`** — wires `compareHooks` into the doctor pipeline. Detail format on drift: `<N> hook script(s) differ between source and <marketRoot>: <first-3 file paths with reason> +<remaining> more. Likely cause: /plugin update is a silent no-op. Fix: /plugin uninstall claudemd@claudemd then /plugin install claudemd@claudemd, then /reload-plugins.` On the dogfood machine, this surfaces the exact 3-file drift (`hooks/lib/rule-hits.sh`, `hooks/memory-read-check.sh`, `hooks/session-summary.sh`) with the canonical fix command in the message.
54
+ - `[feat]` **`marketplacePluginRoot()` helper in `scripts/lib/paths.js`** — returns `~/.claude/plugins/marketplaces/claudemd`. Decoupled from `pluginCacheDir()` because the cache vs marketplaces directories can drift (cache holds per-version snapshots; marketplaces is the single live plugin root Claude Code resolves `${CLAUDE_PLUGIN_ROOT}` to at hook-fire time).
55
+ - `[test]` **8 new cases in `tests/scripts/install-drift.test.js`** — equal-content baseline, file-content-differs, file-missing-in-market, self-compare skip, market-missing skip, no-hooks-in-source skip, recursive scan into `hooks/lib/`, non-`.sh` files ignored.
56
+ - `[test]` **2 new cases in `tests/scripts/doctor.test.js`** — `hook-drift skips when no marketplace install exists` (fresh-install / npm-CLI-only does not fail-loudly) + `hook-drift flags differing hooks when marketplace install lags source` (mirrors the real source `hooks/` into a tmp marketplace and breaks one file to assert the drift surfaces with file path + remediation in the detail).
57
+
58
+ ### Why no L3 / pre-ship-review chain
59
+
60
+ `feat:` adds an additive doctor check + spec wording-only fixes. Per spec §2 hard-upgrade exclusion: spec wording fixes restoring documented intent are L2 max (the `[fix]` CHANGELOG framing). Doctor check is L2 (additive feature, multi-file with tests, no API change). No spec semantic change. Diff: 1 new lib (~80 LOC), 1 new lib helper (~5 LOC), 1 doctor branch (~20 LOC), 1 new test file (~110 LOC), 2 new doctor test cases (~30 LOC), spec wording shifts in `spec/CLAUDE.md` lines 180 + 238, version bumps across 3 manifests + spec headers + `hard-rules.json#spec_version`. Test count: 271 → 281 (+10 JS cases).
61
+
11
62
  ## [0.9.21] - 2026-05-10
12
63
 
13
64
  **Hotfix — `claudemd-cli lint <path>` silent-text-scan when path looks like a path but doesn't resolve to a regular file (v0.9.14 family residual).** Spec v6.11.7 unchanged. The v0.9.14 fix added auto-`--file` for the case where the positional was an existing regular file. The other branches — missing path, directory, dead symlink — still fell through to scanning the literal positional string. Three failure shapes surfaced during the dogfood session that produced v0.9.18–v0.9.20:
package/README.md CHANGED
@@ -61,7 +61,7 @@ Verify in one command (Linux): `node --version && jq --version && gh --version &
61
61
  | 12 shell hooks | `banned-vocab-check` · `pre-bash-safety-check` · `ship-baseline-check` · `residue-audit` · `memory-read-check` · `sandbox-disposal-check` · `session-start-check` · `session-summary` · `transcript-vocab-scan` · `transcript-structure-scan` · `version-sync` · `mem-audit` |
62
62
  | 9 slash commands | `/claudemd-status` · `/claudemd-update` · `/claudemd-audit` · `/claudemd-toggle` · `/claudemd-doctor` · `/claudemd-uninstall` · `/claudemd-rules` · `/claudemd-clean-residue` · `/claudemd-sparkline` |
63
63
  | 1 standalone CLI | `claudemd-cli lint` · `claudemd-cli audit` ([npm: `claudemd-cli`](https://www.npmjs.com/package/claudemd-cli)) |
64
- | Spec v6.11.7 | `~/.claude/CLAUDE.md` · `CLAUDE-extended.md` · `CLAUDE-changelog.md` (backup-before-overwrite) |
64
+ | Spec v6.11.8 | `~/.claude/CLAUDE.md` · `CLAUDE-extended.md` · `CLAUDE-changelog.md` (backup-before-overwrite) |
65
65
 
66
66
  Install moves any existing `~/.claude/CLAUDE*.md` to `~/.claude/backup-<ISO>/` (last 5 kept automatically). Uninstall offers `keep / restore / delete`; `delete` requires an extra confirmation.
67
67
 
@@ -299,7 +299,7 @@ claudemd/
299
299
  ├── commands/ # 9 slash-command markdown files
300
300
  ├── bin/ # standalone CLI entrypoint (claudemd-lint.js → `npx claudemd-cli` on npmjs.org)
301
301
  ├── scripts/ # 10 Node.js management scripts + scripts/lib/ (single-source registry, lint, etc.)
302
- ├── spec/ # shipped v6.11.7 CLAUDE*.md trio + hard-rules.json manifest
302
+ ├── spec/ # shipped v6.11.8 CLAUDE*.md trio + hard-rules.json manifest
303
303
  ├── tests/ # hook shell tests + Node.js tests + integration + fixtures
304
304
  ├── docs/ # ADDING-NEW-HOOK.md + RULE-HITS-SCHEMA.md + superpowers/
305
305
  └── .github/workflows/ # ci.yml (ubuntu+macOS × node 20) + npm-publish.yml (tag-triggered)
@@ -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) {
@@ -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.21",
3
+ "version": "0.9.23",
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": {