hypomnema 1.4.0 → 1.4.2

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 (39) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.ko.md +151 -153
  4. package/README.md +121 -123
  5. package/commands/audit.md +4 -4
  6. package/commands/crystallize.md +22 -7
  7. package/commands/doctor.md +3 -3
  8. package/commands/feedback.md +5 -3
  9. package/commands/graph.md +3 -3
  10. package/commands/ingest.md +6 -4
  11. package/commands/init.md +5 -3
  12. package/commands/lint.md +3 -3
  13. package/commands/query.md +3 -3
  14. package/commands/rename.md +4 -4
  15. package/commands/resume.md +4 -2
  16. package/commands/stats.md +3 -3
  17. package/commands/upgrade.md +4 -4
  18. package/commands/verify.md +3 -3
  19. package/docs/CONTRIBUTING.md +107 -16
  20. package/hooks/hypo-auto-minimal-crystallize.mjs +23 -11
  21. package/hooks/hypo-shared.mjs +96 -14
  22. package/package.json +6 -1
  23. package/scripts/check-bilingual.mjs +49 -11
  24. package/scripts/check-tracker-ids.mjs +60 -1
  25. package/scripts/crystallize.mjs +275 -39
  26. package/scripts/lib/changelog-classify.mjs +216 -0
  27. package/scripts/lib/check-bilingual.mjs +125 -22
  28. package/scripts/lib/check-tracker-ids.mjs +19 -0
  29. package/scripts/lib/project-create.mjs +23 -5
  30. package/scripts/lib/schema-vocab.mjs +105 -1
  31. package/scripts/lint.mjs +9 -1
  32. package/scripts/weekly-report.mjs +9 -3
  33. package/skills/crystallize/SKILL.md +30 -2
  34. package/templates/SCHEMA.md +11 -3
  35. package/templates/hypo-config.md +1 -1
  36. package/templates/hypo-guide.md +4 -1
  37. package/scripts/bump-version.mjs +0 -77
  38. package/scripts/smoke-pack.mjs +0 -261
  39. package/scripts/smoke-plugin.mjs +0 -194
@@ -10,6 +10,7 @@ import {
10
10
  readFileSync,
11
11
  writeFileSync,
12
12
  existsSync,
13
+ statSync,
13
14
  appendFileSync,
14
15
  mkdirSync,
15
16
  rmSync,
@@ -607,7 +608,15 @@ function closeCandidateSlugs(hypoDir, dates) {
607
608
  }
608
609
  for (const d of dates) {
609
610
  const re = new RegExp('^## \\[' + escapeRegExp(d) + '\\] session \\| (\\S+)', 'gm');
610
- for (const m of content.matchAll(re)) slugs.add(m[1]);
611
+ for (const m of content.matchAll(re)) {
612
+ // B-1: only real projects are close candidates. A malformed log heading
613
+ // (`## [d] session | hypomnema:`) or a stale slug yields a ghost token
614
+ // that no longer maps to a `projects/<slug>/` directory — gating on disk
615
+ // keeps it out of the dangling-close set. Directory (not bare-exists)
616
+ // mirrors the apply-path project check (crystallize.mjs:193).
617
+ const dir = join(projectsDir, m[1]);
618
+ if (existsSync(dir) && statSync(dir).isDirectory()) slugs.add(m[1]);
619
+ }
611
620
  }
612
621
  }
613
622
  return slugs;
@@ -675,7 +684,29 @@ function hasTodayCloseActivity(hypoDir, project, dates) {
675
684
  * dates: string[], fallback: boolean, primary: string|null,
676
685
  * project: string|null, stale: string[], missing: string[]}}
677
686
  */
678
- export function sessionCloseGlobalStatus(hypoDir) {
687
+ export function sessionCloseGlobalStatus(hypoDir, opts = {}) {
688
+ // projectOverride (check-only): the caller (`crystallize --check-session-close
689
+ // --project=<slug>`) wants THIS project's close status, not the recency pick —
690
+ // bypass discovery and report the single project, preserving the global return
691
+ // shape. NEVER threaded from a marker-writing path (--mark / apply auto-marker /
692
+ // PreCompact): those stay global so the marker == compact-ready invariant holds
693
+ // (ADR 0047, codex design review). A green status here is a project-scoped
694
+ // diagnostic, not the global compact-readiness verdict.
695
+ if (opts.projectOverride) {
696
+ const s = sessionCloseFileStatus(hypoDir, { projectOverride: opts.projectOverride });
697
+ return {
698
+ ok: s.ok,
699
+ projects: s.project
700
+ ? [{ project: s.project, ok: s.ok, stale: s.stale, missing: s.missing }]
701
+ : [],
702
+ dates: freshDates(),
703
+ fallback: false,
704
+ primary: s.project,
705
+ project: s.project,
706
+ stale: s.stale,
707
+ missing: s.missing,
708
+ };
709
+ }
679
710
  const dates = freshDates();
680
711
  const recency = resolveActiveProject(hypoDir); // no cwd — close never picks by cwd
681
712
  const todayActive = [...closeCandidateSlugs(hypoDir, dates)].filter((p) =>
@@ -748,6 +779,21 @@ function deriveLogTitle(tail) {
748
779
  return t.replace(/^\s*[—:-]\s*/, '').trim(); // drop a leading separator
749
780
  }
750
781
 
782
+ /**
783
+ * Build the canonical root log.md entry for ONE session-log heading. Single
784
+ * source of truth for the derived-entry format, shared by the global Stop-hook
785
+ * derive (deriveRootLogEntries) and apply's direct per-close write (B-1), so the
786
+ * two paths never drift on the `→ [[projects/<slug>/hot]]` pointer or spacing.
787
+ * `headingTail` is the text AFTER `## [date]` in the authored session-log heading.
788
+ * @returns {{ heading: string, block: string }} heading = the `## [date] session
789
+ * | <slug>` line used for exact-line dedup; block = the full two-line entry.
790
+ */
791
+ export function rootLogEntry(slug, date, headingTail) {
792
+ const title = deriveLogTitle(headingTail);
793
+ const heading = `## [${date}] session | ${slug}` + (title ? ` — ${title}` : '');
794
+ return { heading, block: `${heading}\n→ [[projects/${slug}/hot]]` };
795
+ }
796
+
751
797
  /**
752
798
  * Append any missing root log.md `## [date] session | <slug>` entries derived from
753
799
  * each today-active project's session-log heading(s). Idempotent: dedups on the
@@ -811,11 +857,10 @@ export function deriveRootLogEntries(hypoDir) {
811
857
  const headingRe = new RegExp('^#{1,6} \\[' + escapeRegExp(date) + '\\]\\s*(.*)$', 'gm');
812
858
  let m;
813
859
  while ((m = headingRe.exec(slog)) !== null) {
814
- const title = deriveLogTitle(m[1]);
815
- const heading = `## [${date}] session | ${slug}` + (title ? ` — ${title}` : '');
860
+ const { heading, block } = rootLogEntry(slug, date, m[1]);
816
861
  if (seenHeadings.has(heading)) continue; // exact-line dedup (log.md + queued)
817
862
  seenHeadings.add(heading);
818
- additions.push(`${heading}\n→ [[projects/${slug}/hot]]`);
863
+ additions.push(block);
819
864
  }
820
865
  }
821
866
  }
@@ -1560,23 +1605,40 @@ export function closeFileTargets(hypoDir) {
1560
1605
  * evidence file for EVERY freshDate (not just dates[0]) so the lint scope matches
1561
1606
  * what sessionCloseFileStatus actually checks across a local/UTC date boundary.
1562
1607
  */
1608
+ // The lint-scope target set for ONE project's close: the shared root files
1609
+ // (hot.md / log.md) plus that project's mandatory close files — session-state,
1610
+ // project hot, and each fresh date's session-log evidence file. Used both by
1611
+ // `--check-session-close --project=<slug>` (a project-scoped diagnostic — see
1612
+ // precompactGateStatus opts.projectOverride) and as the per-project building
1613
+ // block of closeFileTargetsGlobal, so the two scopes stay identical per project.
1614
+ export function closeFileTargetsForProject(hypoDir, slug) {
1615
+ const dates = freshDates();
1616
+ const out = new Set(['hot.md', 'log.md']);
1617
+ out.add(`projects/${slug}/session-state.md`);
1618
+ out.add(`projects/${slug}/hot.md`);
1619
+ // Scope to the file each date's freshness is PROVEN by (daily shard or, via
1620
+ // fallback, the legacy monthly), so a corrupt evidence file can't pass the
1621
+ // gate while its lint error is demoted to an out-of-scope notice.
1622
+ for (const d of dates) out.add(sessionLogScopePath(hypoDir, slug, d));
1623
+ return out;
1624
+ }
1625
+
1563
1626
  export function closeFileTargetsGlobal(hypoDir) {
1564
1627
  const dates = freshDates();
1565
1628
  const out = new Set(['hot.md', 'log.md']);
1566
1629
  let active = [...closeCandidateSlugs(hypoDir, dates)].filter((p) =>
1567
1630
  hasTodayCloseActivity(hypoDir, p, dates),
1568
1631
  );
1632
+ // No project closed today → fall back to the recency project (mirrors
1633
+ // sessionCloseGlobalStatus's own fallback at the top of this file), so the lint
1634
+ // scope never narrows below the close-status scope. Dropping this (root-only
1635
+ // when active=[]) would re-open the very gap closeFileTargetsForProject closes.
1569
1636
  if (active.length === 0) {
1570
1637
  const recency = resolveActiveProject(hypoDir);
1571
1638
  if (recency) active = [recency];
1572
1639
  }
1573
1640
  for (const p of active) {
1574
- out.add(`projects/${p}/session-state.md`);
1575
- out.add(`projects/${p}/hot.md`);
1576
- // Scope to the file each date's freshness is PROVEN by (daily shard or, via
1577
- // fallback, the legacy monthly), so a corrupt evidence file can't pass the
1578
- // gate while its lint error is demoted to an out-of-scope notice.
1579
- for (const d of dates) out.add(sessionLogScopePath(hypoDir, p, d));
1641
+ for (const f of closeFileTargetsForProject(hypoDir, p)) out.add(f);
1580
1642
  }
1581
1643
  return out;
1582
1644
  }
@@ -1629,8 +1691,18 @@ export function partitionLintScope(findings, scope) {
1629
1691
  * opts.transcriptPath to widen it to the session's edited files exactly as the
1630
1692
  * hook does.
1631
1693
  *
1694
+ * opts.projectOverride (CHECK-ONLY) narrows BOTH the close status and the lint
1695
+ * scope to a single project, for `--check-session-close --project=<slug>`. A
1696
+ * green result is then a project-scoped diagnostic, NOT the global compact-ready
1697
+ * verdict (ADR 0046) — the caller must surface the scope. It is NEVER passed from
1698
+ * a marker-writing path (--mark / apply auto-marker / PreCompact); those stay
1699
+ * global so a marker can't attest compact-ready while PreCompact re-checks red
1700
+ * (the marker == compact-ready invariant, ADR 0047 / codex design review). When a
1701
+ * log-only marker governs the session, log-only mode wins and projectOverride is
1702
+ * ignored.
1703
+ *
1632
1704
  * @param {string} hypoDir
1633
- * @param {{lintScope?: Iterable<string>, transcriptPath?: string|null, claudeHome?: string}} [opts]
1705
+ * @param {{lintScope?: Iterable<string>, transcriptPath?: string|null, claudeHome?: string, projectOverride?: string|null}} [opts]
1634
1706
  * @returns {{ok: boolean, close: object, blockers: {type:string,reason:string}[], notices: {type:string,reason:string}[], driftTargets: string[], skipped: {lint:boolean, feedback:boolean}}}
1635
1707
  */
1636
1708
  export function precompactGateStatus(hypoDir, opts = {}) {
@@ -1693,7 +1765,9 @@ export function precompactGateStatus(hypoDir, opts = {}) {
1693
1765
  });
1694
1766
  }
1695
1767
  } else {
1696
- close = sessionCloseGlobalStatus(hypoDir);
1768
+ // projectOverride narrows the close status to one project (check-only); a
1769
+ // marker-writing caller never sets it, so the marker path stays global.
1770
+ close = sessionCloseGlobalStatus(hypoDir, { projectOverride: opts.projectOverride });
1697
1771
  if (!close.ok) {
1698
1772
  blockers.push({
1699
1773
  type: 'close',
@@ -1744,8 +1818,16 @@ export function precompactGateStatus(hypoDir, opts = {}) {
1744
1818
  // mandatory files in and re-introduce the cross-project attribution. The
1745
1819
  // session's own transcript-touched files are still added below (a log-only
1746
1820
  // session is accountable for the wiki files it actually edited).
1821
+ // Lint scope: explicit opts.lintScope wins; else log-only uses the shared
1822
+ // root files only; else projectOverride narrows to that one project's close
1823
+ // files (matching the narrowed close status above); else the global set.
1747
1824
  const scope = new Set(
1748
- opts.lintScope || (logOnly ? ['hot.md', 'log.md'] : closeFileTargetsGlobal(hypoDir)),
1825
+ opts.lintScope ||
1826
+ (logOnly
1827
+ ? ['hot.md', 'log.md']
1828
+ : opts.projectOverride
1829
+ ? closeFileTargetsForProject(hypoDir, opts.projectOverride)
1830
+ : closeFileTargetsGlobal(hypoDir)),
1749
1831
  );
1750
1832
  if (opts.transcriptPath && existsSync(opts.transcriptPath)) {
1751
1833
  for (const f of extractTouchedWikiFiles(opts.transcriptPath, hypoDir)) scope.add(f);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hypomnema",
3
- "version": "1.4.0",
3
+ "version": "1.4.2",
4
4
  "description": "LLM-native personal wiki system for Claude Code",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -9,6 +9,11 @@
9
9
  },
10
10
  "files": [
11
11
  "scripts/",
12
+ "!scripts/collect-changelog.mjs",
13
+ "!scripts/lib/collect-changelog.mjs",
14
+ "!scripts/bump-version.mjs",
15
+ "!scripts/smoke-pack.mjs",
16
+ "!scripts/smoke-plugin.mjs",
12
17
  "commands/",
13
18
  "hooks/",
14
19
  "skills/",
@@ -20,7 +20,12 @@ import { readFileSync } from 'fs';
20
20
  import { spawnSync } from 'child_process';
21
21
  import { fileURLToPath } from 'url';
22
22
  import { dirname, join } from 'path';
23
- import { validateChangelog, validateTagBody } from './lib/check-bilingual.mjs';
23
+ import {
24
+ validateChangelog,
25
+ validateTagBody,
26
+ listChangelogVersions,
27
+ meetsKoreanCutoff,
28
+ } from './lib/check-bilingual.mjs';
24
29
 
25
30
  const __dirname = dirname(fileURLToPath(import.meta.url));
26
31
  const REPO_ROOT = join(__dirname, '..');
@@ -43,7 +48,11 @@ function usage(exitCode) {
43
48
  process.stdout.write(
44
49
  `Usage:\n` +
45
50
  ` node scripts/check-bilingual.mjs --changelog [version]\n` +
46
- ` Validate CHANGELOG.md "## [<version>]" section. Default version: package.json.\n` +
51
+ ` Validate one CHANGELOG.md "## [<version>]" section (section model).\n` +
52
+ ` Default version: package.json.\n` +
53
+ ` node scripts/check-bilingual.mjs --changelog --all\n` +
54
+ ` Validate EVERY documented version (Korean enforced at >= 1.2.0,\n` +
55
+ ` English-only versions below the cutoff pass).\n` +
47
56
  ` node scripts/check-bilingual.mjs --tag <ref>\n` +
48
57
  ` Validate annotated tag body (lightweight tags are rejected).\n`,
49
58
  );
@@ -52,12 +61,44 @@ function usage(exitCode) {
52
61
 
53
62
  const args = process.argv.slice(2);
54
63
  const mode = args[0];
64
+ const wantAll = args.includes('--all');
55
65
 
56
66
  if (mode === '--help' || mode === '-h') usage(0);
57
67
  if (!mode) usage(1);
58
68
 
59
69
  if (mode === '--changelog') {
60
- let version = args[1];
70
+ let content;
71
+ try {
72
+ content = readFileSync(join(REPO_ROOT, 'CHANGELOG.md'), 'utf-8');
73
+ } catch (err) {
74
+ fail(`cannot read CHANGELOG.md: ${err.message}`);
75
+ }
76
+
77
+ if (wantAll) {
78
+ // Validate every documented version. Korean is enforced only at/after the
79
+ // cutoff; pre-cutoff versions pass on English presence (format.md §9). This
80
+ // is the migration gate — it must not green-pass a half-migrated file.
81
+ const versions = listChangelogVersions(content);
82
+ if (versions.length === 0) fail('no "## [<version>]" sections found in CHANGELOG.md');
83
+ const failures = [];
84
+ let enforced = 0;
85
+ for (const v of versions) {
86
+ const r = validateChangelog(content, v);
87
+ if (meetsKoreanCutoff(v)) enforced++;
88
+ if (!r.ok) failures.push(` [${v}] ${r.reason}`);
89
+ }
90
+ if (failures.length) {
91
+ fail(`${failures.length}/${versions.length} version(s) failed:\n${failures.join('\n')}`);
92
+ }
93
+ ok(
94
+ `CHANGELOG.md --all: ${versions.length} versions conform ` +
95
+ `(${enforced} Korean-enforced at >= 1.2.0, ${versions.length - enforced} English-only below cutoff).`,
96
+ );
97
+ }
98
+
99
+ // single-version path (kept: prepublishOnly and release.yml call --changelog
100
+ // with no version, defaulting to package.json's).
101
+ let version = args[1] && !args[1].startsWith('--') ? args[1] : undefined;
61
102
  if (!version) {
62
103
  try {
63
104
  const pkg = JSON.parse(readFileSync(join(REPO_ROOT, 'package.json'), 'utf-8'));
@@ -68,16 +109,13 @@ if (mode === '--changelog') {
68
109
  }
69
110
  if (!version) fail('no version (arg empty, package.json has no "version" field)');
70
111
 
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
112
  const result = validateChangelog(content, version);
79
113
  if (!result.ok) fail(result.reason);
80
- ok(`CHANGELOG.md [${version}] — ${result.hangulCount} Hangul chars in "### 한글 요약".`);
114
+ if (result.koreanExempt) {
115
+ ok(`CHANGELOG.md [${version}] — pre-cutoff version, English-only (Korean exempt).`);
116
+ } else {
117
+ ok(`CHANGELOG.md [${version}] — ${result.hangulCount} Hangul chars across "#### 한국어" sub-blocks.`);
118
+ }
81
119
  } else if (mode === '--tag') {
82
120
  const ref = args[1];
83
121
  if (!ref) fail('--tag requires a ref argument (e.g. v1.2.1)');
@@ -51,6 +51,7 @@ import {
51
51
  messageHasGitTemplate,
52
52
  BLOCKED_PATTERNS,
53
53
  USER_FACING_PATTERNS,
54
+ TAG_BODY_PATTERNS,
54
55
  } from './lib/check-tracker-ids.mjs';
55
56
  import {
56
57
  parseNameStatus,
@@ -297,6 +298,52 @@ function runCommitMsg(file, json) {
297
298
  process.exit(report(violations, json));
298
299
  }
299
300
 
301
+ // Scan an annotated tag's body for ALL wiki tracker prefixes (TAG_BODY_PATTERNS:
302
+ // ISSUE-/fix #/FEAT-/IMPR-/PRAC-). The tag body is the PUBLIC release surface —
303
+ // `gh release create --notes-from-tag` republishes it verbatim — and it carries
304
+ // no code, so unlike the file gate it must reject every prefix (changelog-pr-
305
+ // guide §5 / T4). Wired into release.yml before `gh release create`. `--tag -`
306
+ // reads the body from stdin (piping / test), `--tag <ref>` reads it from git.
307
+ function runTag(ref, json) {
308
+ let body;
309
+ if (ref === '-') {
310
+ try {
311
+ body = readFileSync(0, 'utf-8');
312
+ } catch (err) {
313
+ process.stderr.write(`[check-tracker-ids] --tag -: cannot read stdin: ${err.message}\n`);
314
+ process.exit(2);
315
+ }
316
+ } else {
317
+ // Require an annotated tag: `<ref>^{tag}` peels only for an annotated tag
318
+ // object. A lightweight tag has no body to leak, but a release uses
319
+ // --notes-from-tag on an annotated tag, so a lightweight one here is a
320
+ // release misconfiguration — fail loudly rather than silently pass.
321
+ const tagObj = git(['rev-parse', '--verify', '--quiet', `${ref}^{tag}`]);
322
+ if (tagObj.status !== 0) {
323
+ const exists = git(['rev-parse', '--verify', '--quiet', ref]);
324
+ if (exists.status !== 0) {
325
+ process.stderr.write(`[check-tracker-ids] --tag: tag ${ref} not found\n`);
326
+ } else {
327
+ process.stderr.write(
328
+ `[check-tracker-ids] --tag: ${ref} is a lightweight tag (no annotation body to scan)\n`,
329
+ );
330
+ }
331
+ process.exit(2);
332
+ }
333
+ const tagBody = git(['tag', '-l', '--format=%(contents)', ref]);
334
+ if (tagBody.status !== 0) {
335
+ process.stderr.write(
336
+ `[check-tracker-ids] --tag: failed to read tag ${ref}: ${tagBody.stderr}\n`,
337
+ );
338
+ process.exit(2);
339
+ }
340
+ body = tagBody.stdout || '';
341
+ }
342
+ const file = ref === '-' ? '<stdin>' : `tag:${ref}`;
343
+ const violations = scanText(body, TAG_BODY_PATTERNS).map((h) => ({ file, ...h }));
344
+ process.exit(report(violations, json));
345
+ }
346
+
300
347
  // ── arg parsing ────────────────────────────────────────────────────────────
301
348
 
302
349
  function usage(code) {
@@ -304,7 +351,10 @@ function usage(code) {
304
351
  'Usage:\n' +
305
352
  ' node scripts/check-tracker-ids.mjs [--all] [--json]\n' +
306
353
  ' node scripts/check-tracker-ids.mjs --staged [--json]\n' +
307
- ' node scripts/check-tracker-ids.mjs --commit-msg <file> [--json]\n',
354
+ ' node scripts/check-tracker-ids.mjs --commit-msg <file> [--json]\n' +
355
+ ' node scripts/check-tracker-ids.mjs --tag <ref|-> [--json]\n' +
356
+ ' Scan an annotated tag body (or stdin via "-") for ALL tracker\n' +
357
+ ' prefixes; the public release surface must be tracker-ID-0.\n',
308
358
  );
309
359
  process.exit(code);
310
360
  }
@@ -321,6 +371,15 @@ if (argv.includes('--commit-msg')) {
321
371
  usage(2);
322
372
  }
323
373
  runCommitMsg(file, json);
374
+ } else if (argv.includes('--tag')) {
375
+ const i = argv.indexOf('--tag');
376
+ const ref = argv[i + 1];
377
+ // `-` (stdin) is a valid ref token here; only a missing/flag value is an error.
378
+ if (!ref || (ref.startsWith('--') && ref !== '-')) {
379
+ process.stderr.write('[check-tracker-ids] --tag requires a ref argument (or "-" for stdin)\n');
380
+ usage(2);
381
+ }
382
+ runTag(ref, json);
324
383
  } else if (argv.includes('--staged')) {
325
384
  runStaged(json);
326
385
  } else {