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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/commands/crystallize.md +23 -6
- package/commands/feedback.md +1 -1
- package/docs/CONTRIBUTING.md +96 -11
- package/hooks/hypo-auto-commit.mjs +3 -3
- package/hooks/hypo-auto-minimal-crystallize.mjs +8 -3
- package/hooks/hypo-cwd-change.mjs +2 -2
- package/hooks/hypo-first-prompt.mjs +1 -1
- package/hooks/hypo-personal-check.mjs +57 -7
- package/hooks/hypo-session-start.mjs +51 -4
- package/hooks/hypo-shared.mjs +137 -12
- package/hooks/version-check.mjs +204 -6
- package/package.json +5 -2
- package/scripts/bump-version.mjs +9 -3
- package/scripts/check-bilingual.mjs +115 -0
- package/scripts/crystallize.mjs +124 -15
- package/scripts/doctor.mjs +45 -9
- package/scripts/feedback-sync.mjs +44 -15
- package/scripts/feedback.mjs +5 -5
- package/scripts/fix-status-verify.mjs +256 -0
- package/scripts/init.mjs +45 -4
- package/scripts/install-git-hooks.mjs +258 -0
- package/scripts/lib/adr-corpus.mjs +79 -0
- package/scripts/lib/check-bilingual.mjs +141 -0
- package/scripts/lib/extensions.mjs +3 -3
- package/scripts/lib/feedback-scope.mjs +21 -0
- package/scripts/lib/fix-manifest.mjs +109 -0
- package/scripts/lib/fix-status-verify.mjs +438 -0
- package/scripts/lib/pre-commit-format.mjs +251 -0
- package/scripts/lib/project-create.mjs +2 -2
- package/scripts/lint.mjs +48 -8
- package/scripts/pre-commit-format.mjs +198 -0
- package/scripts/smoke-pack.mjs +16 -0
- package/scripts/upgrade.mjs +55 -23
- package/skills/crystallize/SKILL.md +13 -2
- package/templates/hypo-config.md +1 -1
- package/templates/hypo-guide.md +4 -0
package/hooks/hypo-shared.mjs
CHANGED
|
@@ -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
|
|
21
|
-
//
|
|
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
|
-
* (
|
|
211
|
-
*
|
|
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
|
-
//
|
|
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
|
/**
|
package/hooks/version-check.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
-
"
|
|
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"
|
package/scripts/bump-version.mjs
CHANGED
|
@@ -52,6 +52,12 @@ for (const { path, pattern } of targets) {
|
|
|
52
52
|
}
|
|
53
53
|
|
|
54
54
|
console.log(`\nNext steps:`);
|
|
55
|
-
console.log(`
|
|
56
|
-
console.log(`
|
|
57
|
-
console.log(`
|
|
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
|
+
}
|