claudemd-cli 0.68.2 → 0.69.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
@@ -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. |
@@ -215,6 +215,19 @@ export DISABLE_BOOTSTRAP_FAIL_BANNER=1 # v0.50.0+ — only the SessionStart
215
215
  # sentinel + bootstrap.log trail keep being
216
216
  # written so the state stays diagnosable.
217
217
 
218
+ export DISABLE_SPEC_DRIFT_BANNER=1 # only the SessionStart banner reporting
219
+ # installed-spec vs plugin-spec drift; the
220
+ # version check itself still runs, so
221
+ # /claudemd-doctor keeps reporting the drift.
222
+
223
+ export DISABLE_RULE_HITS_LOG=1 # stop every hook from appending to
224
+ # ~/.claude/logs/claudemd.jsonl. Enforcement
225
+ # is unaffected (a deny still denies); what
226
+ # stops is the telemetry /claudemd-audit,
227
+ # /claudemd-rules and the §13.1 demote loop
228
+ # read. Set it while hand-probing a hook so
229
+ # the probe does not land in the real corpus.
230
+
218
231
  export DISABLE_BATCH_CADENCE_ADVISORY=1 # v0.19.2+ — only the §13.2 batch-review
219
232
  # cadence advisory inside session-end-check;
220
233
  # mid-SPINE warn-on-unvalidated-mutation
@@ -353,12 +366,12 @@ claudemd/
353
366
  ├── .claude-plugin/
354
367
  │ ├── plugin.json # minimal manifest (name, version, author, license, keywords)
355
368
  │ └── marketplace.json # marketplace catalog entry
356
- ├── hooks/ # 15 shell hooks + hooks/lib/ (hook-common, rule-hits, platform)
369
+ ├── hooks/ # 15 shell hooks + hooks/lib/ (hook-common, rule-hits, platform, memory-tags)
357
370
  │ └── hooks.json # authoritative hook registration (v0.1.5+); CC expands ${CLAUDE_PLUGIN_ROOT} here
358
371
  ├── commands/ # 16 slash-command markdown files
359
372
  ├── bin/ # standalone CLI entrypoint (claudemd-lint.js → `npx claudemd-cli` on npmjs.org)
360
373
  ├── 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
374
+ ├── spec/ # shipped v6.25.2 CLAUDE*.md trio + OPERATOR.md + hard-rules.json manifest
362
375
  ├── tests/ # hook shell tests + Node.js tests + integration + fixtures
363
376
  ├── docs/ # ADDING-NEW-HOOK.md + RULE-HITS-SCHEMA.md + superpowers/
364
377
  └── .github/workflows/ # ci.yml (ubuntu+macOS × node 20/22/24) + npm-publish.yml (tag-triggered)
@@ -368,12 +381,18 @@ claudemd/
368
381
 
369
382
  ## Extending
370
383
 
371
- - **Add a new hook** — see [`docs/ADDING-NEW-HOOK.md`](docs/ADDING-NEW-HOOK.md) for the 5-step guide (hook script + test + plugin registration + doc + version bump).
384
+ - **Add a new hook** — see [`docs/ADDING-NEW-HOOK.md`](docs/ADDING-NEW-HOOK.md) for the full checklist (hook script + test + plugin registration + telemetry gates + docs + the content gates a new hook trips + version bump).
372
385
  - **Rule-hits log schema** — [`docs/RULE-HITS-SCHEMA.md`](docs/RULE-HITS-SCHEMA.md) for the JSONL row format used by `/claudemd-audit`.
373
386
  - **Design rationale + decisions log** — [`docs/superpowers/specs/2026-04-21-claudemd-plugin-design.md`](docs/superpowers/specs/2026-04-21-claudemd-plugin-design.md).
374
387
 
375
388
  ---
376
389
 
390
+ ## Changelog
391
+
392
+ Every release is recorded in [`CHANGELOG.md`](https://github.com/sdsrss/claudemd/blob/main/CHANGELOG.md) on GitHub. It is not shipped in the npm tarball — at 727 KB it was 89% of a package whose runtime is 95 KB unpacked (35 KB compressed), and `npx claudemd-cli` re-downloads that tarball on every cold run.
393
+
394
+ ---
395
+
377
396
  ## License
378
397
 
379
398
  MIT — see [LICENSE](LICENSE).
@@ -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,
@@ -27,6 +29,11 @@ import {
27
29
  stripGitCommitComments,
28
30
  looksLikeGitMessageFile,
29
31
  } from '../scripts/lib/lint.js';
32
+ // Shared argv authority. It used to be a private function here, which is how
33
+ // scripts/lint-argv.js came to authenticate a CLI's argv contract by FUNCTION
34
+ // NAME — any file declaring a local `validateAndExpandFlags` satisfied the gate
35
+ // (audit-2026-08-22 条目 14). The gate now requires this import.
36
+ import { validateAndExpandFlags } from '../scripts/lib/argv.js';
30
37
 
31
38
  const HERE = path.dirname(fileURLToPath(import.meta.url));
32
39
  const REPO_ROOT = path.resolve(HERE, '..');
@@ -81,40 +88,63 @@ function readPackageVersion() {
81
88
  }
82
89
  }
83
90
 
84
- // Strict-validate flag-shaped args + normalize `--key=value` `--key value`
85
- // pairs so the existing space-form parsing below works on either shape.
86
- // Catches the same antipattern the slash-command CLIs hit in v0.9.16/0.9.17:
87
- // `args.includes('--json')` returns false for `--json=yes`, so the flag was
88
- // silently dropped; `args.indexOf('--file')` returns -1 for `--file=PATH`,
89
- // so the value was silently ignored; an unknown `--jzon` typo was silently
90
- // stripped from positional and never surfaced. Each path now exits 2.
91
- function validateAndExpandFlags(args, knownBools, knownValues, sub) {
92
- const out = [];
93
- const bools = new Set(knownBools);
94
- const values = new Set(knownValues);
95
- for (const a of args) {
96
- if (!a.startsWith('--')) { out.push(a); continue; }
97
- if (a.includes('=')) {
98
- const eq = a.indexOf('=');
99
- const k = a.slice(0, eq);
100
- const v = a.slice(eq + 1);
101
- if (bools.has(k)) {
102
- process.stderr.write(`${sub}: '${k}' is a boolean flag and does not take a value (got '${a}'). Drop the '=...' suffix.\n`);
103
- process.exit(2);
104
- }
105
- if (values.has(k)) {
106
- out.push(k);
107
- out.push(v);
108
- continue;
91
+ // Resolve the repo's `commit.template` and return its lines, so cleanup can
92
+ // drop template text by exact match instead of by shape.
93
+ //
94
+ // Why this exists (0.68.3 pre-tag review, HIGH-1): `commit.template` +
95
+ // `commit.status=false` hands a commit-msg hook a buffer that is entirely
96
+ // git-authored comment lines no `#\t` status prefix, no cut line — and git
97
+ // discards all of them. Both shape signals miss it, so lint scanned a checklist
98
+ // the author never committed and denied a clean commit. A §10-V checklist in a
99
+ // commit template is exactly what this project's own users write.
100
+ //
101
+ // Best-effort by construction: no git, no repo, unset config, unreadable file
102
+ // null, and the caller falls back to the shape signals unchanged.
103
+ function readCommitTemplate(sourcePath) {
104
+ // A commit-msg hook runs with cwd at the work tree root, so that is the first
105
+ // place to ask. The message file's own directory is the fallback — and it is
106
+ // usually `.git/`, where `rev-parse --show-toplevel` refuses to answer
107
+ // ("this operation must be run in a work tree"), so the work tree is derived
108
+ // from `--absolute-git-dir` instead of assumed.
109
+ const starts = [process.cwd()];
110
+ if (sourcePath) starts.push(path.dirname(path.resolve(sourcePath)));
111
+
112
+ for (const cwd of starts) {
113
+ try {
114
+ const git = (...a) => spawnSync('git', a, {
115
+ cwd, encoding: 'utf8', timeout: 5000, windowsHide: true,
116
+ });
117
+
118
+ const cfg = git('config', '--get', 'commit.template');
119
+ if (cfg.status !== 0 || !cfg.stdout || !cfg.stdout.trim()) continue;
120
+ let tmpl = cfg.stdout.trim();
121
+
122
+ // git expands a leading `~/`; it does not expand shell variables.
123
+ if (tmpl === '~' || tmpl.startsWith('~/')) {
124
+ const home = os.homedir();
125
+ if (home) tmpl = path.join(home, tmpl.slice(1));
109
126
  }
110
- process.stderr.write(`${sub}: unknown flag '${k}' (got '${a}').\n`);
111
- process.exit(2);
127
+ if (!path.isAbsolute(tmpl)) tmpl = path.resolve(workTreeOf(git, cwd), tmpl);
128
+
129
+ return fs.readFileSync(tmpl, 'utf8').split('\n');
130
+ } catch {
131
+ // fall through to the next candidate
112
132
  }
113
- if (bools.has(a) || values.has(a)) { out.push(a); continue; }
114
- process.stderr.write(`${sub}: unknown flag '${a}'.\n`);
115
- process.exit(2);
116
133
  }
117
- return out;
134
+ return null;
135
+ }
136
+
137
+ // The work tree a relative `commit.template` resolves against.
138
+ function workTreeOf(git, cwd) {
139
+ const top = git('rev-parse', '--show-toplevel');
140
+ if (top.status === 0 && top.stdout.trim()) return top.stdout.trim();
141
+ // Called from inside the git dir: the work tree is its parent. `.git` for a
142
+ // normal repo, `.git/worktrees/<name>` for a linked one — hence common-dir.
143
+ const common = git('rev-parse', '--git-common-dir');
144
+ if (common.status === 0 && common.stdout.trim()) {
145
+ return path.dirname(path.resolve(cwd, common.stdout.trim()));
146
+ }
147
+ return cwd;
118
148
  }
119
149
 
120
150
  function lintCmd(rawArgs) {
@@ -305,7 +335,9 @@ function lintCmd(rawArgs) {
305
335
  // sitting in a `#` line git will discard is not in the commit message either.
306
336
  const commitMsgCleanup = !denyCommitMsg && (forceCommitMsg || looksLikeGitMessageFile(sourcePath));
307
337
  if (commitMsgCleanup) {
308
- text = stripGitCommitComments(text, commentChar);
338
+ text = stripGitCommitComments(text, commentChar, {
339
+ templateLines: readCommitTemplate(sourcePath),
340
+ });
309
341
  }
310
342
 
311
343
  // 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.69.0",
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": {
@@ -0,0 +1,126 @@
1
+ // Strict argv parser for slash-command CLI scripts (clean-residue / audit /
2
+ // sparkline). Three contracts the previous inline parsers silently violated:
3
+ // 1. `--key=value` is the ONLY accepted shape for value flags. The space form
4
+ // `--key value` falls back to default + ignores the value, exiting 0 — same
5
+ // footgun family as the v0.9.14 `claudemd-cli lint <path>` silent-success.
6
+ // 2. Unknown flags reject loudly. Pre-fix, every script's `args.find()` lookup
7
+ // silently dropped anything it didn't recognize, so a typo produced
8
+ // indistinguishable-from-success output.
9
+ // 3. `--key=value` for boolean flags rejects (`--apply=yes` shouldn't parse
10
+ // as `--apply` true).
11
+ // Caller catches `ArgvError` and exits 2 (distinct from numeric-validation
12
+ // exit 1) so wrappers can tell parsing-shape errors from validation errors.
13
+
14
+ export class ArgvError extends Error {
15
+ constructor(message) { super(message); this.name = 'ArgvError'; }
16
+ }
17
+
18
+ // Discoverability helper: when `--help` or `-h` is the first non-empty arg
19
+ // (or anywhere in argv for scripts with no flags), print usage to stdout and
20
+ // exit 0. Caller invokes BEFORE parseStrict so unknown-arg rejection doesn't
21
+ // shadow the universal first-probe of every Unix CLI. Pre-fix, every
22
+ // parseStrict-using script (audit / sparkline / hard-rules-audit /
23
+ // clean-residue / doctor) responded `Unknown argument: '--help'.` exit 2 —
24
+ // classic discoverability bug for new users.
25
+ export function printHelpAndExit(argv, usage) {
26
+ if (argv.some(a => a === '--help' || a === '-h')) {
27
+ process.stdout.write(usage.endsWith('\n') ? usage : usage + '\n');
28
+ process.exit(0);
29
+ }
30
+ }
31
+
32
+ // Strict positive-integer validator for numeric flags (`--days` / `--age-days`
33
+ // / `--prune-backups` / `--sample`). Returns the integer when `raw` (after
34
+ // trimming surrounding whitespace) is a plain base-10 positive integer with no
35
+ // leading zero, else null. `Number()` alone is too permissive — it coerces
36
+ // '0x1e' → 30, '1e2' → 100, ' 30 ' → 30 — all of which pass `Number.isInteger`
37
+ // despite help text promising a "positive integer", a silent contract
38
+ // divergence (inverse of the `parseInt` truncation footgun). Mirrors the
39
+ // `/^[1-9][0-9]*$/` guard already used for CLAUDEMD_BATCH_THRESHOLD in status.js.
40
+ export function parsePositiveInt(raw) {
41
+ if (raw == null) return null;
42
+ const s = String(raw).trim();
43
+ // Plain base-10 notation only — rejects hex ('0x1e'), exponential ('1e2'),
44
+ // signs, and interior junk that `Number()` would coerce. A trailing-zero
45
+ // decimal ('30.0', '30.00') is allowed through the shape gate so the
46
+ // integer-valued-float check below can accept it (existing contract: callers
47
+ // / scripts may pass '30.0'); a true fraction ('1.5') passes the shape gate
48
+ // but fails Number.isInteger and is rejected.
49
+ if (!/^[0-9]+(\.[0-9]+)?$/.test(s)) return null;
50
+ const n = Number(s);
51
+ if (!Number.isInteger(n) || n < 1) return null;
52
+ return n;
53
+ }
54
+
55
+ export function parseStrict(argv, { bools = [], values = [] } = {}) {
56
+ const out = { bools: new Set(), values: {} };
57
+ const knownBool = new Set(bools);
58
+ const knownValue = new Set(values);
59
+ for (const a of argv) {
60
+ if (knownBool.has(a)) { out.bools.add(a); continue; }
61
+ if (a.startsWith('--') && a.includes('=')) {
62
+ const eq = a.indexOf('=');
63
+ const k = a.slice(0, eq);
64
+ const v = a.slice(eq + 1);
65
+ if (knownValue.has(k)) { out.values[k] = v; continue; }
66
+ if (knownBool.has(k)) {
67
+ throw new ArgvError(`Boolean flag '${k}' does not take a value (got '${a}').`);
68
+ }
69
+ throw new ArgvError(`Unknown flag: '${k}'.`);
70
+ }
71
+ if (knownValue.has(a)) {
72
+ throw new ArgvError(`'${a}' requires '=value' form (got '${a}' bare). Use '${a}=N'.`);
73
+ }
74
+ throw new ArgvError(`Unknown argument: '${a}'.`);
75
+ }
76
+ return out;
77
+ }
78
+
79
+ // Space-form argv validator for the published `claudemd-cli` binary.
80
+ //
81
+ // Lived in bin/claudemd-lint.js until audit-2026-08-22 条目 14: a second argv
82
+ // authority beside parseStrict, and scripts/lint-argv.js authenticated it BY
83
+ // FUNCTION NAME, so any file that declared a local function called
84
+ // `validateAndExpandFlags` satisfied the gate without validating anything. The
85
+ // gate had been widened to accommodate the duplicate instead of the duplicate
86
+ // being converged. It is not merged into parseStrict because the two contracts
87
+ // genuinely differ and the difference is published: parseStrict rejects the
88
+ // `--key value` space form and positional arguments, both of which
89
+ // `claudemd-cli lint <path> --comment-char ';'` documents and users' pre-commit
90
+ // hooks depend on.
91
+ //
92
+ // Strict-validate flag-shaped args + normalize `--key=value` → `--key value`
93
+ // pairs so the existing space-form parsing below works on either shape.
94
+ // Catches the same antipattern the slash-command CLIs hit in v0.9.16/0.9.17:
95
+ // `args.includes('--json')` returns false for `--json=yes`, so the flag was
96
+ // silently dropped; `args.indexOf('--file')` returns -1 for `--file=PATH`,
97
+ // so the value was silently ignored; an unknown `--jzon` typo was silently
98
+ // stripped from positional and never surfaced. Each path now exits 2.
99
+ export function validateAndExpandFlags(args, knownBools, knownValues, sub) {
100
+ const out = [];
101
+ const bools = new Set(knownBools);
102
+ const values = new Set(knownValues);
103
+ for (const a of args) {
104
+ if (!a.startsWith('--')) { out.push(a); continue; }
105
+ if (a.includes('=')) {
106
+ const eq = a.indexOf('=');
107
+ const k = a.slice(0, eq);
108
+ const v = a.slice(eq + 1);
109
+ if (bools.has(k)) {
110
+ process.stderr.write(`${sub}: '${k}' is a boolean flag and does not take a value (got '${a}'). Drop the '=...' suffix.\n`);
111
+ process.exit(2);
112
+ }
113
+ if (values.has(k)) {
114
+ out.push(k);
115
+ out.push(v);
116
+ continue;
117
+ }
118
+ process.stderr.write(`${sub}: unknown flag '${k}' (got '${a}').\n`);
119
+ process.exit(2);
120
+ }
121
+ if (bools.has(a) || values.has(a)) { out.push(a); continue; }
122
+ process.stderr.write(`${sub}: unknown flag '${a}'.\n`);
123
+ process.exit(2);
124
+ }
125
+ return out;
126
+ }
@@ -85,10 +85,14 @@ function posixClassesToJs(regex) {
85
85
  // not read as a value claim. `\b` treats '-', '/', '.' as word boundaries, so
86
86
  // `\bcomprehensive\b` fires INSIDE `comprehensive-parser.js` or a branch name
87
87
  // `docs/comprehensive-audit`. Mirrors hooks/banned-vocab-check.sh's v0.23.19
88
- // Path 2 sanitizer (fenced blocks → inline backtick spans → slashed-path runs)
89
- // and adds a bare dotted-file token strip, because the CLI's primary input —
90
- // commit messages commonly names bare files (`refactor comprehensive-parser.js`)
91
- // without backticks or a leading path. Token classes are ASCII-only so 中文
88
+ // Path 2 sanitizer stage for stage: fenced blocks → inline backtick spans →
89
+ // slashed-path runs bare dotted-file tokens. The last clause was JS-only when
90
+ // it was written — commit messages, the CLI's primary input, commonly name bare
91
+ // files (`refactor comprehensive-parser.js`) with no backticks and no leading
92
+ // path — but the bash side gained it on 2026-08-16 and
93
+ // tests/scripts/sanitize-stage-parity.test.js now extracts both programs and
94
+ // requires the clause lists to match, so it is no longer an extension of
95
+ // anything (audit-2026-08-22 条目 22). Token classes are ASCII-only so 中文
92
96
  // prose and bare-word claims (the real violations) stay intact and still match.
93
97
  export function stripIdentifiers(text) {
94
98
  if (!text) return text;
@@ -162,8 +166,10 @@ export function stripIdentifiers(text) {
162
166
  // The bash engines need no equivalent: POSIX sed does not backtrack and
163
167
  // the hook caps its input at `tail -c 4096`; the Node path caps nothing.
164
168
  .replace(/(?<![A-Za-z0-9._@~-])[A-Za-z0-9._@~-]*\/[A-Za-z0-9._/@~-]*/g, ' ')
165
- // 4. Bare dotted-file tokens (foo.js, comprehensive-parser.ts) — CLI
166
- // extension. The extension must start with a LOWERCASE letter, which
169
+ // 4. Bare dotted-file tokens (foo.js, comprehensive-parser.ts). JS-only
170
+ // when written; the bash sanitizer carries the same clause since
171
+ // 2026-08-16 and sanitize-stage-parity pins them together.
172
+ // The extension must start with a LOWERCASE letter, which
167
173
  // (a) excludes decimals / versions ("3.5x", "v6.14") whose ".5x"/".14"
168
174
  // could otherwise swallow a baseline-less ratio claim → false negative,
169
175
  // and (b) excludes sentence-boundary typos ("comprehensive.Next", capital
@@ -275,7 +281,7 @@ export function looksLikeGitMessageFile(filePath) {
275
281
  // editor shapes stored none. Stripping unconditionally therefore muted a
276
282
  // real violation in `git commit -F release-notes.md` or
277
283
  // `-m "$(cat notes.md)"` whenever the body carried a markdown heading.
278
- export function stripGitCommitComments(text, commentChar = '#') {
284
+ export function stripGitCommitComments(text, commentChar = '#', { templateLines } = {}) {
279
285
  if (!text) return text;
280
286
  const c = (typeof commentChar === 'string' && commentChar.length === 1) ? commentChar : '#';
281
287
  const esc = c.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
@@ -287,31 +293,64 @@ export function stripGitCommitComments(text, commentChar = '#') {
287
293
  const lines = cutAt === -1 ? all : all.slice(0, cutAt);
288
294
  const sawCutLine = cutAt !== -1;
289
295
 
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');
296
+ // 2. Drop lines git copied verbatim out of `commit.template`. This is an
297
+ // exact match against the template file's own comment lines, not a shape
298
+ // heuristic: git knows they are template lines because it copied them, and
299
+ // it discards them under the editor path's cleanup=strip. Author-typed
300
+ // comment lines are absent from the template and stay in scope (P1-3).
301
+ const fromTemplate = templateComments(templateLines, c);
302
+ const body = fromTemplate.size
303
+ ? lines.filter(l => !(l.startsWith(c) && fromTemplate.has(l)))
304
+ : lines;
305
+
306
+ // 3. Strip the remaining comment lines only when git wrote a status block or
307
+ // a cut line here.
308
+ if (!sawCutLine && !hasGitTemplate(body, c)) return body.join('\n');
309
+ return body.filter(l => !l.startsWith(c)).join('\n');
310
+ }
311
+
312
+ // The comment lines of a resolved `commit.template`, as an exact-match set.
313
+ // Non-comment template lines are deliberately excluded: git KEEPS those in the
314
+ // stored message, so they are the author's text and must stay scannable.
315
+ function templateComments(templateLines, c) {
316
+ const set = new Set();
317
+ if (!templateLines) return set;
318
+ const arr = Array.isArray(templateLines) ? templateLines : String(templateLines).split('\n');
319
+ for (const l of arr) if (typeof l === 'string' && l.startsWith(c)) set.add(l);
320
+ return set;
293
321
  }
294
322
 
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.
323
+ // Locale-proof template detection. The signal is structural — git localizes the
324
+ // LABELS ("Changes to be committed", "Please enter the commit message…") but
325
+ // never the `<commentChar>`+TAB status prefix that introduces each file it
326
+ // lists. (The cut line is the other signal, handled by the caller.)
299
327
  //
300
- // Deliberately conservative: when neither signal fires we scan MORE text, so a
328
+ // Deliberately conservative: when it does not fire we scan MORE text, so a
301
329
  // 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.
330
+ // silent miss.
331
+ //
332
+ // A second signal used to live here — "≥3 contiguous comment lines ending at
333
+ // EOF" — meant to catch git's intro paragraph. Removed (audit-2026-08-22 P1-3)
334
+ // because of what it reached in the other direction: a `git commit -F notes.md`
335
+ // body (cleanup=whitespace — git KEEPS those lines) ending in three `#` lines
336
+ // had them stripped before the scan, muting any §10-V violation inside. Same
337
+ // violation at 2 trailing `#` lines denied, at 3 it exited 0 — a silent miss
338
+ // that grew MORE likely the longer the commented block got, on the shipped
339
+ // pre-commit/CI entry point.
340
+ //
341
+ // That removal was shipped with the claim that no git shape needs the signal.
342
+ // The 0.68.3 pre-tag review refuted it: the six-shape table measured
343
+ // `commit.status=false` with no `commit.template` configured. Configure one and
344
+ // git hands the hook a buffer of pure template comment lines — no `#\t`, no cut
345
+ // line — and discards every one of them. The replacement is not a third shape
346
+ // heuristic but the template's actual content, matched line-for-line; see
347
+ // `templateComments` and the `templateLines` option on the caller above.
348
+ // The `--allow-empty`-on-a-clean-tree shape (git status prose, no `#\t`,
349
+ // no cut line) is still scanned and is NOT covered here — stating it rather
350
+ // than implying the set is closed, which is the error this comment shipped.
304
351
  function hasGitTemplate(lines, c) {
305
352
  // 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;
353
+ return lines.some(l => l.startsWith(c + '\t'));
315
354
  }
316
355
 
317
356
  export function scan(text, { excludeRatio = false, patterns, sanitize = false } = {}) {