hypomnema 1.1.0 → 1.2.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 (72) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.ko.md +4 -2
  4. package/README.md +4 -2
  5. package/commands/audit.md +2 -2
  6. package/commands/crystallize.md +113 -23
  7. package/commands/feedback.md +40 -26
  8. package/commands/ingest.md +31 -9
  9. package/commands/upgrade.md +2 -2
  10. package/docs/ARCHITECTURE.md +1 -1
  11. package/docs/CONTRIBUTING.md +1 -1
  12. package/hooks/hooks.json +30 -1
  13. package/hooks/hypo-auto-commit.mjs +10 -4
  14. package/hooks/hypo-auto-minimal-crystallize.mjs +145 -0
  15. package/hooks/hypo-auto-stage.mjs +4 -3
  16. package/hooks/hypo-compact-guard.mjs +33 -24
  17. package/hooks/hypo-cwd-change.mjs +107 -24
  18. package/hooks/hypo-file-watch.mjs +23 -10
  19. package/hooks/hypo-first-prompt.mjs +37 -23
  20. package/hooks/hypo-hot-rebuild.mjs +22 -10
  21. package/hooks/hypo-lookup.mjs +171 -65
  22. package/hooks/hypo-personal-check.mjs +207 -112
  23. package/hooks/hypo-pre-commit.mjs +46 -0
  24. package/hooks/hypo-session-end.mjs +58 -0
  25. package/hooks/hypo-session-record.mjs +11 -5
  26. package/hooks/hypo-session-start.mjs +298 -52
  27. package/hooks/hypo-shared.mjs +793 -37
  28. package/hooks/hypo-web-fetch-ingest.mjs +121 -0
  29. package/hooks/version-check-fetch.mjs +74 -0
  30. package/hooks/version-check.mjs +184 -0
  31. package/package.json +17 -3
  32. package/scripts/crystallize.mjs +623 -18
  33. package/scripts/doctor.mjs +730 -47
  34. package/scripts/feedback-sync.mjs +974 -0
  35. package/scripts/feedback.mjs +253 -44
  36. package/scripts/graph.mjs +35 -22
  37. package/scripts/ingest.mjs +89 -16
  38. package/scripts/init.mjs +398 -113
  39. package/scripts/lib/design-history-stale.mjs +83 -0
  40. package/scripts/lib/extensions.mjs +749 -0
  41. package/scripts/lib/frontmatter.mjs +5 -1
  42. package/scripts/lib/hypo-ignore.mjs +12 -10
  43. package/scripts/lib/pkg-json.mjs +23 -5
  44. package/scripts/lib/project-create.mjs +225 -0
  45. package/scripts/lib/schema-vocab.mjs +96 -0
  46. package/scripts/lint.mjs +238 -31
  47. package/scripts/query.mjs +26 -10
  48. package/scripts/resume.mjs +11 -5
  49. package/scripts/session-audit.mjs +37 -27
  50. package/scripts/smoke-pack.mjs +224 -0
  51. package/scripts/stats.mjs +24 -10
  52. package/scripts/uninstall.mjs +363 -49
  53. package/scripts/upgrade.mjs +706 -202
  54. package/scripts/verify.mjs +24 -14
  55. package/scripts/weekly-report.mjs +59 -25
  56. package/skills/crystallize/SKILL.md +20 -7
  57. package/skills/ingest/SKILL.md +25 -5
  58. package/templates/.hypoignore +16 -2
  59. package/templates/Home.md +2 -0
  60. package/templates/SCHEMA.md +61 -6
  61. package/templates/extensions/agents/.gitkeep +0 -0
  62. package/templates/extensions/commands/.gitkeep +0 -0
  63. package/templates/extensions/hooks/.gitkeep +0 -0
  64. package/templates/extensions/skills/.gitkeep +0 -0
  65. package/templates/gitignore +5 -0
  66. package/templates/hot.md +2 -0
  67. package/templates/hypo-config.md +1 -1
  68. package/templates/hypo-guide.md +42 -2
  69. package/templates/hypo-help.md +1 -1
  70. package/templates/pages/observability/_index.md +77 -0
  71. package/templates/projects/_template/index.md +2 -2
  72. package/templates/projects/_template/prd.md +1 -1
@@ -0,0 +1,121 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * hypo-web-fetch-ingest.mjs — PostToolUse hook (fix #2)
4
+ *
5
+ * When the LLM uses WebFetch or WebSearch, nudge it to follow §8.4 path A:
6
+ * "external research → /hypo:ingest into sources/". This hook produces a
7
+ * *signal*, not an automatic ingest call — per spec §5.4.6 Q-5.4.6:
8
+ * "WebFetch/WebSearch 자동 ingest 시그널은 별도 hook(fix #2)으로 분리."
9
+ *
10
+ * Why signal-only (not direct ingest):
11
+ * - slug derivation is LLM-driven (commands/ingest.md), so a hook cannot
12
+ * reliably write to the canonical sources/<slug>.* path.
13
+ * - duplicate-ingest detection is delegated to /hypo:ingest itself; the
14
+ * nudge text says "이미 반영 여부 확인 후" so the LLM checks the page
15
+ * graph before adding a new source.
16
+ *
17
+ * Output contract — Claude Code docs, "Add context for Claude":
18
+ * PostToolUse uses **nested** hookSpecificOutput.additionalContext, NOT
19
+ * the top-level additionalContext that UserPromptSubmit hooks use.
20
+ * buildOutput() in hypo-shared.mjs is the top-level helper used by
21
+ * hypo-first-prompt / hypo-lookup; intentionally not reused here. See
22
+ * commit 515458f for the per-event matrix this codebase follows.
23
+ *
24
+ * URL redaction:
25
+ * additionalContext lands in the transcript. Query strings and
26
+ * fragments may carry tokens (?token=…, #access_token=…), so we emit
27
+ * origin + pathname only. Malformed URLs are dropped to avoid raw
28
+ * echoes.
29
+ *
30
+ * matcher choice:
31
+ * hooks/hooks.json does not (today) propagate matcher/timeout through
32
+ * scripts/init.mjs:mergeSettingsJson — the installer rewrites groups
33
+ * as {hooks:[{type,command}]}. So we filter on tool_name internally
34
+ * instead of declaring a matcher. Per-tool spawn cost (~39ms node
35
+ * startup, measured 2026-05-23) is the trade-off; switch to a matcher
36
+ * when the installer learns to preserve it.
37
+ *
38
+ * Fail-safe (spec §5.4.3 (e)): silent fail + {continue:true,
39
+ * suppressOutput:true}. PostToolUse fires only on successful tool calls;
40
+ * failures arrive on a separate PostToolUseFailure event, so no
41
+ * in-hook failure branch is needed.
42
+ */
43
+
44
+ import { isGateSkipped } from './hypo-shared.mjs';
45
+
46
+ let input = {};
47
+ try {
48
+ const raw = await new Promise((r) => {
49
+ let d = '';
50
+ process.stdin.on('data', (c) => (d += c));
51
+ process.stdin.on('end', () => r(d));
52
+ });
53
+ input = raw ? JSON.parse(raw) : {};
54
+ } catch (err) {
55
+ process.stderr.write(`[hypo-web-fetch-ingest] error: ${err?.message ?? String(err)}\n`);
56
+ console.log(JSON.stringify({ continue: true, suppressOutput: true }));
57
+ process.exit(0);
58
+ }
59
+
60
+ const context = buildContext(input);
61
+ const output = { continue: true, suppressOutput: true };
62
+ if (context) {
63
+ output.hookSpecificOutput = {
64
+ hookEventName: 'PostToolUse',
65
+ additionalContext: context,
66
+ };
67
+ }
68
+ console.log(JSON.stringify(output));
69
+
70
+ function buildContext(data) {
71
+ if (isGateSkipped()) return null;
72
+ const tool = data?.tool_name ?? '';
73
+ if (tool === 'WebFetch') {
74
+ const url = redactUrl(data?.tool_input?.url);
75
+ if (!url) return null;
76
+ return (
77
+ `[WIKI AUTO-INGEST: WebFetch] ${url}\n` +
78
+ `Spec §8.4 path A: confirm this URL is not already represented in ` +
79
+ `sources/ or pages/, then call /hypo:ingest to capture it. ` +
80
+ `(URL redacted of query/hash for transcript safety.)`
81
+ );
82
+ }
83
+ if (tool === 'WebSearch') {
84
+ return (
85
+ `[WIKI AUTO-INGEST: WebSearch] external search performed.\n` +
86
+ `Spec §8.4 path A: any URL you fetch from these results should be ` +
87
+ `captured via /hypo:ingest — confirm it is not already in sources/ before ingesting.`
88
+ );
89
+ }
90
+ return null;
91
+ }
92
+
93
+ /**
94
+ * Reduce a URL to `origin + pathname` so query strings, fragments, and
95
+ * userinfo (`https://user:pass@host`) — which routinely carry
96
+ * credentials / session tokens — never reach the transcript via
97
+ * additionalContext. `new URL().origin` is protocol + host + port only,
98
+ * so userinfo is dropped for free.
99
+ *
100
+ * The protocol allow-list (http/https) is a defense-in-depth guard:
101
+ * non-web schemes like `file://`, `ftp://`, `data:` can produce
102
+ * surprising origins (e.g. `null/Users/...`) or pull a local path into
103
+ * the transcript, so they are rejected instead of redacted.
104
+ *
105
+ * Path-carried secrets (`https://host/path/<token>`) remain — keeping
106
+ * the path is the whole point of identifying *which* page to ingest, so
107
+ * full path redaction would defeat the nudge's purpose. The nudge text
108
+ * still tells the LLM to verify the URL before committing it.
109
+ *
110
+ * Returns null on malformed input or disallowed scheme.
111
+ */
112
+ function redactUrl(raw) {
113
+ if (!raw || typeof raw !== 'string') return null;
114
+ try {
115
+ const u = new URL(raw);
116
+ if (u.protocol !== 'http:' && u.protocol !== 'https:') return null;
117
+ return `${u.origin}${u.pathname}`;
118
+ } catch {
119
+ return null;
120
+ }
121
+ }
@@ -0,0 +1,74 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * version-check-fetch.mjs — detached update-check worker
4
+ *
5
+ * Spawned (detached, unref'd, stdio ignored) by the SessionStart hook ONLY when
6
+ * the cache is stale. It fetches the latest published versions for both
7
+ * channels and merges them into the cache, then exits. The hook never waits on
8
+ * it, so session start stays at 0ms added latency; the refreshed version is
9
+ * shown from the NEXT session.
10
+ *
11
+ * Best-effort throughout: any failure (offline, 404, timeout) leaves the cache
12
+ * untouched for that channel and exits 0 — never throws back at a hook.
13
+ *
14
+ * Usage: node version-check-fetch.mjs [cachePath]
15
+ */
16
+
17
+ import { defaultCachePath, mergeLatest } from './version-check.mjs';
18
+
19
+ const NPM_URL = 'https://registry.npmjs.org/hypomnema/latest';
20
+ const PLUGIN_URL =
21
+ 'https://raw.githubusercontent.com/sk-lim19f/Hypomnema/main/.claude-plugin/marketplace.json';
22
+ const TIMEOUT_MS = 2000;
23
+
24
+ async function fetchJson(url) {
25
+ const ctrl = new AbortController();
26
+ const timer = setTimeout(() => ctrl.abort(), TIMEOUT_MS);
27
+ try {
28
+ const res = await fetch(url, { signal: ctrl.signal, headers: { accept: 'application/json' } });
29
+ if (!res.ok) return null;
30
+ return await res.json();
31
+ } catch {
32
+ return null;
33
+ } finally {
34
+ clearTimeout(timer);
35
+ }
36
+ }
37
+
38
+ async function fetchNpmLatest() {
39
+ const data = await fetchJson(NPM_URL);
40
+ return data && typeof data.version === 'string' ? data.version : null;
41
+ }
42
+
43
+ async function fetchPluginLatest() {
44
+ const data = await fetchJson(PLUGIN_URL);
45
+ if (!data || !Array.isArray(data.plugins)) return null;
46
+ // Select by name rather than plugins[0]: a future marketplace.json could list
47
+ // more than one plugin or reorder entries, which would otherwise read the
48
+ // wrong version.
49
+ const entry = data.plugins.find((p) => p && p.name === 'hypomnema');
50
+ const v = entry && entry.version;
51
+ return typeof v === 'string' ? v : null;
52
+ }
53
+
54
+ async function main() {
55
+ const cachePath = process.argv[2] || defaultCachePath();
56
+ const [npmLatest, pluginLatest] = await Promise.all([fetchNpmLatest(), fetchPluginLatest()]);
57
+
58
+ const latest = {};
59
+ if (npmLatest) latest.npm = npmLatest;
60
+ if (pluginLatest) latest.plugin = pluginLatest;
61
+
62
+ // Even if both fetches fail we still stamp checkedAt so we don't hammer the
63
+ // network every single session while offline — the TTL backs off naturally.
64
+ try {
65
+ mergeLatest(cachePath, latest);
66
+ } catch {
67
+ /* best-effort */
68
+ }
69
+ }
70
+
71
+ main().then(
72
+ () => process.exit(0),
73
+ () => process.exit(0),
74
+ );
@@ -0,0 +1,184 @@
1
+ /**
2
+ * version-check.mjs — update-notifier core (pure logic + cache I/O)
3
+ *
4
+ * Hypomnema ships through TWO channels — the `hypomnema` npm package and a
5
+ * Claude Code plugin (marketplace `sk-lim19f/Hypomnema`). This module decides,
6
+ * given the cached "latest" versions and the installed version, whether to show
7
+ * an "update available" banner at session start.
8
+ *
9
+ * Design constraints (see ADR / teams review 2026-05-21):
10
+ * - The SessionStart hook must never make a synchronous network call. It reads
11
+ * ONLY the cache here; a detached worker (version-check-fetch.mjs) refreshes
12
+ * the cache out-of-band. So everything in this file is offline + cheap.
13
+ * - Per-channel state: npm and plugin `latest` can diverge (npm publish vs
14
+ * marketplace commit happen at different times), so `latest` and
15
+ * `notifiedFor` are keyed by channel — a single scalar would suppress or
16
+ * repeat banners when the user switches channels.
17
+ * - Cache writes are atomic (tmp + rename); the fetch worker MERGES rather
18
+ * than overwrites so it never erases the hook's `notifiedFor` marks.
19
+ */
20
+
21
+ import { readFileSync, writeFileSync, renameSync, mkdirSync } from 'fs';
22
+ import { dirname, join } from 'path';
23
+ import { homedir } from 'os';
24
+
25
+ export const TTL_MS = 24 * 60 * 60 * 1000; // 24h
26
+ export const CHANNELS = ['npm', 'plugin'];
27
+
28
+ /**
29
+ * Cache lives under ~/.claude (Claude-hook-specific state), NOT inside the
30
+ * Obsidian vault ~/hypomnema — that directory is git-tracked, so a cache file
31
+ * there would create dirty status, sync noise, and accidental-commit / privacy
32
+ * risk (teams review (e), 2026-05-21).
33
+ */
34
+ export function defaultCachePath(home = homedir()) {
35
+ return join(home, '.claude', 'hypomnema', 'cache', 'version-check.json');
36
+ }
37
+
38
+ // ── semver ───────────────────────────────────────────────────────────────────
39
+
40
+ /**
41
+ * Parse a semver string. Tolerates a leading `v` and ignores build metadata.
42
+ * Returns null for anything that isn't `MAJOR.MINOR.PATCH[-prerelease][+build]`.
43
+ */
44
+ export function parseSemver(v) {
45
+ if (typeof v !== 'string') return null;
46
+ const m = v
47
+ .trim()
48
+ .replace(/^v/, '')
49
+ .match(/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/);
50
+ if (!m) return null;
51
+ return { major: +m[1], minor: +m[2], patch: +m[3], pre: m[4] || '' };
52
+ }
53
+
54
+ /**
55
+ * 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).
57
+ */
58
+ export function compareSemver(a, b) {
59
+ const pa = parseSemver(a);
60
+ const pb = parseSemver(b);
61
+ if (!pa || !pb) return null;
62
+ for (const k of ['major', 'minor', 'patch']) {
63
+ if (pa[k] !== pb[k]) return pa[k] < pb[k] ? -1 : 1;
64
+ }
65
+ if (pa.pre === pb.pre) return 0;
66
+ if (!pa.pre) return 1; // release > prerelease
67
+ if (!pb.pre) return -1;
68
+ return pa.pre < pb.pre ? -1 : 1; // lexicographic fallback (good enough)
69
+ }
70
+
71
+ // ── channel detection ──────────────────────────────────────────────────────
72
+
73
+ /**
74
+ * Decide the active install channel from the package root path.
75
+ *
76
+ * The caller should pass the root derived from the RUNNING hook path
77
+ * (import.meta.url → ../..), with ~/.claude/hypo-pkg.json's pkgRoot only as a
78
+ * fallback: that metadata file has drifted before, and in a dual install
79
+ * (npm global + plugin) it names just one path. Reporting the inactive channel
80
+ * would hand the user the wrong update command (teams review (b), 2026-05-21).
81
+ *
82
+ * Plugin is checked before npm because a plugin install can itself live under a
83
+ * node_modules path, but never vice-versa.
84
+ */
85
+ export function detectChannel(pkgRoot) {
86
+ if (typeof pkgRoot !== 'string' || !pkgRoot) return 'unknown';
87
+ const p = pkgRoot.replace(/\\/g, '/');
88
+ if (p.includes('/plugins/') || p.includes('/.claude/plugins/')) return 'plugin';
89
+ if (p.includes('/node_modules/')) return 'npm';
90
+ return 'unknown';
91
+ }
92
+
93
+ /** Channel-specific one-line update instruction. */
94
+ export function buildUpdateLine(channel, current, latest) {
95
+ const head = `[Hypomnema] Update available! ${current} → ${latest}`;
96
+ if (channel === 'plugin') {
97
+ return `${head}\n → run: /plugin marketplace update hypomnema then /reload-plugins`;
98
+ }
99
+ if (channel === 'npm') {
100
+ return `${head}\n → run: npm install -g hypomnema`;
101
+ }
102
+ return `${head}\n → npm i -g hypomnema (or /plugin marketplace update hypomnema && /reload-plugins)`;
103
+ }
104
+
105
+ // ── cache freshness + notice decision (pure) ─────────────────────────────────
106
+
107
+ /**
108
+ * Is the cache fresh enough to skip a refresh? A `checkedAt` in the future
109
+ * (clock skew / corrupt cache) is treated as stale so the worker re-fetches.
110
+ */
111
+ export function cacheIsFresh(cache, now = Date.now(), ttl = TTL_MS) {
112
+ if (!cache || typeof cache.checkedAt !== 'number') return false;
113
+ if (cache.checkedAt > now + 60_000) return false;
114
+ return now - cache.checkedAt < ttl;
115
+ }
116
+
117
+ /**
118
+ * Decide whether to show a banner. Returns { latest, line } or null.
119
+ * Skips when: unknown channel, no cached latest for the channel, invalid
120
+ * semver, current >= latest (incl. local dev where current > latest), or the
121
+ * channel was already notified for this exact latest version.
122
+ */
123
+ export function computeNotice(cache, channel, current) {
124
+ if (!cache || channel === 'unknown' || !CHANNELS.includes(channel)) return null;
125
+ const latest = cache.latest && cache.latest[channel];
126
+ if (!latest) return null;
127
+ const cmp = compareSemver(current, latest);
128
+ if (cmp === null || cmp >= 0) return null;
129
+ const already = cache.notifiedFor && cache.notifiedFor[channel];
130
+ if (already === latest) return null;
131
+ return { latest, line: buildUpdateLine(channel, current, latest) };
132
+ }
133
+
134
+ // ── cache I/O (atomic) ───────────────────────────────────────────────────────
135
+
136
+ /** Read + parse the cache; returns null on missing/corrupt file. */
137
+ export function readCache(path) {
138
+ try {
139
+ return JSON.parse(readFileSync(path, 'utf-8'));
140
+ } catch {
141
+ return null;
142
+ }
143
+ }
144
+
145
+ /** Atomic write: tmp file in the same dir, then rename (last-writer-wins). */
146
+ export function writeCacheAtomic(path, obj) {
147
+ mkdirSync(dirname(path), { recursive: true });
148
+ const tmp = `${path}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
149
+ writeFileSync(tmp, JSON.stringify(obj, null, 2));
150
+ renameSync(tmp, path);
151
+ }
152
+
153
+ /**
154
+ * Record that the banner for {channel: latest} has been shown, preserving the
155
+ * rest of the cache. Read-merge-write so concurrent worker refreshes and hook
156
+ * marks don't clobber each other's fields. Best-effort: swallows errors.
157
+ */
158
+ export function markNotified(path, channel, latest) {
159
+ try {
160
+ const cache = readCache(path) || {};
161
+ cache.notifiedFor = { ...(cache.notifiedFor || {}), [channel]: latest };
162
+ writeCacheAtomic(path, cache);
163
+ } catch {
164
+ /* best-effort */
165
+ }
166
+ }
167
+
168
+ /**
169
+ * Merge freshly-fetched latest versions into the cache without erasing
170
+ * `notifiedFor`. Used by the detached fetch worker.
171
+ */
172
+ export function mergeLatest(path, latest, now = Date.now()) {
173
+ const cache = readCache(path) || {};
174
+ cache.checkedAt = now;
175
+ cache.latest = { ...(cache.latest || {}), ...latest };
176
+ cache.notifiedFor = cache.notifiedFor || {};
177
+ writeCacheAtomic(path, cache);
178
+ return cache;
179
+ }
180
+
181
+ /** True if any opt-out env var is set. */
182
+ export function isOptedOut(env = process.env) {
183
+ return Boolean(env.HYPO_NO_UPDATE_CHECK || env.NO_UPDATE_NOTIFIER || env.CI);
184
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hypomnema",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "LLM-native personal wiki system for Claude Code",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -17,7 +17,14 @@
17
17
  ".claude-plugin/",
18
18
  "README.md"
19
19
  ],
20
- "keywords": ["wiki", "llm", "claude", "claude-code", "personal-wiki", "knowledge-management"],
20
+ "keywords": [
21
+ "wiki",
22
+ "llm",
23
+ "claude",
24
+ "claude-code",
25
+ "personal-wiki",
26
+ "knowledge-management"
27
+ ],
21
28
  "repository": {
22
29
  "type": "git",
23
30
  "url": "https://github.com/sk-lim19f/Hypomnema.git"
@@ -32,6 +39,13 @@
32
39
  "scripts": {
33
40
  "test": "node tests/runner.mjs",
34
41
  "lint": "node scripts/lint.mjs",
35
- "graph": "node scripts/graph.mjs"
42
+ "graph": "node scripts/graph.mjs",
43
+ "smoke-pack": "node scripts/smoke-pack.mjs",
44
+ "format": "prettier --write .",
45
+ "format:check": "prettier --check .",
46
+ "prepublishOnly": "npm test && npm run lint && npm run smoke-pack"
47
+ },
48
+ "devDependencies": {
49
+ "prettier": "^3.8.3"
36
50
  }
37
51
  }