hypomnema 1.6.2 → 1.7.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.
Files changed (62) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.ko.md +39 -14
  4. package/README.md +39 -14
  5. package/commands/capture.md +8 -6
  6. package/commands/crystallize.md +39 -20
  7. package/docs/ARCHITECTURE.md +49 -14
  8. package/docs/CONTRIBUTING.md +31 -29
  9. package/hooks/base-store.mjs +265 -0
  10. package/hooks/hooks.json +7 -1
  11. package/hooks/hypo-auto-minimal-crystallize.mjs +63 -20
  12. package/hooks/hypo-auto-stage.mjs +30 -0
  13. package/hooks/hypo-cwd-change.mjs +31 -2
  14. package/hooks/hypo-file-watch.mjs +21 -2
  15. package/hooks/hypo-first-prompt.mjs +19 -3
  16. package/hooks/hypo-lookup.mjs +86 -29
  17. package/hooks/hypo-personal-check.mjs +24 -3
  18. package/hooks/hypo-session-record.mjs +2 -3
  19. package/hooks/hypo-session-start.mjs +89 -7
  20. package/hooks/hypo-shared.mjs +904 -128
  21. package/hooks/proposal-store.mjs +513 -0
  22. package/package.json +40 -14
  23. package/scripts/capture.mjs +556 -37
  24. package/scripts/crystallize.mjs +639 -108
  25. package/scripts/doctor.mjs +304 -9
  26. package/scripts/feedback-sync.mjs +515 -44
  27. package/scripts/graph.mjs +9 -2
  28. package/scripts/init.mjs +230 -34
  29. package/scripts/lib/extensions.mjs +656 -1
  30. package/scripts/lib/hypo-ignore.mjs +54 -6
  31. package/scripts/lib/hypo-root.mjs +56 -6
  32. package/scripts/lib/page-usage.mjs +15 -2
  33. package/scripts/lib/pkg-json.mjs +40 -0
  34. package/scripts/lib/plugin-detect.mjs +96 -6
  35. package/scripts/lib/wd-match.mjs +23 -5
  36. package/scripts/lib/wikilink.mjs +32 -6
  37. package/scripts/lint.mjs +20 -4
  38. package/scripts/proposal.mjs +1032 -0
  39. package/scripts/query.mjs +25 -4
  40. package/scripts/resume.mjs +34 -12
  41. package/scripts/stats.mjs +28 -8
  42. package/scripts/uninstall.mjs +141 -6
  43. package/scripts/upgrade.mjs +197 -15
  44. package/skills/crystallize/SKILL.md +44 -7
  45. package/skills/debate/SKILL.md +88 -0
  46. package/skills/debate/references/orchestration-patterns.md +83 -0
  47. package/templates/.hyposcanignore +10 -0
  48. package/templates/SCHEMA.md +12 -0
  49. package/templates/gitignore +5 -0
  50. package/templates/hypo-config.md +1 -1
  51. package/templates/hypo-guide.md +6 -0
  52. package/scripts/.gitkeep +0 -0
  53. package/scripts/check-bilingual.mjs +0 -153
  54. package/scripts/check-readme-version.mjs +0 -126
  55. package/scripts/check-tracker-ids.mjs +0 -426
  56. package/scripts/check-versions.mjs +0 -171
  57. package/scripts/install-git-hooks.mjs +0 -293
  58. package/scripts/lib/changelog-classify.mjs +0 -216
  59. package/scripts/lib/check-bilingual.mjs +0 -244
  60. package/scripts/lib/check-tracker-ids.mjs +0 -217
  61. package/scripts/lib/pre-commit-format.mjs +0 -251
  62. package/scripts/pre-commit-format.mjs +0 -198
@@ -1,244 +0,0 @@
1
- /**
2
- * lib/check-bilingual.mjs — pure validators for the bilingual release-doc rule.
3
- *
4
- * Rule source: CLAUDE.md learned_behaviors release-doc-bilingual (2026-05-24).
5
- * Every Hypomnema OSS ship must carry an English body PLUS a Korean summary
6
- * block in both the CHANGELOG section and the git tag annotation. This module
7
- * exports the parsing/validation primitives; the CLI wrapper in
8
- * scripts/check-bilingual.mjs handles I/O and exit codes.
9
- *
10
- * Scope (deliberate): this gate only enforces the KOREAN half. The English
11
- * half has been historically present in every ship — the rule exists because
12
- * the Korean half is what gets silently dropped under time pressure (see
13
- * release-doc-bilingual feedback page). A tag body of "---" + Korean with an
14
- * empty English section would technically pass these validators, but that's
15
- * acceptable because such a body is not a realistic failure mode for a
16
- * maintainer who already wrote the English release notes. If "English missing"
17
- * ever becomes a real regression vector, add an English-half threshold.
18
- *
19
- * Why pure functions: lets tests/runner.mjs construct synthetic CHANGELOG /
20
- * tag-body strings without touching real git or real CHANGELOG.md.
21
- */
22
-
23
- // AC00–D7A3 covers all precomposed Hangul syllables. We NFC-normalize the
24
- // input before counting so jamo-only inputs (decomposed) still match after
25
- // composition.
26
- export const HANGUL_RE = /[가-힣]/g;
27
- export const HANGUL_BODY_THRESHOLD = 10;
28
-
29
- export function countHangul(text) {
30
- return (text.normalize('NFC').match(HANGUL_RE) || []).length;
31
- }
32
-
33
- export function escapeRegex(s) {
34
- return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
35
- }
36
-
37
- // Section model (changelog-pr-guide format.md §1/§2). A version block carries
38
- // gated sections, each split into "#### English" / "#### 한국어" sub-blocks at
39
- // and after the Korean cutoff. Highlights is gated too (it has a curated Korean
40
- // half); Changelog and the migration callout are NOT gated (language-neutral).
41
- export const GATED_HEADINGS = ['Highlights', 'New Features', 'Bug Fixes', 'Chores'];
42
-
43
- // Korean-summary cutoff: 1.2.0 is the first release that shipped a Korean half
44
- // (empirically the first "### 한글 요약" in CHANGELOG.md). 1.0.0–1.1.0 are
45
- // English-only; we never fabricate a Korean half for them (format.md §9).
46
- export const KOREAN_CUTOFF = [1, 2, 0];
47
-
48
- // Parse "major.minor.patch" (ignoring any prerelease/build suffix) into a number
49
- // triple, or null if it does not look like semver.
50
- export function parseSemver(v) {
51
- const m = /^(\d+)\.(\d+)\.(\d+)/.exec(String(v == null ? '' : v).trim());
52
- return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
53
- }
54
-
55
- // Is `version` at or past the Korean cutoff (>= 1.2.0)? A prerelease of a
56
- // cutoff version (1.2.0-rc.1) counts as in-era. Unparseable → treated as not
57
- // meeting the cutoff (the gate then only checks English presence).
58
- export function meetsKoreanCutoff(version) {
59
- const s = parseSemver(version);
60
- if (!s) return false;
61
- for (let i = 0; i < 3; i++) {
62
- if (s[i] !== KOREAN_CUTOFF[i]) return s[i] > KOREAN_CUTOFF[i];
63
- }
64
- return true;
65
- }
66
-
67
- // Split `lines` into the sections introduced by a heading of exactly `hashes`
68
- // '#'. A section ends at the next same-level heading OR any higher-level (fewer
69
- // '#') heading; a deeper heading (more '#') stays inside the section body. So
70
- // slicing a version block at level 3 keeps each "### Section" with its nested
71
- // "#### English/한국어" lines, and re-slicing a section at level 4 yields those
72
- // sub-blocks. Returns [{ title, body: string[] }].
73
- function sliceSections(lines, hashes) {
74
- const headRe = new RegExp(`^#{${hashes}} (.+?)\\s*$`);
75
- const sameOrHigherRe = new RegExp(`^#{1,${hashes}} `);
76
- const out = [];
77
- let cur = null;
78
- for (const line of lines) {
79
- const m = headRe.exec(line);
80
- if (m) {
81
- if (cur) out.push(cur);
82
- cur = { title: m[1].trim(), body: [] };
83
- continue;
84
- }
85
- if (cur) {
86
- // a same-or-higher level heading (that is not our own level) closes the
87
- // open section without opening a tracked one.
88
- if (sameOrHigherRe.test(line)) {
89
- out.push(cur);
90
- cur = null;
91
- continue;
92
- }
93
- cur.body.push(line);
94
- }
95
- }
96
- if (cur) out.push(cur);
97
- return out;
98
- }
99
-
100
- // List the versions a CHANGELOG documents, in file order (skips "Unreleased").
101
- export function listChangelogVersions(content) {
102
- const lines = content.replace(/\r\n/g, '\n').split('\n');
103
- const re = /^## \[([^\]]+)\](\s.*)?$/;
104
- const out = [];
105
- for (const l of lines) {
106
- const m = re.exec(l);
107
- if (m && m[1] !== 'Unreleased') out.push(m[1]);
108
- }
109
- return out;
110
- }
111
-
112
- /**
113
- * Validate one CHANGELOG version block against the section model.
114
- *
115
- * At/after the Korean cutoff (>= 1.2.0): the block must carry at least one gated
116
- * section, and EVERY gated section present must hold both a "#### English" and a
117
- * "#### 한국어" sub-block, the Korean one non-empty, with the version's total
118
- * Korean >= HANGUL_BODY_THRESHOLD. Before the cutoff (1.0.0–1.1.0): English-only
119
- * era — the block need only carry content; no Korean is required or fabricated.
120
- *
121
- * @param {string} content CHANGELOG.md raw content (CRLF tolerated).
122
- * @param {string} version Semver string to look up (e.g. "1.2.1").
123
- * @returns {{ok: true, hangulCount: number, koreanExempt?: boolean} | {ok: false, reason: string}}
124
- */
125
- export function validateChangelog(content, version) {
126
- if (!version) return { ok: false, reason: 'no version supplied' };
127
- const lines = content.replace(/\r\n/g, '\n').split('\n');
128
- const versionEsc = escapeRegex(version);
129
- // Anchor closing bracket so 1.2.1 does NOT match the prefix of 1.2.10.
130
- const sectionRe = new RegExp(`^## \\[${versionEsc}\\](\\s.*)?$`);
131
-
132
- const startIndices = [];
133
- for (let i = 0; i < lines.length; i++) {
134
- if (sectionRe.test(lines[i])) startIndices.push(i);
135
- }
136
- if (startIndices.length === 0) {
137
- return { ok: false, reason: `no "## [${version}]" section in CHANGELOG.md` };
138
- }
139
- if (startIndices.length > 1) {
140
- return {
141
- ok: false,
142
- reason: `duplicate "## [${version}]" sections (${startIndices.length}) in CHANGELOG.md`,
143
- };
144
- }
145
-
146
- const start = startIndices[0];
147
- let sectionEnd = lines.length;
148
- for (let i = start + 1; i < lines.length; i++) {
149
- if (/^## /.test(lines[i])) {
150
- sectionEnd = i;
151
- break;
152
- }
153
- }
154
- const blockLines = lines.slice(start + 1, sectionEnd);
155
- const sections = sliceSections(blockLines, 3);
156
- const gated = sections.filter((s) => GATED_HEADINGS.includes(s.title));
157
-
158
- if (!meetsKoreanCutoff(version)) {
159
- // Pre-cutoff English-only era: require content (a non-heading body line),
160
- // never a Korean half.
161
- const hasContent = blockLines.some((l) => l.trim() && !/^#/.test(l));
162
- if (!hasContent) {
163
- return { ok: false, reason: `section [${version}] has no content` };
164
- }
165
- return { ok: true, hangulCount: 0, koreanExempt: true };
166
- }
167
-
168
- // Cutoff+: enforce the per-section bilingual structure.
169
- if (gated.length === 0) {
170
- return {
171
- ok: false,
172
- reason:
173
- `section [${version}] has no gated section ` +
174
- `(one of ${GATED_HEADINGS.join(' / ')} is required at >= 1.2.0)`,
175
- };
176
- }
177
- let total = 0;
178
- for (const sec of gated) {
179
- const subs = sliceSections(sec.body, 4);
180
- const hasEnglish = subs.some((s) => s.title === 'English');
181
- const korean = subs.find((s) => s.title === '한국어');
182
- if (!hasEnglish) {
183
- return { ok: false, reason: `[${version}] "${sec.title}" missing "#### English" sub-block` };
184
- }
185
- if (!korean) {
186
- return { ok: false, reason: `[${version}] "${sec.title}" missing "#### 한국어" sub-block` };
187
- }
188
- const koCount = countHangul(korean.body.join('\n'));
189
- if (koCount < 1) {
190
- return {
191
- ok: false,
192
- reason: `[${version}] "${sec.title}" "#### 한국어" has no Korean text (heading alone does not count)`,
193
- };
194
- }
195
- total += koCount;
196
- }
197
- if (total < HANGUL_BODY_THRESHOLD) {
198
- return {
199
- ok: false,
200
- reason:
201
- `section [${version}] total Korean is ${total} Hangul chars ` +
202
- `(threshold: ${HANGUL_BODY_THRESHOLD}). Write real Korean summaries in the "#### 한국어" sub-blocks.`,
203
- };
204
- }
205
- return { ok: true, hangulCount: total };
206
- }
207
-
208
- /**
209
- * Validate that a git tag annotation body has a "---" separator with Korean
210
- * text after the LAST such separator. Tolerates earlier "---" markdown
211
- * horizontal rules in the English body.
212
- *
213
- * @param {string} body Tag annotation body, as returned by
214
- * `git tag -l --format='%(contents)' <ref>`.
215
- * @returns {{ok: true, hangulCount: number} | {ok: false, reason: string}}
216
- */
217
- export function validateTagBody(body) {
218
- const lines = body.replace(/\r\n/g, '\n').split('\n');
219
- let lastSepIdx = -1;
220
- for (let i = lines.length - 1; i >= 0; i--) {
221
- if (lines[i].trim() === '---') {
222
- lastSepIdx = i;
223
- break;
224
- }
225
- }
226
- if (lastSepIdx === -1) {
227
- return {
228
- ok: false,
229
- reason:
230
- 'tag annotation has no "---" separator line (expected: English body + "---" + Korean)',
231
- };
232
- }
233
- const tail = lines.slice(lastSepIdx + 1).join('\n');
234
- const count = countHangul(tail);
235
- if (count < HANGUL_BODY_THRESHOLD) {
236
- return {
237
- ok: false,
238
- reason:
239
- `tag body after the last "---" has only ${count} Hangul chars ` +
240
- `(threshold: ${HANGUL_BODY_THRESHOLD}). Write a real Korean summary after the separator.`,
241
- };
242
- }
243
- return { ok: true, hangulCount: count };
244
- }
@@ -1,217 +0,0 @@
1
- /**
2
- * lib/check-tracker-ids.mjs — pure scanner for wiki-internal tracker IDs.
3
- *
4
- * The OSS repo must not ship references to the maintainer's PRIVATE wiki issue
5
- * tracker (`ISSUE-N`) or fix tracker (`fix #N`) in public artifacts: an OSS user
6
- * who opens a shipped file or reads the README/CHANGELOG just sees a dangling
7
- * pointer into a tracker they cannot access. GitHub references (`PR #N`, `(#N)`,
8
- * bare `#N`, issue URLs) are legitimate and must NOT be flagged.
9
- *
10
- * This module is PURE (no fs, no git, no process) so it is unit-testable; the
11
- * CLI wrapper (scripts/check-tracker-ids.mjs) does the file/git/commit-msg I/O.
12
- *
13
- * Blocked everywhere in scope (examples use an `N` placeholder, NOT a real
14
- * digit, so this file itself scans clean and is NOT exempted from --all):
15
- * - /\bISSUE-\d+\b/i → the wiki issue tracker, any case (e.g. ISSUE-N)
16
- * - /\bfix[ \t ]+#\d+\b/i → the wiki fix tracker, any case (e.g. fix #N)
17
- * - /\bFEAT-\d+\b/i → the wiki feature tracker, any case (e.g. FEAT-N)
18
- * - /\bIMPR-\d+\b/i → the wiki improvement tracker, any case (e.g. IMPR-N)
19
- * - /\bPRAC-\d+\b/i → the wiki practice tracker, any case (e.g. PRAC-N)
20
- *
21
- * FEAT-/IMPR-/PRAC- were once exempt inside shipped code comments; that exemption
22
- * shipped dangling pointers into the maintainer's private wiki to OSS users, so it
23
- * was removed. They now block everywhere in scope; the maintainer keeps them only
24
- * in tests/, qa-runs/, and local notes. The verifier subsystem that legitimately
25
- * carries `decisions/NNNN` runtime data is excluded from the scan instead (see the
26
- * CLI's EXCLUDED_FILES).
27
- *
28
- * `ADR NNNN` / `ADR-NNNN` / `decisions/NNNN` (DECISION_PATTERNS) are dangling
29
- * pointers into the maintainer's private wiki ADR set. They block everywhere in
30
- * scope EXCEPT CHANGELOG.md, whose version history legitimately cites the decision
31
- * behind a release line. The CLI applies these per-file via patternsFor().
32
- *
33
- * Accepted edge cases (documented, not bugs):
34
- * - FALSE POSITIVE: `FOO-ISSUE-N` matches (the `-` gives a word boundary
35
- * before ISSUE). Such a string in this repo would still be a tracker ref.
36
- * - The `fix #N` matcher requires the literal word `fix` immediately before the
37
- * `#`, so `prefix #N`, `suffix #N`, `PR #N`, `(#N)`, and bare `#N` are all
38
- * safe — none start the word `fix` right before the `#`.
39
- */
40
-
41
- // `ADR NNNN` / `ADR-NNNN` / `decisions/NNNN` point into the maintainer's private
42
- // wiki ADR set, which an OSS user does not have. The CLI applies this set to every
43
- // in-scope file EXCEPT CHANGELOG.md (version history may cite a decision); the
44
- // verifier subsystem that carries decisions/ paths as runtime data is removed by
45
- // EXCLUDED_FILES first. The ADR matcher tolerates a space, tab, or hyphen between
46
- // `ADR` and the number. Examples below use `NNNN`, not real digits, so this file
47
- // scans clean.
48
- export const DECISION_PATTERNS = [
49
- {
50
- name: 'ADR NNNN',
51
- label: 'wiki ADR pointer',
52
- re: /\bADR[ \t-]+\d{3,4}\b/gi,
53
- },
54
- {
55
- name: 'decisions/NNNN',
56
- label: 'wiki decisions path',
57
- re: /\bdecisions\/\d{3,4}\b/gi,
58
- },
59
- ];
60
-
61
- // Each entry: a named, /g/i regex over a single line of text.
62
- export const BLOCKED_PATTERNS = [
63
- {
64
- name: 'ISSUE-N',
65
- label: 'wiki issue-tracker id',
66
- re: /\bISSUE-\d+\b/gi,
67
- },
68
- {
69
- name: 'fix #N',
70
- label: 'wiki fix-tracker id',
71
- re: /\bfix[ \t ]+#\d+\b/gi,
72
- },
73
- // FEAT-/IMPR-/PRAC- were a tag-body-only set until they were promoted here so
74
- // they block in shipped code comments too (the old exemption leaked dead wiki
75
- // pointers to OSS users). Examples below use `N`, not a real digit, so this
76
- // file scans clean and is NOT exempted from --all.
77
- { name: 'FEAT-N', label: 'wiki feature-tracker id', re: /\bFEAT-\d+\b/gi },
78
- { name: 'IMPR-N', label: 'wiki improvement-tracker id', re: /\bIMPR-\d+\b/gi },
79
- { name: 'PRAC-N', label: 'wiki practice-tracker id', re: /\bPRAC-\d+\b/gi },
80
- ];
81
-
82
- // The full tracker-ID set for a no-code public surface (the annotated tag body,
83
- // republished verbatim by `gh release create --notes-from-tag`). It equals
84
- // BLOCKED_PATTERNS now that FEAT-/IMPR-/PRAC- live there; ADR/decisions anchors
85
- // are intentionally allowed in the tag body (release notes cite decisions the way
86
- // CHANGELOG history does). The CHANGELOG's own surface-ID-0 is held by the section
87
- // migration + a grep regression test (changelog-pr-guide §5).
88
- export const TAG_BODY_PATTERNS = [...BLOCKED_PATTERNS];
89
-
90
- // Strip a leading comment-continuation marker (`*`, `//`, `#`) so a wrapped
91
- // comment line can be re-joined to its predecessor as flowing text.
92
- const COMMENT_CONT_RE = /^[ \t]*(?:\*|\/\/|#+)[ \t]?/;
93
-
94
- /**
95
- * Scan a blob of text against `patterns` (default BLOCKED_PATTERNS). Returns hits:
96
- * { pattern, label, match, line, col, lineText }
97
- * `line`/`col` are 1-based. Empty array => clean. The CLI passes the broader
98
- * [...BLOCKED_PATTERNS, ...DECISION_PATTERNS] set for every in-scope file but the
99
- * CHANGELOG.
100
- *
101
- * Tracker tokens also line-wrap inside comments (`... continuing (ADR\n * 0045)`).
102
- * A pure per-line scan misses those, so each line is ALSO scanned joined to the
103
- * next (with the next line's comment-continuation marker collapsed to a space);
104
- * only matches that START on the current line and CROSS the join are kept, so a
105
- * wrap is reported exactly once at its prefix line and non-wrapped tokens are
106
- * never double-counted.
107
- */
108
- export function scanText(text, patterns = BLOCKED_PATTERNS) {
109
- if (typeof text !== 'string' || text.length === 0) return [];
110
- const hits = [];
111
- const lines = text.split(/\r?\n/);
112
- for (let i = 0; i < lines.length; i++) {
113
- const lineText = lines[i];
114
- for (const { name, label, re } of patterns) {
115
- re.lastIndex = 0; // reset shared /g regex between lines
116
- let m;
117
- while ((m = re.exec(lineText)) !== null) {
118
- hits.push({
119
- pattern: name,
120
- label,
121
- match: m[0],
122
- line: i + 1,
123
- col: m.index + 1,
124
- lineText,
125
- });
126
- if (m.index === re.lastIndex) re.lastIndex++; // never-zero-width guard
127
- }
128
- }
129
- // Wrapped-token guard: a token can line-wrap at two points — between its
130
- // prefix WORD and its number (`ADR\n0045`, where the break stands in for a
131
- // space) OR right at its separator (`ISSUE-\n9`, `decisions/\n0031`,
132
- // `fix #\n37`, where the digit must sit flush against `-`/`/`/`#`). So scan
133
- // this line joined to the next BOTH with a space (space-separated forms) and
134
- // with no gap (separator-flush forms), keeping only matches that begin on this
135
- // line and cross the join. A token reconstructed by both joins (e.g. the ADR
136
- // hyphen form) is de-duplicated by its whitespace-collapsed text.
137
- if (i + 1 < lines.length) {
138
- const lt = lineText.length;
139
- const tail = lines[i + 1].replace(COMMENT_CONT_RE, '');
140
- const seen = new Set();
141
- for (const [joined, tailAt] of [
142
- [lineText + ' ' + tail, lt + 1],
143
- [lineText + tail, lt],
144
- ]) {
145
- for (const { name, label, re } of patterns) {
146
- re.lastIndex = 0;
147
- let m;
148
- while ((m = re.exec(joined)) !== null) {
149
- if (m.index < lt && m.index + m[0].length > tailAt) {
150
- const key = name + ':' + m[0].replace(/\s+/g, '');
151
- if (!seen.has(key)) {
152
- seen.add(key);
153
- hits.push({
154
- pattern: name,
155
- label,
156
- match: m[0],
157
- line: i + 1,
158
- col: m.index + 1,
159
- lineText: joined,
160
- });
161
- }
162
- }
163
- if (m.index === re.lastIndex) re.lastIndex++;
164
- }
165
- }
166
- }
167
- }
168
- }
169
- return hits;
170
- }
171
-
172
- /**
173
- * Strip a `--verbose` commit diff: everything from the scissors line onward is
174
- * dropped by git before the commit is recorded, so it must not be scanned. The
175
- * scissors art is comment-char-prefixed but the `>8` token is constant across
176
- * comment chars, so match on that. Returns the text up to (excluding) the
177
- * scissors line. If no scissors line is present, returns the input unchanged.
178
- */
179
- // git's real scissors line is COMMENT-prefixed (`# ----- >8 -----`); a bare
180
- // `--- >8 ---` line is NOT a scissors marker (git keeps it as body), so it must
181
- // not trigger truncation. Require the leading comment char (default `#`, or `;`).
182
- const SCISSORS_RE = /^[ \t]*[#;][ \t]*-{2,}[ \t]*>8[ \t]*-{2,}/;
183
-
184
- export function stripScissors(text) {
185
- if (typeof text !== 'string') return '';
186
- const lines = text.split(/\r?\n/);
187
- const idx = lines.findIndex((l) => SCISSORS_RE.test(l));
188
- if (idx === -1) return text;
189
- return lines.slice(0, idx).join('\n');
190
- }
191
-
192
- /**
193
- * Does a commit-message file carry git's auto-generated template? git ONLY adds
194
- * the instructional / status comment block (and the --verbose scissors) when the
195
- * effective cleanup mode will STRIP comment lines (the editor default). In the
196
- * comment-PRESERVING modes (`git commit -m` → whitespace, `--cleanup=verbatim`)
197
- * git adds NO template, so a `#` line there is real committed content.
198
- *
199
- * The commit-msg hook therefore decides comment handling by template presence,
200
- * not by config alone: template present ⇒ scan the strip-comments view (matches
201
- * git, no false-positive on the template's branch/file names); template absent ⇒
202
- * scan the raw view so a `#`-prefixed tracker id git WILL keep is still caught.
203
- *
204
- * Markers cover the default English git template + the scissors line. Other
205
- * locales may miss the template and fall through to the raw (comment-keeping)
206
- * scan — which is the SAFE direction for a gate (catches more, never less).
207
- */
208
- export function messageHasGitTemplate(text) {
209
- if (typeof text !== 'string') return false;
210
- if (SCISSORS_RE_M.test(text)) return true; // --verbose scissors (comment-prefixed)
211
- return /^[#;] *(Please enter the commit message|On branch |Changes to be committed:|Changes not staged for commit:|Untracked files:|Your branch (is|and))/m.test(
212
- text,
213
- );
214
- }
215
-
216
- // Multiline form of SCISSORS_RE for whole-message detection.
217
- const SCISSORS_RE_M = /^[ \t]*[#;][ \t]*-{2,}[ \t]*>8[ \t]*-{2,}/m;