claudemd-cli 0.109.1 → 0.111.0

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/README.md CHANGED
@@ -1,14 +1,14 @@
1
1
  # claudemd
2
2
 
3
- > A **personal AI-coding discipline harness**: one developer's opinionated **AI-CODING-SPEC v8.0**, encoded as Claude Code shell hooks and shipped with the plugin. Built and dogfooded on my own repos — fork and adapt, don't adopt wholesale.
3
+ > A **personal AI-coding discipline harness**: one developer's opinionated **AI-CODING-SPEC v8.1**, encoded as Claude Code shell hooks and shipped with the plugin. Built and dogfooded on my own repos — fork and adapt, don't adopt wholesale.
4
4
 
5
5
  [![CI](https://github.com/sdsrss/claudemd/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/sdsrss/claudemd/actions/workflows/ci.yml)
6
6
  [![npm](https://img.shields.io/npm/v/claudemd-cli.svg)](https://www.npmjs.com/package/claudemd-cli)
7
7
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
8
8
 
9
- claudemd plugs into the Claude Code hook system to **block commits, pushes, and bash commands** that violate AI-CODING-SPEC v8.0 — banned vocabulary in commit messages, `rm -rf $VAR` without variable validation, ship-on-red-CI, unread `MEMORY.md` entries during release flows, and more. The spec itself (`CLAUDE.md` + `CLAUDE-extended.md` + `CLAUDE-changelog.md` + `OPERATOR.md`) ships with the plugin and installs into `~/.claude/`, so the rules Claude Code reads at session start match the rules the hooks enforce. (`OPERATOR.md` is the human-only spec-maintenance handbook — Agent-loaded files are the CLAUDE trio.)
9
+ claudemd plugs into the Claude Code hook system to **block commits, pushes, and bash commands** that violate AI-CODING-SPEC v8.1 — banned vocabulary in commit messages, `rm -rf $VAR` without variable validation, ship-on-red-CI, unread `MEMORY.md` entries during release flows, and more. The spec itself (`CLAUDE.md` + `CLAUDE-extended.md` + `CLAUDE-changelog.md` + `OPERATOR.md`) ships with the plugin and installs into `~/.claude/`, so the rules Claude Code reads at session start match the rules the hooks enforce. (`OPERATOR.md` is the human-only spec-maintenance handbook — Agent-loaded files are the CLAUDE trio.)
10
10
 
11
- A standalone CLI (`npx claudemd-cli`) reuses the same `banned-vocab.patterns` source for git pre-commit hooks, GitHub Actions, and other agents that don't run inside Claude Code.
11
+ A standalone CLI (`npx claudemd-cli`) reuses the same `banned-vocab.patterns` source for git commit-msg hooks, GitHub Actions, and other agents that don't run inside Claude Code.
12
12
 
13
13
  > **Status & scope.** This is a single-maintainer tool that encodes one developer's working discipline — not a general-purpose product. It's exercised almost entirely on my own repositories, the spec is opinionated, and the defaults reflect my preferences rather than a consensus. If you install it, expect to fork `CLAUDE.md` and tune the rules to your own workflow. Treat it as a reference implementation of "spec-as-enforced-hooks," not a turnkey solution. Issues and PRs are welcome.
14
14
 
@@ -35,7 +35,7 @@ Then bootstrap the **current** session (skip the wait-for-next-session restart)
35
35
 
36
36
  Fallback (no slash command — e.g. scripting outside CC): `node ~/.claude/plugins/cache/claudemd/claudemd/<version>/scripts/install.js`. Find `<version>` with `ls ~/.claude/plugins/cache/claudemd/claudemd/ | sort -V | tail -1`.
37
37
 
38
- > ⚠️ **`~/.claude/CLAUDE.md` is shared real estate.** Claude Code reads this file as your user-global instructions across every project. If you've hand-written personal instructions there (`Always reply in 中文`, `My name is X`, etc.), install moves your existing files to `~/.claude/backup-<ISO>/` (last 5 kept automatically) before writing the spec. Since v0.5.3, install prints a `[claudemd] WARN: …` line to stderr when the existing file does not look like a claudemd spec — visible when you run `/claudemd-install` (or `install.js`) yourself. Since v0.75.0 the **SessionStart** bootstrap, where that stderr goes to `claudemd-bootstrap.log` instead, raises the same notice as an in-session banner naming the backup path (silence it with `DISABLE_USER_CONTENT_BANNER=1`). To bring your personal instructions back on uninstall, run `CLAUDEMD_SPEC_ACTION=restore /claudemd-uninstall`.
38
+ > ⚠️ **`~/.claude/CLAUDE.md` is shared real estate.** Claude Code reads this file as your user-global instructions across every project. If you've hand-written personal instructions there (`Always reply in 中文`, `My name is X`, etc.), install moves your existing files to `~/.claude/backup-<ISO>/` (last 5 kept automatically) before writing the spec. Since v0.5.3, install prints a `[claudemd] WARN: …` line to stderr when the existing file does not look like a claudemd spec — visible when you run `/claudemd-install` (or `install.js`) yourself. Since v0.75.0 the **SessionStart** bootstrap, where that stderr goes to `claudemd-bootstrap.log` instead, raises the same notice as an in-session banner naming the backup path (silence it with `DISABLE_USER_CONTENT_BANNER=1`). To bring your personal instructions back, run `/claudemd-uninstall restore` and then `/plugin uninstall claudemd@claudemd` — while the plugin stays installed, the next session start installs the spec over the restored file again — or merge the two files by hand.
39
39
 
40
40
  ---
41
41
 
@@ -60,7 +60,7 @@ Verify in one command (Linux): `node --version && jq --version && gh --version &
60
60
  | 27 shell hooks | `banned-vocab-check` · `pre-bash-safety-check` · `ship-baseline-check` · `residue-audit` · `memory-read-check` · `memory-prompt-hint` · `spec-module-inject` · `deferred-notice` · `spec-module-gate` · `sandbox-disposal-check` · `session-start-check` · `session-extended-read` · `session-summary` · `session-end-check` · `transcript-vocab-scan` · `rework-breaker` · `test-failure-debug` · `cross-repo-write-check` · `tmp-sweep` · `branch-prune` · `verify-log` · `evidence-gate` · `ledger-staleness` · `reply-language-check` · `transcript-structure-scan` · `version-sync` · `mem-audit` |
61
61
  | 16 slash commands | `/claudemd-install` · `/claudemd-status` · `/claudemd-update` · `/claudemd-refresh` · `/claudemd-audit` · `/claudemd-toggle` · `/claudemd-doctor` · `/claudemd-analyze` · `/claudemd-uninstall` · `/claudemd-rules` · `/claudemd-clean-residue` · `/claudemd-sparkline` · `/claudemd-sampling-audit` · `/claudemd-bypass-audit` · `/claudemd-design-adopt` · `/claudemd-statusline` |
62
62
  | 1 standalone CLI | `claudemd-cli lint` · `claudemd-cli audit` ([npm: `claudemd-cli`](https://www.npmjs.com/package/claudemd-cli)) |
63
- | Spec v8.0 | `~/.claude/CLAUDE.md` · `CLAUDE-extended.md` · `CLAUDE-changelog.md` · `OPERATOR.md` (backup-before-overwrite) · `spec-modules/*.md` (mirrored from the plugin) |
63
+ | Spec v8.1 | `~/.claude/CLAUDE.md` · `CLAUDE-extended.md` · `CLAUDE-changelog.md` · `OPERATOR.md` (backup-before-overwrite) · `spec-modules/*.md` (mirrored from the plugin) |
64
64
  | StatusLine (opt-out) | PS1-style line — `user@host:dir (branch) Model [ctx:N% · 5h:N% · 7d:N%]` (`dir` = cwd basename; context / 5-hour quota / weekly quota, all **used %**, read from Claude Code's `rate_limits` payload; quota segments auto-hide when the data is absent, or force-hide with `DISABLE_STATUSLINE_QUOTA=1`) — wired into `~/.claude/settings.json` on install **only when the slot is empty**; an existing statusline is left untouched. Skip entirely with `CLAUDEMD_NO_STATUSLINE=1`. Manage via `/claudemd-statusline`. |
65
65
 
66
66
  Install backs up a hand-written `~/.claude/CLAUDE.md` (any file without the `# AI-CODING-SPEC` H1) to `~/.claude/backup-<ISO>/` before overwriting (last 5 kept automatically). An already-installed claudemd spec never enters `backup-<ISO>/` — deliberate (v0.23.11): no directory in that namespace ever has the spec as its `CLAUDE.md`, so `restore` can never return a stale spec instead. (The user-content sweep does move `CLAUDE-extended.md`, `CLAUDE-changelog.md` and `OPERATOR.md` along beside your own file, so the directory is not literally yours alone — the `CLAUDE.md` in it is.) It is not discarded either (v0.83.0): unless it already matches the shipped spec byte for byte, it is copied to `spec-backup-<ISO>/`, a separate namespace `restore` does not read (last 5 kept). Uninstall offers `keep / restore / delete`; `delete` requires an extra confirmation.
@@ -98,7 +98,7 @@ Once installed, hooks run silently in the background. Verbose log: `~/.claude/lo
98
98
  | `PostToolUseFailure` on `Bash`, when a test runner (`npm test`, `pytest`, `node --test`, `bash tests/…`, …) exits non-zero and `debug.md` is not yet in this session (a runner piped into `tail` exits 0 when it fails, and is not seen) | `test-failure-debug` (v0.105.0+) | Adds the `debug` spec module as context, once per session, shared with `spec-module-inject`'s once-per-session list. Opt-in (`DEBUG_ON_TEST_FAILURE=1`, default OFF). |
99
99
  | `PreToolUse` on `Edit`/`Write`/`NotebookEdit`/`Bash`, when the target is a git repo other than the session's project | `cross-repo-write-check` (v0.95.0+) | Advisory, never a verdict; injects one line per target repo per session: stop and ask unless the user asked for that repo, and use `git worktree add` rather than switching branches in its shared tree. Bash side sees git writes after a literal `cd` or `git -C` only. Opt-in (`CROSS_REPO_WRITE=1`, default OFF); `CROSS_REPO_WRITE_ALLOW` takes colon-separated absolute repo roots (a leading `~/` or `$HOME/` is expanded). |
100
100
  | `PostToolUse` on any `Bash` call, at most once per 10 min | `tmp-sweep` (v0.93.0+) | Deletes, detached, the per-run dirs vitest leaves in the temp root and never removes (`<tmp>/<21-char id>/ssr/<sha1>`, 13-45 MB each; on a tmpfs `/tmp` that is RAM). Only the exact vitest signature, only when idle 60 min (24 h while a vitest watcher runs), across `$TMPDIR`, `/tmp` and `~/.cache/tmp`. Also injects one line when the temp root (`$TMPDIR`, else `/tmp`) is ≥ 80% full. Parallel Bash calls claim the slot atomically, so one sweep runs (a few can overlap only while a lock left by a killed hook is being cleared). **On by default.** |
101
- | `PostToolUse` after `git merge` / `pull` / `fetch` / `push` or `gh pr merge` | `branch-prune` (v0.93.0+) | Advisory. Lists the local branches whose remote branch was deleted (`[gone]`) and whose tip is on the default branch, plus `worktree-agent-*` branches on it, with the `git branch -d` command that deletes them; git re-checks merged-ness and use at the moment of deletion. Never lists the default branch, a worktree checkout or a symbolic ref, nor (`worktree-agent-*` aside) a branch whose upstream still exists or one with no upstream. Deletes nothing. Local git only. **On by default.** |
101
+ | `PostToolUse` after `git merge` / `pull` / `fetch` / `push` or `gh pr merge` | `branch-prune` (v0.93.0+) | Advisory. Lists the local branches whose remote branch was deleted (`[gone]`) and whose tip is on the default branch, plus `worktree-agent-*` branches on it, and tells the session to delete them with the `git branch -d` command it gives, leaving out any the project keeps (v0.110.0+; before, it left that to the user); git re-checks merged-ness and use at the moment of deletion. Never lists the default branch, a worktree checkout or a symbolic ref, nor (`worktree-agent-*` aside) a branch whose upstream still exists or one with no upstream. Deletes nothing. Local git only. **On by default.** |
102
102
  | `PostToolUse` after assistant text containing banned vocab | `transcript-vocab-scan` | Advisory; logs to rule-hits without blocking. Opt-in (`TRANSCRIPT_VOCAB_SCAN=1`, default OFF) for FP signal collection. |
103
103
  | Session end with last assistant turn carrying a §10 report out of order (spec v8 `## 汇报` parts 变更 / 验证 / 遗留, or the v7 four sections), a verification line without a verdict or a v7 `Done:` line without an evidence fingerprint, or `Uncertain:` short hedges without `because` | `transcript-structure-scan` (v0.9.10+) | Stop advisory — closes the audit gap that ~7 self-enforced HARD rules (§iron-law-2 / §10-four-section-order / §10-honesty) had no hook-side feedback signal. Opt-in (`TRANSCRIPT_STRUCTURE_SCAN=1`, default OFF) for FP signal collection; FP-tightened so single-section `Done:` lines never trigger. |
104
104
 
@@ -127,7 +127,7 @@ Per-hook timeout (3-5s in `hooks.json`); timeout = treated as exit 0 (pass) per
127
127
  | `/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). |
128
128
  | `/claudemd-toggle <hook-name>` | Enable/disable a specific hook by toggling `DISABLE_*_HOOK` in `settings.json` env. |
129
129
  | `/claudemd-doctor [--prune-backups=N]` | Health checks; optionally prune each backup namespace (`~/.claude/backup-*`, `spec-backup-*`, `handhook-backup-*`) to its N newest. Dirs the `backup-namespace-legacy` check reports (pre-0.68.3 spec backups sitting in the personal namespace) are skipped — neither deleted nor counted toward N — and echoed under `pruneSkippedLegacy`. v0.7.1+ also flags rule sections whose bypass:deny ratio > 50% (R-N6 §0.1 demotion candidates). |
130
- | `/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). |
130
+ | `/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 fired rows, `hits.fired`) and `staleReviews` (rules whose `last_demote_review` is null/old). |
131
131
  | `/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. |
132
132
  | `/claudemd-clean-residue [--apply]` | Dry-run-by-default cleanup across four scopes: `$TMPDIR` (`claudemd-sync-*` / `claudemd-memtags-hay-*` sentinels, historical `claudemd-(mockgh\|work).*` sandboxes), `~/.claude/tmp` tool-exhaust past the §EXT §7-EXT retention window, the seven ephemeral classes in `~/.claude/.claudemd-state`, and headless-probe dirs (`-tmp-*`, `-var-tmp`, every transcript `entrypoint: sdk-cli`) in `~/.claude/projects` past the same window, never an interactive session's or one holding a memory file. Exits 3 if `--apply` leaves targets behind. |
133
133
  | `/claudemd-design-adopt [check\|remove]` | v0.24.0 — for a UI project, generate a thin, fact-based `DESIGN.md` from its real design-token sources (deterministic detector `scripts/design-detect.js`; evidence-gated rules menu; never invents values) and wire a sentinel block into project CLAUDE.md. `check` verifies pointers resolve; `remove` unwires. Always shows the diff and asks before writing. Manual/opt-in — no SessionStart hook, nothing auto-fires. |
@@ -138,7 +138,7 @@ Per-hook timeout (3-5s in `hooks.json`); timeout = treated as exit 0 (pass) per
138
138
 
139
139
  ## Standalone CLI
140
140
 
141
- The same `banned-vocab.patterns` source the in-CC hook uses is also exposed as a Node CLI for **git pre-commit hooks, GitHub Actions, and other agents** (Codex, Cursor, OpenClaw) — anywhere outside the Claude Code process.
141
+ The same `banned-vocab.patterns` source the in-CC hook uses is also exposed as a Node CLI for **git commit-msg hooks, GitHub Actions, and other agents** (Codex, Cursor, OpenClaw) — anywhere outside the Claude Code process.
142
142
 
143
143
  ```bash
144
144
  # After npm publish (operator-driven, not part of plugin install):
@@ -154,14 +154,14 @@ node bin/claudemd-lint.js audit ~/.claude/projects/<encoded>/<session>.jsonl
154
154
 
155
155
  | Subcommand | Purpose |
156
156
  |---|---|
157
- | `lint <text>` / `--file` / `--stdin` | Scan commit-message text for §10-V banned vocab. Exit 0 clean / 1 hits. |
158
- | `audit <jsonl-path>` | Scan all assistant-text turns in a Claude Code transcript jsonl. Skips `@ratio` patterns by default (chat prose has different baseline conventions); pass `--include-ratio` to include them. |
157
+ | `lint <text>` / `--file` / `--stdin` | Scan commit-message text for §10-V banned vocab. Exit 0 clean / 1 hits / 2 bad arguments, including an empty or blank text argument (`--stdin` or `--file` with empty input still exits 0). |
158
+ | `audit <jsonl-path>` | Scan all assistant-text turns in one Claude Code transcript jsonl (more than one path exits 2; run it once per file). Skips `@ratio` patterns by default (chat prose has different baseline conventions); pass `--include-ratio` to include them. |
159
159
  | `--json` | JSON output (machine-readable for CI). |
160
160
  | `--commit-msg` / `--no-commit-msg` | (`lint`) Force git commit-message cleanup on/off. Auto-ON for `COMMIT_EDITMSG` / `MERGE_MSG` / `SQUASH_MSG` / `TAG_EDITMSG` / `NOTES_EDITMSG`. |
161
161
  | `--comment-char <c>` | (`lint`) git `core.commentChar`. Default `#`. |
162
162
  | `--version` / `--help` | Standard. |
163
163
 
164
- **Pre-commit example (`.git/hooks/commit-msg`)**:
164
+ **Commit-message hook example (`.git/hooks/commit-msg`)** — a `commit-msg` hook, not `pre-commit`: git hands `commit-msg` the message file as `$1` and gives `pre-commit` no arguments (an empty `lint ""` exits 2 rather than passing):
165
165
 
166
166
  ```bash
167
167
  #!/usr/bin/env bash
@@ -385,15 +385,15 @@ Reversing the order is the orphan-state vector — `${CLAUDE_PLUGIN_ROOT}` and `
385
385
 
386
386
  ### Spec disposition
387
387
 
388
- `/claudemd-uninstall` defaults to `keep` (leaves `~/.claude/CLAUDE*.md` in place). Override via env vars before the slash command:
388
+ `/claudemd-uninstall` defaults to `keep` (leaves `~/.claude/CLAUDE*.md` in place). A slash command takes no env vars, so pick `restore` by typing `/claudemd-uninstall restore`; any other argument (`/claudemd-uninstall delete`, say) gets a list of the options and a question, which you answer in the next message; the command runs `scripts/uninstall.js` with the env vars below (the same ones work when you run the script directly):
389
389
 
390
- | Option | Env vars | Behavior |
391
- |---|---|---|
392
- | `keep` (default) | (none) | `~/.claude/CLAUDE*.md` left in place; settings.json hook entries cleared. |
393
- | `restore` | `CLAUDEMD_SPEC_ACTION=restore` | Copies the most recent `~/.claude/backup-<ISO>/*.md` back to `~/.claude/`. Use this if your install-time stderr showed `[claudemd] WARN: existing ~/.claude/CLAUDE.md does not look like a claudemd spec` — your hand-written user-global instructions are sitting in the backup waiting to be brought back. |
394
- | `delete` | `CLAUDEMD_SPEC_ACTION=delete CLAUDEMD_CONFIRM=1` | Hard-AUTH: removes all four spec files (`CLAUDE*.md` + `OPERATOR.md`) and every regular `*.md` file in `~/.claude/spec-modules/`. |
390
+ | Option | Type / ask for | Env vars the script reads | Behavior |
391
+ |---|---|---|---|
392
+ | `keep` (default) | `/claudemd-uninstall` | (none) | `~/.claude/CLAUDE*.md` left in place; settings.json hook entries cleared. |
393
+ | `restore` | `/claudemd-uninstall restore` | `CLAUDEMD_SPEC_ACTION=restore` | Copies the most recent `~/.claude/backup-<ISO>/*.md` back to `~/.claude/`, except over a home file that is not the spec and differs from the backup (listed in `kept`). Use this if your install-time stderr showed `[claudemd] WARN: existing ~/.claude/CLAUDE.md does not look like a claudemd spec` — your hand-written user-global instructions are sitting in the backup waiting to be brought back. Then run `/plugin uninstall claudemd@claudemd`: while the plugin stays installed, the next session start installs the spec over the restored file again. |
394
+ | `delete` | ask for a full removal | `CLAUDEMD_SPEC_ACTION=delete CLAUDEMD_CONFIRM=1` | Hard-AUTH: removes all four spec files (`CLAUDE*.md` + `OPERATOR.md`) and every regular `*.md` file in `~/.claude/spec-modules/`. After a default uninstall (no manifest left), only spec files whose first line is the `# AI-CODING-SPEC` H1 and only the module files claudemd generated (their `# AI-CODING-SPEC … — module: <name>` heading) go; the rest are listed in `kept`. |
395
395
 
396
- `CLAUDEMD_PURGE=1` (env var) on `/claudemd-uninstall` also drops `~/.claude/.claudemd-state/` and your rule-hits log.
396
+ Asking for a purge (`CLAUDEMD_PURGE=1`) also drops `~/.claude/.claudemd-state/` and your rule-hits log.
397
397
 
398
398
  **What `--purge` deliberately leaves**: `~/.claude/settings.json.claudemd-backup-<ISO>` — the copy of your `settings.json` taken before install rewrote it, and `~/.claude/backup-<ISO>/` / `spec-backup-<ISO>/`. Purge removes claudemd's own state; it does not remove your data, and these files are the copies that exist so `restore` has something to restore from. Delete them by hand once you are sure you no longer want them.
399
399
 
@@ -409,6 +409,8 @@ CLAUDEMD_SPEC_ACTION=delete CLAUDEMD_CONFIRM=1 node ~/.claude/plugins/cache/clau
409
409
 
410
410
  The slash command and the script are equivalent — the slash command just supplies `${CLAUDE_PLUGIN_ROOT}` for you.
411
411
 
412
+ Once the cache is gone too, restore your own `CLAUDE.md` by hand: copy `CLAUDE.md` from the newest `~/.claude/backup-<ISO>/` that holds one to `~/.claude/CLAUDE.md`.
413
+
412
414
  ---
413
415
 
414
416
  ## Troubleshooting
@@ -449,7 +451,7 @@ claudemd/
449
451
  ├── commands/ # 16 slash-command markdown files
450
452
  ├── bin/ # standalone CLI entrypoint (claudemd-lint.js → `npx claudemd-cli` on npmjs.org)
451
453
  ├── scripts/ # 22 Node.js scripts + scripts/lib/ (single-source registry, lint, etc.)
452
- ├── spec/ # shipped v8.0 CLAUDE*.md trio + OPERATOR.md + hard-rules.json manifest
454
+ ├── spec/ # shipped v8.1 CLAUDE*.md trio + OPERATOR.md + hard-rules.json manifest
453
455
  ├── tests/ # hook shell tests + Node.js tests + integration + fixtures
454
456
  ├── docs/ # ADDING-NEW-HOOK.md + RULE-HITS-SCHEMA.md + superpowers/
455
457
  └── .github/workflows/ # ci.yml (ubuntu+macOS × node 20/22/24) + npm-publish.yml (tag-triggered)
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  // claudemd-lint — CLI surface for §10-V banned-vocab + transcript scanning.
3
- // Built for use OUTSIDE Claude Code: git pre-commit hooks, GitHub Actions,
3
+ // Built for use OUTSIDE Claude Code: git commit-msg hooks, GitHub Actions,
4
4
  // other agent integrations (Codex / Cursor / OpenClaw). Reuses the same
5
5
  // pattern file (hooks/banned-vocab.patterns) the in-CC bash hooks read,
6
6
  // so enforcement is consistent across surfaces.
@@ -62,7 +62,7 @@ Flags:
62
62
  Notes:
63
63
  A bare \`lint <arg>\` whose only positional is an existing regular file
64
64
  is auto-treated as \`--file <arg>\` so \`claudemd-cli lint .git/COMMIT_EDITMSG\`
65
- works as expected in pre-commit hooks. Pass --stdin or quote literal
65
+ works as expected in a commit-msg hook. Pass --stdin or quote literal
66
66
  text to opt out.
67
67
 
68
68
  A commit-msg hook is handed the RAW message file, which still holds git's
@@ -276,7 +276,7 @@ function lintCmd(rawArgs) {
276
276
  } else if (positional.length > 0) {
277
277
  // Auto-detect: a bare single positional that is an existing regular file
278
278
  // is overwhelmingly the user's intent (they're piping a commit-msg path
279
- // from a git pre-commit hook). Without this, `claudemd-cli lint message.txt`
279
+ // from a git commit-msg hook). Without this, `claudemd-cli lint message.txt`
280
280
  // silently scans the LITERAL STRING "message.txt" → exits 0 even when
281
281
  // the file contents would deny.
282
282
  //
@@ -294,7 +294,7 @@ function lintCmd(rawArgs) {
294
294
  // a single literal word, not a typo'd path. `lint message.txt` (no slash,
295
295
  // missing file) stays text — it's ambiguous between "literal text" and
296
296
  // "filename in cwd"; pre-existing-file behavior wins to preserve the
297
- // pre-commit-hook ergonomic where `--file` is explicit.
297
+ // commit-msg-hook ergonomic where `--file` is explicit.
298
298
  if (positional.length === 1) {
299
299
  const arg = positional[0];
300
300
  // Path-shape heuristic: contains `/`, or is `.`/`..`. Whitespace disqualifies
@@ -325,6 +325,17 @@ function lintCmd(rawArgs) {
325
325
  }
326
326
  }
327
327
  if (text === undefined) text = positional.join(' ');
328
+ // An empty positional is a missing one (R15). Its live shape is
329
+ // `lint "$1"` installed as a pre-commit hook, which git calls with no
330
+ // arguments: the scan of "" passed every commit unscanned.
331
+ if (sourcePath === null && text.trim() === '') {
332
+ process.stderr.write(
333
+ 'lint: the text is empty, so there is nothing to scan. In a git hook this usually means it ' +
334
+ 'is installed as pre-commit, which gets no arguments; use a commit-msg hook, which gets the ' +
335
+ 'message file as $1.\n'
336
+ );
337
+ process.exit(2);
338
+ }
328
339
  } else {
329
340
  process.stderr.write('lint: text required (positional arg, --file PATH, or --stdin)\n');
330
341
  process.exit(2);
@@ -349,7 +360,7 @@ function lintCmd(rawArgs) {
349
360
  }
350
361
 
351
362
  // Per-commit escape hatch — mirrors hooks/banned-vocab-check.sh:36. Without
352
- // this, `claudemd-cli lint --file=.git/COMMIT_EDITMSG` in a git pre-commit
363
+ // this, `claudemd-cli lint --file=.git/COMMIT_EDITMSG` in a git commit-msg
353
364
  // hook silently disagreed with the in-CC bash hook: the same commit message
354
365
  // with `[allow-banned-vocab]` would pass the bash gate (exit 0) but the CLI
355
366
  // would still exit 1 and block the commit. Same input → different verdict =
@@ -376,7 +387,7 @@ function lintCmd(rawArgs) {
376
387
  // rescans the tail — quadratic, 710ms on a 20k digit run, and this runs on
377
388
  // the RAW file before scan(), so lint.js:150-157's sanitizer fix for the same
378
389
  // family never covered it. A digit-dense lockfile or hash dump hung the
379
- // pre-commit hook. 64 is far past any real anchor (`p99 580ms→140ms` is 15).
390
+ // commit-msg hook. 64 is far past any real anchor (`p99 580ms→140ms` is 15).
380
391
  const HAS_NUMERIC_ARROW = /\d\S{0,64}\s*(?:→|->|=>)\s*\d/;
381
392
  const HAS_BASELINE = /baseline/i;
382
393
  const baselineExempt = HAS_NUMERIC_ARROW.test(text) || HAS_BASELINE.test(text);
@@ -399,6 +410,15 @@ function auditCmd(rawArgs) {
399
410
  const positional = args.filter(a => !a.startsWith('--'));
400
411
  const transcriptPath = positional[0];
401
412
 
413
+ // A shell glob (`audit dir/*.jsonl`) hands over several paths; scanning only
414
+ // the first let a hit in any later file pass as exit 0 (R15). Checked before
415
+ // the missing-path test so `audit '' b.jsonl` is not "required" (R18 L9).
416
+ if (positional.length > 1) {
417
+ process.stderr.write(
418
+ `audit: takes one <jsonl-path>, got ${positional.length} — run it once per transcript\n`
419
+ );
420
+ process.exit(2);
421
+ }
402
422
  if (!transcriptPath) {
403
423
  process.stderr.write('audit: <jsonl-path> required\n');
404
424
  process.exit(2);
@@ -426,7 +446,7 @@ function auditCmd(rawArgs) {
426
446
  // Mirrors the `lint --file` read path above (R11-11). The existsSync/statSync
427
447
  // guards a few lines up both PASS on a mode-000 file, so an unreadable
428
448
  // transcript reached this line and exited 1 with a V8 stack — colliding with
429
- // the documented `1 = hits found`, which is what CI and pre-commit gate on.
449
+ // the documented `1 = hits found`, which is what CI and commit-msg hooks gate on.
430
450
  let jsonl;
431
451
  try {
432
452
  jsonl = fs.readFileSync(transcriptPath, 'utf8');
@@ -491,7 +511,7 @@ function auditCmd(rawArgs) {
491
511
  if (integrity.badLines > 0) {
492
512
  process.stderr.write(
493
513
  `audit: warning: skipped ${integrity.badLines} of ${integrity.totalLines} non-empty line(s) ` +
494
- `that did not parse as JSON — the turn count below covers only the rows that parsed.\n`
514
+ `that did not parse as JSON or are not transcript-shaped — the turn count below covers only the rows that parsed.\n`
495
515
  );
496
516
  }
497
517
  // QA ISSUE-002 (option c): string-shape assistant rows are outside the
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "claudemd-cli",
3
- "version": "0.109.1",
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.",
3
+ "version": "0.111.0",
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 commit-msg hooks, GitHub Actions, and other agents.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "claudemd-cli": "bin/claudemd-lint.js"
@@ -178,7 +178,7 @@ export function parseStrictOrExit(argv, spec = {}) {
178
178
  // being converged. It is not merged into parseStrict because the two contracts
179
179
  // genuinely differ and the difference is published: parseStrict rejects the
180
180
  // `--key value` space form and positional arguments, both of which
181
- // `claudemd-cli lint <path> --comment-char ';'` documents and users' pre-commit
181
+ // `claudemd-cli lint <path> --comment-char ';'` documents and users' commit-msg
182
182
  // hooks depend on.
183
183
  //
184
184
  // Strict-validate flag-shaped args + normalize `--key=value` → `--key value`
@@ -390,7 +390,7 @@ function templateComments(templateLines, c) {
390
390
  // had them stripped before the scan, muting any §10-V violation inside. Same
391
391
  // violation at 2 trailing `#` lines denied, at 3 it exited 0 — a silent miss
392
392
  // that grew MORE likely the longer the commented block got, on the shipped
393
- // pre-commit/CI entry point.
393
+ // commit-msg/CI entry point.
394
394
  //
395
395
  // That removal was shipped with the claim that no git shape needs the signal.
396
396
  // The 0.68.3 pre-tag review refuted it: the six-shape table measured
@@ -463,10 +463,19 @@ export function parseTranscript(jsonlText, integrity = null) {
463
463
  continue;
464
464
  }
465
465
  if (row.type !== 'assistant') continue;
466
- const content = row.message?.content || [];
466
+ const content = row.message?.content;
467
+ // Same defect one level down (R15): an object where the block array
468
+ // belongs threw "not iterable", a null block threw on `.type`. A string is
469
+ // a known shape, counted apart by countStringContentAssistantRows.
470
+ if (!Array.isArray(content)) {
471
+ if (content != null && typeof content !== 'string' && integrity) {
472
+ integrity.badLines = (integrity.badLines || 0) + 1;
473
+ }
474
+ continue;
475
+ }
467
476
  const texts = [];
468
477
  for (const b of content) {
469
- if (b.type === 'text' && typeof b.text === 'string') texts.push(b.text);
478
+ if (b && b.type === 'text' && typeof b.text === 'string') texts.push(b.text);
470
479
  }
471
480
  if (texts.length === 0) continue;
472
481
  turns.push({ turnIndex, line: i + 1, text: texts.join(' ') });