claudemd-cli 0.9.22 → 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 +33 -0
- package/bin/claudemd-lint.js +54 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,39 @@ 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
|
+
|
|
11
44
|
## [0.9.22] - 2026-05-10
|
|
12
45
|
|
|
13
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).
|
package/bin/claudemd-lint.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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": {
|