hypomnema 1.2.1 → 1.3.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 (38) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/commands/crystallize.md +23 -6
  4. package/commands/feedback.md +1 -1
  5. package/docs/CONTRIBUTING.md +96 -11
  6. package/hooks/hypo-auto-commit.mjs +3 -3
  7. package/hooks/hypo-auto-minimal-crystallize.mjs +8 -3
  8. package/hooks/hypo-cwd-change.mjs +2 -2
  9. package/hooks/hypo-first-prompt.mjs +1 -1
  10. package/hooks/hypo-personal-check.mjs +57 -7
  11. package/hooks/hypo-session-start.mjs +51 -4
  12. package/hooks/hypo-shared.mjs +137 -12
  13. package/hooks/version-check.mjs +204 -6
  14. package/package.json +5 -2
  15. package/scripts/bump-version.mjs +9 -3
  16. package/scripts/check-bilingual.mjs +115 -0
  17. package/scripts/crystallize.mjs +124 -15
  18. package/scripts/doctor.mjs +45 -9
  19. package/scripts/feedback-sync.mjs +44 -15
  20. package/scripts/feedback.mjs +5 -5
  21. package/scripts/fix-status-verify.mjs +256 -0
  22. package/scripts/init.mjs +45 -4
  23. package/scripts/install-git-hooks.mjs +258 -0
  24. package/scripts/lib/adr-corpus.mjs +79 -0
  25. package/scripts/lib/check-bilingual.mjs +141 -0
  26. package/scripts/lib/extensions.mjs +3 -3
  27. package/scripts/lib/feedback-scope.mjs +21 -0
  28. package/scripts/lib/fix-manifest.mjs +109 -0
  29. package/scripts/lib/fix-status-verify.mjs +438 -0
  30. package/scripts/lib/pre-commit-format.mjs +251 -0
  31. package/scripts/lib/project-create.mjs +2 -2
  32. package/scripts/lint.mjs +48 -8
  33. package/scripts/pre-commit-format.mjs +198 -0
  34. package/scripts/smoke-pack.mjs +16 -0
  35. package/scripts/upgrade.mjs +55 -23
  36. package/skills/crystallize/SKILL.md +13 -2
  37. package/templates/hypo-config.md +1 -1
  38. package/templates/hypo-guide.md +4 -0
@@ -17,8 +17,8 @@ const HOME = homedir();
17
17
  // hypo-session-start / hypo-cwd-change WRITE this marker; hypo-first-prompt
18
18
  // READS + unlinks it. The session_id comes from the Claude Code runtime (a
19
19
  // UUID), but we sanitize defensively so a malformed id with path separators or
20
- // `..` can never escape tmpdir or collide on an empty value (codex review
21
- // 2026-05-21, fix #3/#13). Non-alphanumeric chars collapse to `_`.
20
+ // `..` can never escape tmpdir or collide on an empty value. Non-alphanumeric
21
+ // chars collapse to `_`.
22
22
  export function sessionMarkerPath(sessionId) {
23
23
  const safe = String(sessionId || 'default').replace(/[^A-Za-z0-9._-]/g, '_') || 'default';
24
24
  return join(tmpdir(), `hypo-session-marker-${safe}.json`);
@@ -207,8 +207,8 @@ export function hasSessionLogHeading(content, date) {
207
207
  * "foo-bar" (hyphen is non-word). The canonical log format always separates the
208
208
  * project slug from anything that follows by whitespace or end-of-line, so the
209
209
  * lookahead correctly rejects "session | foo-bar" when looking for "foo".
210
- * (Reported by codex review of fix #38 — was a pre-existing bug in
211
- * sessionCloseFileStatus that the helper extraction inherited.)
210
+ * (Was a pre-existing bug in sessionCloseFileStatus that the helper extraction
211
+ * inherited.)
212
212
  */
213
213
  export function hasLogEntry(content, date, project) {
214
214
  return new RegExp(
@@ -358,9 +358,9 @@ export function sessionCloseFileStatus(hypoDir) {
358
358
 
359
359
  // ── sync-state ────────────────────────────────────────────
360
360
  // `.cache/sync-state.json` is JSONL: one {timestamp, op, error, host} entry per
361
- // line. hypo-auto-commit (#9) appends on pull/push failure; hypo-session-start
362
- // (#10) surfaces open entries and clears them once sync is healthy again;
363
- // doctor (#11) warns while entries remain. Keep the schema defined here only.
361
+ // line. hypo-auto-commit (fix #9) appends on pull/push failure; hypo-session-start
362
+ // (fix #10) surfaces open entries and clears them once sync is healthy again;
363
+ // doctor (fix #11) warns while entries remain. Keep the schema defined here only.
364
364
 
365
365
  /** @returns {string} path to the sync-state JSONL file for a wiki root. */
366
366
  function syncStatePath(hypoDir) {
@@ -527,14 +527,12 @@ export function shouldSuggestProjectCreation(cwd, hypoDir = HYPO_DIR, now = Date
527
527
  * Build the §8.11 auto-project offer line for a cwd. The display name is the
528
528
  * cwd basename, which is attacker-influenced (a directory name can contain
529
529
  * newlines/control chars on Unix). Strip control characters and length-cap it
530
- * so a crafted dir name cannot spoof extra instructions in additionalContext
531
- * (codex review 2026-05-22).
530
+ * so a crafted dir name cannot spoof extra instructions in additionalContext.
532
531
  */
533
532
  export function buildProjectSuggestionLine(cwd) {
534
533
  // Replace any control char (code < 0x20 or === 0x7F) with a space so a
535
- // crafted dir name cannot inject newlines/instructions into additionalContext
536
- // (codex review 2026-05-22). Done by codepoint to keep control bytes out of
537
- // this source file.
534
+ // crafted dir name cannot inject newlines/instructions into additionalContext.
535
+ // Done by codepoint to keep control bytes out of this source file.
538
536
  const sanitized = Array.from(basename(cwd))
539
537
  .map((ch) => {
540
538
  const code = ch.codePointAt(0);
@@ -803,6 +801,133 @@ export function hasMutatingTranscriptActivity(transcriptPath) {
803
801
  return false;
804
802
  }
805
803
 
804
+ // ── session-scoped lint classification ──────────────────────────────────────
805
+ // Bug A/B fix: the close gate must judge a session on the files IT touched, not
806
+ // the whole vault. Lint debt from another project/session (often in shared
807
+ // pages/) must not block this session's close/compact. Two scope builders feed
808
+ // one shared classifier: transcript-derived (hooks + standalone marker) and
809
+ // close-file/payload-derived (the documented apply path writes via Bash, so its
810
+ // files never appear as Edit/Write file_paths and must be seeded explicitly).
811
+
812
+ const MUTATING_FILE_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit']);
813
+
814
+ /** Pull file_path/notebook_path args from mutating tool_use blocks in one
815
+ * transcript entry. Mirrors extractTranscriptToolNames' shape handling
816
+ * (top-level tool_use + nested message.content[] blocks). */
817
+ function extractTranscriptToolFilePaths(entry) {
818
+ const paths = [];
819
+ if (!entry || typeof entry !== 'object') return paths;
820
+ const pull = (name, input) => {
821
+ if (!name || !MUTATING_FILE_TOOLS.has(name) || !input || typeof input !== 'object') return;
822
+ const fp = input.file_path || input.notebook_path;
823
+ if (typeof fp === 'string' && fp) paths.push(fp);
824
+ };
825
+ if (entry.type === 'tool_use') pull(entry.name || entry.tool_name, entry.input);
826
+ const content = entry.message?.content ?? (Array.isArray(entry.content) ? entry.content : null);
827
+ if (Array.isArray(content)) {
828
+ for (const block of content) {
829
+ if (block && typeof block === 'object' && block.type === 'tool_use') {
830
+ pull(block.name || block.tool_name, block.input);
831
+ }
832
+ }
833
+ }
834
+ return paths;
835
+ }
836
+
837
+ /** Normalize an absolute path to a repo-relative POSIX path under hypoDir, or
838
+ * null if it resolves outside the wiki. */
839
+ function toHypoRel(absPath, hypoDir) {
840
+ let rel;
841
+ try {
842
+ rel = relative(hypoDir, absPath);
843
+ } catch {
844
+ return null;
845
+ }
846
+ if (!rel || rel.startsWith('..') || rel.startsWith('/')) return null;
847
+ return rel.split('\\').join('/');
848
+ }
849
+
850
+ /**
851
+ * Repo-relative POSIX paths of wiki files this session edited via direct
852
+ * Edit/Write/MultiEdit/NotebookEdit tool_use. Returns a Set; empty when the
853
+ * transcript is missing/unreadable (callers decide the fallback). A per-line
854
+ * JSON parse error skips that line only (transcripts occasionally truncate).
855
+ */
856
+ export function extractTouchedWikiFiles(transcriptPath, hypoDir) {
857
+ const out = new Set();
858
+ if (!transcriptPath || typeof transcriptPath !== 'string' || !existsSync(transcriptPath)) {
859
+ return out;
860
+ }
861
+ let raw;
862
+ try {
863
+ raw = readFileSync(transcriptPath, 'utf-8');
864
+ } catch {
865
+ return out;
866
+ }
867
+ for (const line of raw.split('\n')) {
868
+ const t = line.trim();
869
+ if (!t) continue;
870
+ let entry;
871
+ try {
872
+ entry = JSON.parse(t);
873
+ } catch {
874
+ continue;
875
+ }
876
+ for (const fp of extractTranscriptToolFilePaths(entry)) {
877
+ const rel = toHypoRel(fp, hypoDir);
878
+ if (rel) out.add(rel);
879
+ }
880
+ }
881
+ return out;
882
+ }
883
+
884
+ /**
885
+ * The mandatory session-close files (repo-relative POSIX). The documented close
886
+ * path `crystallize.mjs --apply-session-close` writes these from inside a Bash
887
+ * call, so they never surface as Edit/Write file_paths — they must seed the
888
+ * scoped-lint set explicitly or a close-introduced error would escape the gate.
889
+ * Mirrors the file list in sessionCloseFileStatus.
890
+ */
891
+ export function closeFileTargets(hypoDir) {
892
+ const out = new Set(['hot.md', 'log.md']);
893
+ const project = resolveActiveProject(hypoDir);
894
+ if (project) {
895
+ out.add(`projects/${project}/session-state.md`);
896
+ out.add(`projects/${project}/hot.md`);
897
+ const month = freshDates()[0].slice(0, 7);
898
+ out.add(`projects/${project}/session-log/${month}.md`);
899
+ }
900
+ return out;
901
+ }
902
+
903
+ /** Normalize a path's separators to POSIX so scope membership is OS-independent.
904
+ * lint.mjs emits `file` via path.relative (back-slashes on Windows) while the
905
+ * scope builders produce forward-slash paths — normalize both sides. */
906
+ function posixPath(p) {
907
+ return (p || '').split('\\').join('/');
908
+ }
909
+
910
+ /**
911
+ * Partition lint findings into `blocking` (a file this session is accountable
912
+ * for) vs `notice` (pre-existing debt elsewhere — surfaced, not blocking).
913
+ *
914
+ * `scope` = iterable of repo-relative paths the session is accountable for.
915
+ * Membership is exact on the normalized path. Only findings passed in are
916
+ * classified — callers pass lint ERRORS; broken wikilinks (lint W4 warnings) are
917
+ * intentionally warn-only (forward references to planned pages are normal in a
918
+ * wiki) and are NOT promoted to blocking by this gate.
919
+ */
920
+ export function partitionLintScope(findings, scope) {
921
+ const normScope = new Set([...scope].map(posixPath));
922
+ const blocking = [];
923
+ const notice = [];
924
+ for (const f of findings || []) {
925
+ if (normScope.has(posixPath(f.file))) blocking.push(f);
926
+ else notice.push(f);
927
+ }
928
+ return { blocking, notice };
929
+ }
930
+
806
931
  // ── session-close checklist ────────────────────────────────────────────────
807
932
 
808
933
  /**
@@ -18,8 +18,8 @@
18
18
  * than overwrites so it never erases the hook's `notifiedFor` marks.
19
19
  */
20
20
 
21
- import { readFileSync, writeFileSync, renameSync, mkdirSync } from 'fs';
22
- import { dirname, join } from 'path';
21
+ import { readFileSync, writeFileSync, renameSync, mkdirSync, realpathSync, existsSync } from 'fs';
22
+ import { dirname, join, delimiter } from 'path';
23
23
  import { homedir } from 'os';
24
24
 
25
25
  export const TTL_MS = 24 * 60 * 60 * 1000; // 24h
@@ -48,24 +48,72 @@ export function parseSemver(v) {
48
48
  .replace(/^v/, '')
49
49
  .match(/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/);
50
50
  if (!m) return null;
51
- return { major: +m[1], minor: +m[2], patch: +m[3], pre: m[4] || '' };
51
+ // Keep core identifiers as RAW DIGIT STRINGS (not +Number) so compareSemver can
52
+ // order them precisely — SemVer caps neither core nor prerelease numeric length,
53
+ // and Number() silently loses precision past 2^53.
54
+ return { major: m[1], minor: m[2], patch: m[3], pre: m[4] || '' };
55
+ }
56
+
57
+ /**
58
+ * Compare two SemVer numeric identifier strings (digits only, no leading zeros).
59
+ * Done WITHOUT Number() so arbitrary-length identifiers order exactly: fewer
60
+ * digits ⇒ smaller value; equal length ⇒ ASCII order is numeric order.
61
+ */
62
+ function compareNumericId(x, y) {
63
+ if (x.length !== y.length) return x.length < y.length ? -1 : 1;
64
+ if (x !== y) return x < y ? -1 : 1;
65
+ return 0;
66
+ }
67
+
68
+ /**
69
+ * Compare two prerelease strings per the SemVer §11 precedence rules. Identifiers
70
+ * are dot-separated; numeric ones compare numerically and always rank LOWER than
71
+ * alphanumeric ones; a larger set of identifiers outranks a smaller one when all
72
+ * preceding identifiers are equal. Both inputs are non-empty prereleases here.
73
+ */
74
+ function comparePrerelease(a, b) {
75
+ const ai = a.split('.');
76
+ const bi = b.split('.');
77
+ const len = Math.max(ai.length, bi.length);
78
+ for (let i = 0; i < len; i++) {
79
+ // "a larger set of pre-release fields has higher precedence" → the one that
80
+ // still has identifiers wins once the shorter one runs out.
81
+ if (i >= ai.length) return -1;
82
+ if (i >= bi.length) return 1;
83
+ const x = ai[i];
84
+ const y = bi[i];
85
+ const xn = /^\d+$/.test(x);
86
+ const yn = /^\d+$/.test(y);
87
+ if (xn && yn) {
88
+ const c = compareNumericId(x, y);
89
+ if (c !== 0) return c;
90
+ } else if (xn !== yn) {
91
+ return xn ? -1 : 1; // numeric identifiers have lower precedence
92
+ } else if (x !== y) {
93
+ return x < y ? -1 : 1; // ASCII lexical for alphanumeric
94
+ }
95
+ }
96
+ return 0;
52
97
  }
53
98
 
54
99
  /**
55
100
  * Compare two semver strings. Returns -1 / 0 / 1, or null if either is invalid.
56
- * A release outranks a prerelease of the same x.y.z (1.2.3 > 1.2.3-rc.1).
101
+ * A release outranks a prerelease of the same x.y.z (1.2.3 > 1.2.3-rc.1), and
102
+ * prereleases follow full SemVer §11 precedence (1.2.3-rc.2 < 1.2.3-rc.10) — this
103
+ * matters because compareSemver now gates the init/upgrade downgrade guard.
57
104
  */
58
105
  export function compareSemver(a, b) {
59
106
  const pa = parseSemver(a);
60
107
  const pb = parseSemver(b);
61
108
  if (!pa || !pb) return null;
62
109
  for (const k of ['major', 'minor', 'patch']) {
63
- if (pa[k] !== pb[k]) return pa[k] < pb[k] ? -1 : 1;
110
+ const c = compareNumericId(pa[k], pb[k]);
111
+ if (c !== 0) return c;
64
112
  }
65
113
  if (pa.pre === pb.pre) return 0;
66
114
  if (!pa.pre) return 1; // release > prerelease
67
115
  if (!pb.pre) return -1;
68
- return pa.pre < pb.pre ? -1 : 1; // lexicographic fallback (good enough)
116
+ return comparePrerelease(pa.pre, pb.pre);
69
117
  }
70
118
 
71
119
  // ── channel detection ──────────────────────────────────────────────────────
@@ -182,3 +230,153 @@ export function mergeLatest(path, latest, now = Date.now()) {
182
230
  export function isOptedOut(env = process.env) {
183
231
  return Boolean(env.HYPO_NO_UPDATE_CHECK || env.NO_UPDATE_NOTIFIER || env.CI);
184
232
  }
233
+
234
+ // ── stale-sibling detection (ADR 0038) ───────────────────────────────────────
235
+ //
236
+ // A second, OLDER Hypomnema can sit on $PATH (e.g. a stale `npm i -g hypomnema`)
237
+ // while a newer copy owns the active hooks. The CLI bin (`hypomnema`) then routes
238
+ // `hypomnema init` / `upgrade --apply` through the OLD package, which silently
239
+ // downgrades the newer registered hooks (dropping features like this notifier).
240
+ //
241
+ // The update-notifier above only asks "is MY install behind latest?" — it is
242
+ // blind to a stale SIBLING. These helpers add that axis. They are fs-only and
243
+ // offline (no `npm`, no `which` spawn) so they are safe inside the SessionStart
244
+ // hook and `doctor`.
245
+
246
+ /** realpathSync that returns null instead of throwing on a missing/broken path. */
247
+ export function realpathSafe(p) {
248
+ if (typeof p !== 'string' || !p) return null;
249
+ try {
250
+ return realpathSync(p);
251
+ } catch {
252
+ return null;
253
+ }
254
+ }
255
+
256
+ /**
257
+ * Read the nearest ancestor `package.json` named `hypomnema`, starting at `start`
258
+ * and walking up. Returns { pkgRoot, version } or null. Used to map a resolved
259
+ * bin path back to the package that owns it.
260
+ */
261
+ function readOwningPkg(start) {
262
+ let dir = start;
263
+ // Bounded ascent (filesystem depth is finite; cap defensively).
264
+ for (let i = 0; i < 64; i++) {
265
+ const pkgPath = join(dir, 'package.json');
266
+ if (existsSync(pkgPath)) {
267
+ try {
268
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
269
+ if (pkg && pkg.name === 'hypomnema' && typeof pkg.version === 'string') {
270
+ return { pkgRoot: dir, version: pkg.version };
271
+ }
272
+ } catch {
273
+ /* keep ascending — a non-hypomnema package.json is not our target */
274
+ }
275
+ }
276
+ const parent = dirname(dir);
277
+ if (parent === dir) break;
278
+ dir = parent;
279
+ }
280
+ return null;
281
+ }
282
+
283
+ /**
284
+ * Locate the `hypomnema` CLI on $PATH WITHOUT spawning `which`/`npm`.
285
+ *
286
+ * Splits $PATH, probes each dir for the bin (plus PATHEXT variants on Windows),
287
+ * resolves symlinks (npm global bins are symlinks into node_modules), then walks
288
+ * up to the owning package.json. Returns { binPath, pkgRoot, version } for the
289
+ * FIRST hit — that is the one the shell would actually run — or null.
290
+ *
291
+ * Windows note: npm installs `.cmd`/`.ps1` launcher shims (not symlinks), so the
292
+ * realpath→package.json walk usually fails there and we return null rather than
293
+ * guess. POSIX (the reported footgun) resolves cleanly.
294
+ */
295
+ export function resolveCliOnPath(binName = 'hypomnema', env = process.env) {
296
+ const pathVar = env.PATH || env.Path || '';
297
+ if (!pathVar) return null;
298
+ const dirs = pathVar.split(delimiter).filter(Boolean);
299
+ const exts =
300
+ process.platform === 'win32'
301
+ ? (env.PATHEXT || '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean)
302
+ : [''];
303
+ for (const dir of dirs) {
304
+ for (const ext of exts) {
305
+ const candidate = join(dir, binName + ext.toLowerCase());
306
+ const real = realpathSafe(candidate);
307
+ if (!real) continue;
308
+ const owner = readOwningPkg(dirname(real));
309
+ if (owner) return { binPath: candidate, ...owner };
310
+ }
311
+ }
312
+ return null;
313
+ }
314
+
315
+ /**
316
+ * Classify two installs by version and identity. Returns:
317
+ * 'same' — same package root (dev re-run / npm-link) → never a downgrade
318
+ * 'downgrade' — `incoming` is strictly OLDER than `active`
319
+ * 'ok' — `incoming` >= `active`
320
+ * 'unknown' — either version unparseable; cannot prove a downgrade
321
+ *
322
+ * realpath-compares the roots first so a dev workspace re-running its own
323
+ * init/upgrade is never mis-flagged.
324
+ */
325
+ export function classifyInstall(incoming, active) {
326
+ const ri = realpathSafe(incoming && incoming.pkgRoot);
327
+ const ra = realpathSafe(active && active.pkgRoot);
328
+ if (ri && ra && ri === ra) return 'same';
329
+ const cmp = compareSemver(incoming && incoming.version, active && active.version);
330
+ if (cmp === null) return 'unknown';
331
+ return cmp < 0 ? 'downgrade' : 'ok';
332
+ }
333
+
334
+ /**
335
+ * Decide whether to warn about a stale sibling owning the CLI. Returns
336
+ * { cliVersion, line, key } or null. Warns only when the PATH CLI is a DIFFERENT,
337
+ * strictly OLDER package than the active install.
338
+ *
339
+ * `key` is a throttle token (cli path+version → active version) so the
340
+ * SessionStart hook can suppress repeats via `siblingNotifiedFor`.
341
+ */
342
+ export function computeSiblingNotice(cli, active) {
343
+ if (!cli || !active || !active.version) return null;
344
+ if (classifyInstall(cli, active) !== 'downgrade') return null;
345
+ const key = `${cli.binPath || cli.pkgRoot}@${cli.version}->${active.version}`;
346
+ const line =
347
+ `[Hypomnema] Stale install on PATH: \`${cli.binPath || cli.pkgRoot}\` is v${cli.version}, ` +
348
+ `but your active install is v${active.version}.\n` +
349
+ ` Running \`hypomnema init\`/\`upgrade\` from PATH would DOWNGRADE your hooks.\n` +
350
+ ` → remove the old one: npm uninstall -g hypomnema (then re-check with \`hypomnema doctor\`)`;
351
+ return { cliVersion: cli.version, line, key };
352
+ }
353
+
354
+ /** Has this exact sibling tuple already been surfaced? */
355
+ export function siblingAlreadyNotified(cache, key) {
356
+ return Boolean(cache && cache.siblingNotifiedFor === key);
357
+ }
358
+
359
+ /** Record that the sibling banner for `key` was shown (read-merge-write). */
360
+ export function markSiblingNotified(path, key) {
361
+ try {
362
+ const cache = readCache(path) || {};
363
+ cache.siblingNotifiedFor = key;
364
+ writeCacheAtomic(path, cache);
365
+ } catch {
366
+ /* best-effort */
367
+ }
368
+ }
369
+
370
+ /**
371
+ * Shared one-line message for the init/upgrade downgrade guard (P). `op` is
372
+ * 'init' or 'upgrade'. Kept here so guard text stays identical across both CLIs.
373
+ */
374
+ export function downgradeGuardMessage(incomingVersion, activeVersion, op) {
375
+ return (
376
+ `[Hypomnema] Refusing to ${op}: this package is v${incomingVersion}, but your ` +
377
+ `active install is NEWER (v${activeVersion}).\n` +
378
+ ` This is usually a stale global CLI on PATH — proceeding would DOWNGRADE your hooks.\n` +
379
+ ` → upgrade the stale copy: npm install -g hypomnema\n` +
380
+ ` → or, if you really mean to downgrade: re-run with --allow-downgrade`
381
+ );
382
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hypomnema",
3
- "version": "1.2.1",
3
+ "version": "1.3.0",
4
4
  "description": "LLM-native personal wiki system for Claude Code",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -39,11 +39,14 @@
39
39
  "scripts": {
40
40
  "test": "node tests/runner.mjs",
41
41
  "lint": "node scripts/lint.mjs",
42
+ "fix:verify": "node scripts/fix-status-verify.mjs",
42
43
  "graph": "node scripts/graph.mjs",
43
44
  "smoke-pack": "node scripts/smoke-pack.mjs",
45
+ "check:bilingual": "node scripts/check-bilingual.mjs --changelog",
44
46
  "format": "prettier --write .",
45
47
  "format:check": "prettier --check .",
46
- "prepublishOnly": "npm test && npm run lint && npm run smoke-pack"
48
+ "prepare": "node scripts/install-git-hooks.mjs",
49
+ "prepublishOnly": "npm test && npm run lint && npm run smoke-pack && npm run check:bilingual"
47
50
  },
48
51
  "devDependencies": {
49
52
  "prettier": "^3.8.3"
@@ -52,6 +52,12 @@ for (const { path, pattern } of targets) {
52
52
  }
53
53
 
54
54
  console.log(`\nNext steps:`);
55
- console.log(` git add -A && git commit -m "chore(release): v${next}"`);
56
- console.log(` git tag v${next}`);
57
- console.log(` git push origin <branch> --tags`);
55
+ console.log(` 1. Edit CHANGELOG.md ensure the "## [${next}]" section has a`);
56
+ console.log(` "### 한글 요약" sub-section (release.yml check-bilingual gate).`);
57
+ console.log(` 2. node scripts/check-bilingual.mjs --changelog # local pre-check`);
58
+ console.log(` 3. git add -A && git commit -m "chore(release): v${next}"`);
59
+ console.log(` 4. Create an ANNOTATED tag (lightweight tags are rejected by CI):`);
60
+ console.log(` git tag -a v${next} -m "<English body>\\n\\n---\\n\\n<한글 요약>"`);
61
+ console.log(` See docs/CONTRIBUTING.md "Cutting a release" for the full template.`);
62
+ console.log(` 5. node scripts/check-bilingual.mjs --tag v${next} # local pre-check`);
63
+ console.log(` 6. git push origin <branch> --tags`);
@@ -0,0 +1,115 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * check-bilingual.mjs — CLI gate for the bilingual release-doc rule.
4
+ *
5
+ * Modes:
6
+ * --changelog [version] Validate CHANGELOG.md section for given version.
7
+ * Defaults to package.json's "version" field.
8
+ * Wired into npm `prepublishOnly` so publishes fail
9
+ * when the Korean summary is missing.
10
+ *
11
+ * --tag <ref> Validate annotated tag body for given ref.
12
+ * Wired into .github/workflows/release.yml so a
13
+ * lightweight tag or a missing Korean section
14
+ * blocks the npm publish step.
15
+ *
16
+ * Exits 0 on pass, 1 on fail with a stderr diagnostic.
17
+ */
18
+
19
+ import { readFileSync } from 'fs';
20
+ import { spawnSync } from 'child_process';
21
+ import { fileURLToPath } from 'url';
22
+ import { dirname, join } from 'path';
23
+ import { validateChangelog, validateTagBody } from './lib/check-bilingual.mjs';
24
+
25
+ const __dirname = dirname(fileURLToPath(import.meta.url));
26
+ const REPO_ROOT = join(__dirname, '..');
27
+
28
+ const RULE_REF =
29
+ 'Rule source: CLAUDE.md learned_behaviors (release-doc-bilingual, 2026-05-24). ' +
30
+ 'OSS Hypomnema ships must carry English body + Korean summary in both CHANGELOG section and git tag annotation.';
31
+
32
+ function fail(msg) {
33
+ process.stderr.write(`[check-bilingual] FAIL: ${msg}\n${RULE_REF}\n`);
34
+ process.exit(1);
35
+ }
36
+
37
+ function ok(msg) {
38
+ process.stdout.write(`[check-bilingual] OK: ${msg}\n`);
39
+ process.exit(0);
40
+ }
41
+
42
+ function usage(exitCode) {
43
+ process.stdout.write(
44
+ `Usage:\n` +
45
+ ` node scripts/check-bilingual.mjs --changelog [version]\n` +
46
+ ` Validate CHANGELOG.md "## [<version>]" section. Default version: package.json.\n` +
47
+ ` node scripts/check-bilingual.mjs --tag <ref>\n` +
48
+ ` Validate annotated tag body (lightweight tags are rejected).\n`,
49
+ );
50
+ process.exit(exitCode);
51
+ }
52
+
53
+ const args = process.argv.slice(2);
54
+ const mode = args[0];
55
+
56
+ if (mode === '--help' || mode === '-h') usage(0);
57
+ if (!mode) usage(1);
58
+
59
+ if (mode === '--changelog') {
60
+ let version = args[1];
61
+ if (!version) {
62
+ try {
63
+ const pkg = JSON.parse(readFileSync(join(REPO_ROOT, 'package.json'), 'utf-8'));
64
+ version = pkg.version;
65
+ } catch (err) {
66
+ fail(`cannot read package.json: ${err.message}`);
67
+ }
68
+ }
69
+ if (!version) fail('no version (arg empty, package.json has no "version" field)');
70
+
71
+ let content;
72
+ try {
73
+ content = readFileSync(join(REPO_ROOT, 'CHANGELOG.md'), 'utf-8');
74
+ } catch (err) {
75
+ fail(`cannot read CHANGELOG.md: ${err.message}`);
76
+ }
77
+
78
+ const result = validateChangelog(content, version);
79
+ if (!result.ok) fail(result.reason);
80
+ ok(`CHANGELOG.md [${version}] — ${result.hangulCount} Hangul chars in "### 한글 요약".`);
81
+ } else if (mode === '--tag') {
82
+ const ref = args[1];
83
+ if (!ref) fail('--tag requires a ref argument (e.g. v1.2.1)');
84
+
85
+ // Reject lightweight tags. `git rev-parse <ref>^{tag}` succeeds ONLY for
86
+ // annotated tags. For lightweight tags the ^{tag} peel fails because there
87
+ // is no tag object — the ref points straight at a commit.
88
+ const tagObj = spawnSync('git', ['rev-parse', '--verify', '--quiet', `${ref}^{tag}`], {
89
+ encoding: 'utf-8',
90
+ cwd: REPO_ROOT,
91
+ });
92
+ if (tagObj.status !== 0) {
93
+ const exists = spawnSync('git', ['rev-parse', '--verify', '--quiet', ref], {
94
+ encoding: 'utf-8',
95
+ cwd: REPO_ROOT,
96
+ });
97
+ if (exists.status !== 0) fail(`tag ${ref} not found`);
98
+ fail(
99
+ `tag ${ref} is a lightweight tag, not annotated. ` +
100
+ `Re-create with: git tag -a ${ref} -m "<English body>\n\n---\n\n<Korean summary>"`,
101
+ );
102
+ }
103
+
104
+ const tagBody = spawnSync('git', ['tag', '-l', `--format=%(contents)`, ref], {
105
+ encoding: 'utf-8',
106
+ cwd: REPO_ROOT,
107
+ });
108
+ if (tagBody.status !== 0) fail(`failed to read tag ${ref}: ${tagBody.stderr}`);
109
+
110
+ const result = validateTagBody(tagBody.stdout || '');
111
+ if (!result.ok) fail(`tag ${ref} — ${result.reason}`);
112
+ ok(`tag ${ref} annotation — ${result.hangulCount} Hangul chars after last "---" separator.`);
113
+ } else {
114
+ fail(`unknown mode: ${mode}. Use --changelog or --tag (see --help).`);
115
+ }