hypomnema 1.3.1 → 1.3.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 (46) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +2 -2
  3. package/README.ko.md +10 -10
  4. package/README.md +10 -10
  5. package/commands/crystallize.md +8 -8
  6. package/commands/feedback.md +1 -1
  7. package/commands/resume.md +1 -1
  8. package/docs/ARCHITECTURE.md +3 -3
  9. package/docs/CONTRIBUTING.md +2 -2
  10. package/hooks/hypo-auto-minimal-crystallize.mjs +57 -11
  11. package/hooks/hypo-compact-guard.mjs +1 -1
  12. package/hooks/hypo-cwd-change.mjs +1 -1
  13. package/hooks/hypo-first-prompt.mjs +2 -2
  14. package/hooks/hypo-hot-rebuild.mjs +14 -1
  15. package/hooks/hypo-personal-check.mjs +91 -179
  16. package/hooks/hypo-pre-commit.mjs +1 -1
  17. package/hooks/hypo-session-end.mjs +1 -1
  18. package/hooks/hypo-session-start.mjs +7 -7
  19. package/hooks/hypo-shared.mjs +788 -72
  20. package/hooks/hypo-web-fetch-ingest.mjs +2 -2
  21. package/hooks/version-check-fetch.mjs +2 -8
  22. package/hooks/version-check.mjs +18 -0
  23. package/package.json +3 -2
  24. package/scripts/check-tracker-ids.mjs +329 -0
  25. package/scripts/crystallize.mjs +244 -109
  26. package/scripts/doctor.mjs +6 -9
  27. package/scripts/feedback-sync.mjs +1 -1
  28. package/scripts/init.mjs +1 -1
  29. package/scripts/install-git-hooks.mjs +75 -40
  30. package/scripts/lib/check-tracker-ids.mjs +140 -0
  31. package/scripts/lib/design-history-stale.mjs +59 -14
  32. package/scripts/lib/extensions.mjs +4 -4
  33. package/scripts/lib/fix-manifest.mjs +2 -2
  34. package/scripts/lib/fix-status-verify.mjs +5 -4
  35. package/scripts/lib/plugin-detect.mjs +15 -6
  36. package/scripts/lib/project-create.mjs +1 -1
  37. package/scripts/lint.mjs +63 -8
  38. package/scripts/rename.mjs +373 -0
  39. package/scripts/resume.mjs +75 -19
  40. package/scripts/uninstall.mjs +1 -1
  41. package/scripts/upgrade.mjs +26 -25
  42. package/skills/crystallize/SKILL.md +10 -7
  43. package/templates/SCHEMA.md +2 -2
  44. package/templates/hypo-config.md +2 -2
  45. package/templates/hypo-guide.md +20 -1
  46. package/templates/projects/_template/index.md +1 -1
@@ -1,11 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * hypo-web-fetch-ingest.mjs — PostToolUse hook (fix #2)
3
+ * hypo-web-fetch-ingest.mjs — PostToolUse hook
4
4
  *
5
5
  * When the LLM uses WebFetch or WebSearch, nudge it to follow §8.4 path A:
6
6
  * "external research → /hypo:ingest into sources/". This hook produces a
7
7
  * *signal*, not an automatic ingest call — per spec §5.4.6 Q-5.4.6:
8
- * "WebFetch/WebSearch 자동 ingest 시그널은 별도 hook(fix #2)으로 분리."
8
+ * "WebFetch/WebSearch 자동 ingest 시그널은 별도 hook으로 분리."
9
9
  *
10
10
  * Why signal-only (not direct ingest):
11
11
  * - slug derivation is LLM-driven (commands/ingest.md), so a hook cannot
@@ -14,7 +14,7 @@
14
14
  * Usage: node version-check-fetch.mjs [cachePath]
15
15
  */
16
16
 
17
- import { defaultCachePath, mergeLatest } from './version-check.mjs';
17
+ import { defaultCachePath, mergeLatest, selectPluginVersion } from './version-check.mjs';
18
18
 
19
19
  const NPM_URL = 'https://registry.npmjs.org/hypomnema/latest';
20
20
  const PLUGIN_URL =
@@ -42,13 +42,7 @@ async function fetchNpmLatest() {
42
42
 
43
43
  async function fetchPluginLatest() {
44
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;
45
+ return selectPluginVersion(data && data.plugins);
52
46
  }
53
47
 
54
48
  async function main() {
@@ -138,6 +138,24 @@ export function detectChannel(pkgRoot) {
138
138
  return 'unknown';
139
139
  }
140
140
 
141
+ /**
142
+ * Pick the plugin's published version out of a marketplace.json `plugins` array.
143
+ * Select by name rather than plugins[0]: the file could list more than one
144
+ * plugin or reorder entries. Accept the current name (`hypo`) and the legacy one
145
+ * (`hypomnema`) so a stale-cached or transitional marketplace.json still
146
+ * resolves. Returns the version string or null.
147
+ */
148
+ export function selectPluginVersion(plugins) {
149
+ if (!Array.isArray(plugins)) return null;
150
+ // Prefer the current name over the legacy one so that a transitional
151
+ // marketplace.json listing BOTH aliases resolves to `hypo` regardless of the
152
+ // entries' order (a legacy-first list must not shadow a newer `hypo` entry).
153
+ const entry =
154
+ plugins.find((p) => p && p.name === 'hypo') || plugins.find((p) => p && p.name === 'hypomnema');
155
+ const v = entry && entry.version;
156
+ return typeof v === 'string' ? v : null;
157
+ }
158
+
141
159
  /** Channel-specific one-line update instruction. */
142
160
  export function buildUpdateLine(channel, current, latest) {
143
161
  const head = `[Hypomnema] Update available! ${current} → ${latest}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hypomnema",
3
- "version": "1.3.1",
3
+ "version": "1.3.2",
4
4
  "description": "LLM-native personal wiki system for Claude Code",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -43,10 +43,11 @@
43
43
  "graph": "node scripts/graph.mjs",
44
44
  "smoke-pack": "node scripts/smoke-pack.mjs",
45
45
  "check:bilingual": "node scripts/check-bilingual.mjs --changelog",
46
+ "check:tracker-ids": "node scripts/check-tracker-ids.mjs --all",
46
47
  "format": "prettier --write .",
47
48
  "format:check": "prettier --check .",
48
49
  "prepare": "node scripts/install-git-hooks.mjs",
49
- "prepublishOnly": "npm test && npm run lint && npm run smoke-pack && npm run check:bilingual"
50
+ "prepublishOnly": "npm test && npm run lint && npm run smoke-pack && npm run check:bilingual && npm run check:tracker-ids"
50
51
  },
51
52
  "devDependencies": {
52
53
  "prettier": "^3.8.3"
@@ -0,0 +1,329 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * check-tracker-ids.mjs — CLI gate: no wiki-internal tracker IDs (ISSUE-N,
4
+ * fix #N) in OSS-public artifacts.
5
+ *
6
+ * Rule source: CLAUDE.md learned_behaviors (no-internal-tracker-ids-in-oss-
7
+ * artifacts, 2026-06-09). The repo ships through npm + a Claude Code plugin;
8
+ * a `fix #N` / `ISSUE-N` in any shipped file or in README/CHANGELOG is a
9
+ * dangling pointer into the maintainer's PRIVATE wiki tracker. GitHub refs
10
+ * (`PR #N`, `(#N)`, `#N`, issue URLs) are legitimate and never flagged.
11
+ *
12
+ * Modes:
13
+ * --all (default) Walk the public-artifact scope and scan every text
14
+ * file. Wired into npm `prepublishOnly` and CI.
15
+ * --staged Scan the STAGED blob of every in-scope file in the
16
+ * index (git show :path), mirroring the pre-commit
17
+ * formatter's name-status/-z/diff-filter plumbing so
18
+ * deletes, type-changes, symlinks and submodules are
19
+ * skipped. Used by the pre-commit hook.
20
+ * --commit-msg <file> Scan a commit message. Drops the --verbose scissors
21
+ * diff, then strips comment lines ONLY when git added
22
+ * its template (editor/strip mode); for `commit -m` /
23
+ * whitespace / verbatim (no template) it scans comment
24
+ * lines too, since git keeps them. Used by the
25
+ * commit-msg hook.
26
+ * --json Machine-readable output.
27
+ *
28
+ * Exit: 0 clean · 1 violations · 2 usage error.
29
+ *
30
+ * Scope (public): README.md, README.ko.md, CHANGELOG.md, package.json (npm
31
+ * auto-ships it) + shipped trees commands/ hooks/ scripts/ skills/ templates/
32
+ * docs/ .github/ .claude-plugin/. Excluded: tests/ and qa-runs/ (internal
33
+ * maintainer artifacts — a tracker id in a test description aids traceability
34
+ * and never reaches an installed user), node_modules/, .git/. The checker's OWN
35
+ * sources are scanned (NOT exempt); their examples use `N` placeholders so they
36
+ * stay clean.
37
+ *
38
+ * ADR scope (narrower): README.md, README.ko.md, and docs/ additionally block
39
+ * `ADR NNNN` / `decisions/NNNN` wiki-ADR pointers (USER_FACING_PATTERNS). Shipped
40
+ * code comments and CHANGELOG history keep their ADR anchors, so this set is
41
+ * applied per-file via patternsFor() only inside that doc surface.
42
+ */
43
+
44
+ import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs';
45
+ import { spawnSync } from 'node:child_process';
46
+ import { fileURLToPath } from 'node:url';
47
+ import { dirname, join, relative, sep, extname } from 'node:path';
48
+ import {
49
+ scanText,
50
+ stripScissors,
51
+ messageHasGitTemplate,
52
+ BLOCKED_PATTERNS,
53
+ USER_FACING_PATTERNS,
54
+ } from './lib/check-tracker-ids.mjs';
55
+ import {
56
+ parseNameStatus,
57
+ parseLsFilesStage,
58
+ filterRegularFiles,
59
+ } from './lib/pre-commit-format.mjs';
60
+
61
+ const __dirname = dirname(fileURLToPath(import.meta.url));
62
+ // Root resolves from this script's location (so --staged always gates the real
63
+ // Hypomnema index regardless of cwd). CHECK_TRACKER_ROOT is a TEST-ONLY seam;
64
+ // the installed git hooks `unset CHECK_TRACKER_ROOT` before invoking (see
65
+ // install-git-hooks.mjs gateLines) so an inherited/hostile value cannot redirect
66
+ // the real gate. Read-only either way (no writes), so it only changes what is
67
+ // scanned, never what is written.
68
+ const REPO_ROOT = process.env.CHECK_TRACKER_ROOT || join(__dirname, '..');
69
+
70
+ const RULE_REF =
71
+ 'Rule: CLAUDE.md learned_behaviors (no-internal-tracker-ids-in-oss-artifacts). ' +
72
+ 'OSS-public artifacts must not reference the private wiki tracker (ISSUE-N / fix #N). ' +
73
+ 'Use a GitHub ref (#N / PR #N) or drop the reference.';
74
+
75
+ // Top-level public-artifact entry points. Files are scanned directly; dirs are
76
+ // walked recursively. Anything outside this set is out of scope. package.json is
77
+ // here because npm auto-ships it (it is not in the `files` allowlist but is
78
+ // always packed), so a tracker id in its metadata is a public leak too.
79
+ const SCOPE_FILES = ['README.md', 'README.ko.md', 'CHANGELOG.md', 'package.json'];
80
+ const SCOPE_DIRS = [
81
+ 'commands',
82
+ 'hooks',
83
+ 'scripts',
84
+ 'skills',
85
+ 'templates',
86
+ 'docs',
87
+ '.github',
88
+ '.claude-plugin',
89
+ ];
90
+
91
+ // Never scanned even when under a scope dir. tests/ and qa-runs/ are
92
+ // internal-maintainer artifacts (a tracker id in a test name aids traceability
93
+ // and never reaches an installed user). The checker's OWN sources are NOT
94
+ // excluded — they use `N` placeholders in their examples so they scan clean.
95
+ const EXCLUDED_DIRS = new Set(['node_modules', '.git', 'tests', 'qa-runs']);
96
+ const EXCLUDED_FILES = new Set();
97
+
98
+ // Only text artifacts. Binary / lockfiles add noise and never carry prose refs.
99
+ const TEXT_EXT = new Set(['.md', '.mjs', '.js', '.cjs', '.json', '.yml', '.yaml', '.sh', '.txt']);
100
+
101
+ function isExcludedRel(relPath) {
102
+ const parts = relPath.split(sep);
103
+ if (parts.some((p) => EXCLUDED_DIRS.has(p))) return true;
104
+ if (EXCLUDED_FILES.has(relPath)) return true;
105
+ return false;
106
+ }
107
+
108
+ function isTextFile(p) {
109
+ return TEXT_EXT.has(extname(p).toLowerCase());
110
+ }
111
+
112
+ // Single public-scope predicate shared by --all and --staged so the two modes
113
+ // can never disagree on what counts as a public artifact (a root file like
114
+ // `foo.md` is out of scope for both; `package.json` and scope-dir files are in).
115
+ function isInScope(relPath) {
116
+ if (isExcludedRel(relPath)) return false;
117
+ if (!isTextFile(relPath)) return false;
118
+ if (SCOPE_FILES.includes(relPath)) return true;
119
+ return SCOPE_DIRS.includes(relPath.split(sep)[0]);
120
+ }
121
+
122
+ // User-facing prose surface: README.md, README.ko.md, and the docs/ tree. These
123
+ // additionally block `ADR NNNN` / `decisions/NNNN` (dangling wiki-ADR pointers).
124
+ // Everything else in scope — shipped code, CHANGELOG, package.json — keeps its ADR
125
+ // anchors, so the broader pattern set is applied here ONLY.
126
+ const USER_FACING_DOCS = new Set(['README.md', 'README.ko.md']);
127
+ function isUserFacingDoc(relPath) {
128
+ return USER_FACING_DOCS.has(relPath) || relPath.split(sep)[0] === 'docs';
129
+ }
130
+ function patternsFor(relPath) {
131
+ return isUserFacingDoc(relPath)
132
+ ? [...BLOCKED_PATTERNS, ...USER_FACING_PATTERNS]
133
+ : BLOCKED_PATTERNS;
134
+ }
135
+
136
+ // Recursively collect in-scope text files under an absolute dir.
137
+ function walk(absDir, acc) {
138
+ let entries;
139
+ try {
140
+ entries = readdirSync(absDir, { withFileTypes: true });
141
+ } catch {
142
+ return;
143
+ }
144
+ for (const ent of entries) {
145
+ const abs = join(absDir, ent.name);
146
+ const rel = relative(REPO_ROOT, abs);
147
+ if (isExcludedRel(rel)) continue;
148
+ if (ent.isDirectory()) {
149
+ walk(abs, acc);
150
+ } else if (ent.isFile() && isTextFile(abs)) {
151
+ acc.push(rel);
152
+ }
153
+ }
154
+ }
155
+
156
+ function collectAllScopeFiles() {
157
+ const files = [];
158
+ for (const f of SCOPE_FILES) {
159
+ if (existsSync(join(REPO_ROOT, f))) files.push(f);
160
+ }
161
+ for (const d of SCOPE_DIRS) {
162
+ walk(join(REPO_ROOT, d), files);
163
+ }
164
+ return files;
165
+ }
166
+
167
+ // ── violation reporting ──────────────────────────────────────────────────────
168
+
169
+ function report(violations, json) {
170
+ if (json) {
171
+ process.stdout.write(
172
+ JSON.stringify(
173
+ {
174
+ ok: violations.length === 0,
175
+ count: violations.length,
176
+ violations,
177
+ },
178
+ null,
179
+ 2,
180
+ ) + '\n',
181
+ );
182
+ } else if (violations.length === 0) {
183
+ process.stdout.write('[check-tracker-ids] OK: no wiki-internal tracker ids found.\n');
184
+ } else {
185
+ process.stderr.write(
186
+ `[check-tracker-ids] FAIL: ${violations.length} tracker-id reference(s):\n`,
187
+ );
188
+ for (const v of violations) {
189
+ process.stderr.write(
190
+ ` ${v.file}:${v.line}:${v.col} ${v.match} (${v.label})\n` +
191
+ ` ${v.lineText.trim()}\n`,
192
+ );
193
+ }
194
+ process.stderr.write(`${RULE_REF}\n`);
195
+ }
196
+ return violations.length === 0 ? 0 : 1;
197
+ }
198
+
199
+ // ── git helpers (scoped env not required: read-only, no GIT_DIR attack surface
200
+ // here — the hook shim already pins identity before invoking) ──────────────
201
+
202
+ function git(args, opts = {}) {
203
+ return spawnSync('git', args, { cwd: REPO_ROOT, encoding: 'utf-8', ...opts });
204
+ }
205
+
206
+ // ── modes ────────────────────────────────────────────────────────────────────
207
+
208
+ function runAll(json) {
209
+ const files = collectAllScopeFiles();
210
+ const violations = [];
211
+ for (const rel of files) {
212
+ let text;
213
+ try {
214
+ text = readFileSync(join(REPO_ROOT, rel), 'utf-8');
215
+ } catch {
216
+ continue;
217
+ }
218
+ for (const h of scanText(text, patternsFor(rel))) violations.push({ file: rel, ...h });
219
+ }
220
+ process.exit(report(violations, json));
221
+ }
222
+
223
+ function runStaged(json) {
224
+ const diff = git(['diff', '--cached', '--name-status', '-z', '--diff-filter=ACMR', '--']);
225
+ if (diff.status !== 0) {
226
+ // Can't read the index → fail OPEN (don't block a commit on a git probe
227
+ // failure); the --all CI gate is the hard backstop.
228
+ process.stdout.write(
229
+ '[check-tracker-ids] staged: git diff failed; skipping (CI is the backstop).\n',
230
+ );
231
+ process.exit(0);
232
+ }
233
+ const staged = parseNameStatus(diff.stdout || '');
234
+ // Restrict to in-scope public artifacts (same predicate --all uses).
235
+ const inScope = staged.filter((e) => isInScope(e.path));
236
+ if (!inScope.length) {
237
+ if (json) report([], json);
238
+ process.exit(0);
239
+ }
240
+ // Drop symlinks / submodules via the staged mode bits.
241
+ const ls = git(['ls-files', '--stage', '-z', '--', ...inScope.map((e) => e.path)]);
242
+ let regular = inScope;
243
+ if (ls.status === 0) {
244
+ regular = filterRegularFiles(inScope, parseLsFilesStage(ls.stdout || ''));
245
+ }
246
+ const violations = [];
247
+ for (const e of regular) {
248
+ // Scan the STAGED blob, not the working tree — only what is actually being
249
+ // committed is gated (partial stage safety).
250
+ const show = git(['show', `:${e.path}`]);
251
+ if (show.status !== 0) continue;
252
+ for (const h of scanText(show.stdout || '', patternsFor(e.path)))
253
+ violations.push({ file: e.path, ...h });
254
+ }
255
+ process.exit(report(violations, json));
256
+ }
257
+
258
+ function runCommitMsg(file, json) {
259
+ let raw;
260
+ try {
261
+ raw = readFileSync(file, 'utf-8');
262
+ } catch (err) {
263
+ process.stderr.write(
264
+ `[check-tracker-ids] cannot read commit-msg file ${file}: ${err.message}\n`,
265
+ );
266
+ process.exit(2);
267
+ }
268
+ // Decide comment/scissors handling by whether git added its editor template
269
+ // (NOT by config, which the hook can't fully resolve). Two branches:
270
+ //
271
+ // template present (editor commit) → git honors the --verbose scissors AND,
272
+ // in the default cleanup, strips `#` comment lines. Replicate: cut the
273
+ // scissors diff, then `stripspace --strip-comments`. This matches what git
274
+ // records and avoids a false-positive on the template's own branch/file
275
+ // names (e.g. a ticket-numbered branch in `# On branch ...`).
276
+ //
277
+ // template absent (`git commit -m` / `-F` / whitespace / verbatim) → git
278
+ // keeps `#` lines and does NOT treat a bare `>8` line as scissors, so scan
279
+ // the message VERBATIM. This is what closes both -m gaps (a `#`-prefixed
280
+ // tracker id, and content after a user-written `>8` line).
281
+ //
282
+ // KNOWN LIMITATION (documented, exotic): an EDITOR commit run with an explicit
283
+ // non-default `--cleanup=whitespace`/`verbatim` does carry a template yet keeps
284
+ // `#` lines, so a tracker id sitting in a comment line there is not caught.
285
+ // This needs a non-default flag AND a `#`-prefixed id; the prose path (the real
286
+ // risk) is always caught, and the file gate (--all/--staged/CI) is the hard
287
+ // guarantee. commit-msg is best-effort.
288
+ let text;
289
+ if (messageHasGitTemplate(raw)) {
290
+ const noScissors = stripScissors(raw);
291
+ const strip = git(['stripspace', '--strip-comments'], { input: noScissors });
292
+ text = strip.status === 0 ? strip.stdout || '' : noScissors;
293
+ } else {
294
+ text = raw;
295
+ }
296
+ const violations = scanText(text).map((h) => ({ file: relative(REPO_ROOT, file) || file, ...h }));
297
+ process.exit(report(violations, json));
298
+ }
299
+
300
+ // ── arg parsing ────────────────────────────────────────────────────────────
301
+
302
+ function usage(code) {
303
+ process.stdout.write(
304
+ 'Usage:\n' +
305
+ ' node scripts/check-tracker-ids.mjs [--all] [--json]\n' +
306
+ ' node scripts/check-tracker-ids.mjs --staged [--json]\n' +
307
+ ' node scripts/check-tracker-ids.mjs --commit-msg <file> [--json]\n',
308
+ );
309
+ process.exit(code);
310
+ }
311
+
312
+ const argv = process.argv.slice(2);
313
+ if (argv.includes('--help') || argv.includes('-h')) usage(0);
314
+ const json = argv.includes('--json');
315
+
316
+ if (argv.includes('--commit-msg')) {
317
+ const i = argv.indexOf('--commit-msg');
318
+ const file = argv[i + 1];
319
+ if (!file || file.startsWith('--')) {
320
+ process.stderr.write('[check-tracker-ids] --commit-msg requires a file path\n');
321
+ usage(2);
322
+ }
323
+ runCommitMsg(file, json);
324
+ } else if (argv.includes('--staged')) {
325
+ runStaged(json);
326
+ } else {
327
+ // default + explicit --all
328
+ runAll(json);
329
+ }