hypomnema 1.0.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 (79) hide show
  1. package/.claude-plugin/plugin.json +11 -0
  2. package/LICENSE +21 -0
  3. package/README.ko.md +160 -0
  4. package/README.md +160 -0
  5. package/commands/.gitkeep +0 -0
  6. package/commands/crystallize.md +116 -0
  7. package/commands/doctor.md +66 -0
  8. package/commands/feedback.md +67 -0
  9. package/commands/graph.md +54 -0
  10. package/commands/ingest.md +85 -0
  11. package/commands/init.md +101 -0
  12. package/commands/lint.md +55 -0
  13. package/commands/query.md +55 -0
  14. package/commands/resume.md +48 -0
  15. package/commands/stats.md +39 -0
  16. package/commands/uninstall.md +52 -0
  17. package/commands/upgrade.md +63 -0
  18. package/commands/verify.md +60 -0
  19. package/docs/.gitkeep +0 -0
  20. package/docs/ARCHITECTURE.md +183 -0
  21. package/docs/CONTRIBUTING.md +115 -0
  22. package/docs/TEST-CASES.md +580 -0
  23. package/hooks/.gitkeep +0 -0
  24. package/hooks/hooks.json +109 -0
  25. package/hooks/hypo-auto-commit.mjs +36 -0
  26. package/hooks/hypo-auto-stage.mjs +30 -0
  27. package/hooks/hypo-compact-guard.mjs +71 -0
  28. package/hooks/hypo-cwd-change.mjs +91 -0
  29. package/hooks/hypo-file-watch.mjs +47 -0
  30. package/hooks/hypo-first-prompt.mjs +59 -0
  31. package/hooks/hypo-hot-rebuild.mjs +95 -0
  32. package/hooks/hypo-lookup.mjs +178 -0
  33. package/hooks/hypo-personal-check.mjs +195 -0
  34. package/hooks/hypo-session-start.mjs +141 -0
  35. package/hooks/hypo-shared.mjs +213 -0
  36. package/package.json +37 -0
  37. package/scripts/.gitkeep +0 -0
  38. package/scripts/bump-version.mjs +53 -0
  39. package/scripts/crystallize.mjs +153 -0
  40. package/scripts/doctor.mjs +361 -0
  41. package/scripts/feedback.mjs +130 -0
  42. package/scripts/graph.mjs +183 -0
  43. package/scripts/ingest.mjs +130 -0
  44. package/scripts/init.mjs +515 -0
  45. package/scripts/lib/frontmatter.mjs +11 -0
  46. package/scripts/lib/hypo-ignore.mjs +54 -0
  47. package/scripts/lib/hypo-root.mjs +53 -0
  48. package/scripts/lint.mjs +210 -0
  49. package/scripts/query.mjs +124 -0
  50. package/scripts/resume.mjs +115 -0
  51. package/scripts/stats.mjs +132 -0
  52. package/scripts/uninstall.mjs +188 -0
  53. package/scripts/upgrade.mjs +538 -0
  54. package/scripts/verify.mjs +172 -0
  55. package/skills/.gitkeep +0 -0
  56. package/skills/crystallize/SKILL.md +85 -0
  57. package/skills/graph/SKILL.md +54 -0
  58. package/skills/ingest/SKILL.md +83 -0
  59. package/skills/lint/SKILL.md +55 -0
  60. package/skills/query/SKILL.md +58 -0
  61. package/skills/verify/SKILL.md +92 -0
  62. package/templates/.gitkeep +0 -0
  63. package/templates/.hypoignore +18 -0
  64. package/templates/Home.md +34 -0
  65. package/templates/Overview.md +50 -0
  66. package/templates/SCHEMA.md +106 -0
  67. package/templates/hot.md +22 -0
  68. package/templates/hypo-automation.md +69 -0
  69. package/templates/hypo-config.md +41 -0
  70. package/templates/hypo-guide.md +146 -0
  71. package/templates/hypo-help.md +53 -0
  72. package/templates/index.md +44 -0
  73. package/templates/log.md +25 -0
  74. package/templates/pages/_index.md +61 -0
  75. package/templates/projects/_template/hot.md +28 -0
  76. package/templates/projects/_template/index.md +39 -0
  77. package/templates/projects/_template/prd.md +29 -0
  78. package/templates/projects/_template/session-state.md +9 -0
  79. package/templates/session-state.md +12 -0
@@ -0,0 +1,515 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Hypomnema init script
4
+ *
5
+ * Sets up a new wiki directory, installs hooks, and merges settings.json.
6
+ * Called by /hypo:init after collecting wizard answers.
7
+ *
8
+ * Usage:
9
+ * node scripts/init.mjs [options]
10
+ *
11
+ * Options:
12
+ * --hypo-dir=<path> Hypomnema root directory (default: resolves via HYPO_DIR env / hypo-config.md scan / ~/hypomnema)
13
+ * --no-hooks Skip hook installation
14
+ * --codex Also install Codex hooks (~/.codex/hooks/)
15
+ * --git-remote=<url> Git remote URL
16
+ * --no-git-init Skip git initialization
17
+ * --from-remote=<url> Clone existing Hypomnema wiki from remote and install hooks
18
+ * --dry-run Show what would be done without making changes
19
+ * --help, -h Show this help message
20
+ */
21
+
22
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, copyFileSync, readdirSync } from 'fs';
23
+ import { join, basename } from 'path';
24
+ import { homedir } from 'os';
25
+ import { execSync, spawnSync } from 'child_process';
26
+ import { fileURLToPath } from 'url';
27
+ import { expandHome, resolveHypoRoot } from './lib/hypo-root.mjs';
28
+
29
+ const HOME = homedir();
30
+ const SCRIPT_DIR = fileURLToPath(new URL('.', import.meta.url));
31
+ const PKG_ROOT = join(SCRIPT_DIR, '..');
32
+ const HOOKS_SRC = join(PKG_ROOT, 'hooks');
33
+ const TEMPLATES = join(PKG_ROOT, 'templates');
34
+
35
+ // ── arg parsing ──────────────────────────────────────────────────────────────
36
+
37
+ function parseArgs(argv) {
38
+ const args = {
39
+ hypoDir: resolveHypoRoot(),
40
+ hooks: true,
41
+ codex: false,
42
+ gitRemote: null,
43
+ gitInit: true,
44
+ dryRun: false,
45
+ fromRemote: null,
46
+ shellSetup: true,
47
+ shellConfig: null,
48
+ };
49
+ for (const arg of argv.slice(2)) {
50
+ if (arg === '--help' || arg === '-h') {
51
+ console.log(`Usage: node scripts/init.mjs [options]
52
+
53
+ Options:
54
+ --hypo-dir=<path> Hypomnema root directory (default: resolves via HYPO_DIR env / hypo-config.md scan / ~/hypomnema)
55
+ --no-hooks Skip hook installation
56
+ --codex Also install Codex hooks (~/.codex/hooks/)
57
+ --git-remote=<url> Git remote URL
58
+ --no-git-init Skip git initialization
59
+ --from-remote=<url> Clone existing Hypomnema wiki from remote and install hooks
60
+ --no-shell Skip shell function setup (~/.zshrc / ~/.bashrc)
61
+ --shell-config=<path> Shell config file path (default: auto-detect)
62
+ --dry-run Show what would be done without making changes
63
+ --help, -h Show this help message`);
64
+ process.exit(0);
65
+ }
66
+ else if (arg.startsWith('--hypo-dir=')) args.hypoDir = expandHome(arg.slice(11));
67
+ else if (arg === '--no-hooks') args.hooks = false;
68
+ else if (arg === '--codex') args.codex = true;
69
+ else if (arg.startsWith('--git-remote=')) args.gitRemote = arg.slice(13);
70
+ else if (arg === '--no-git-init') args.gitInit = false;
71
+ else if (arg.startsWith('--from-remote=')) {
72
+ const url = arg.slice(14).trim();
73
+ if (!url) { console.error('Error: --from-remote requires a non-empty URL'); process.exit(1); }
74
+ args.fromRemote = url;
75
+ }
76
+ else if (arg === '--dry-run') args.dryRun = true;
77
+ else if (arg === '--no-shell') args.shellSetup = false;
78
+ else if (arg.startsWith('--shell-config=')) args.shellConfig = expandHome(arg.slice(15));
79
+ }
80
+ return args;
81
+ }
82
+
83
+ // ── result tracking ──────────────────────────────────────────────────────────
84
+
85
+ const results = { created: [], skipped: [], merged: [], errors: [] };
86
+
87
+ function log(action, path) { results[action].push(path); }
88
+
89
+ // ── directory structure ──────────────────────────────────────────────────────
90
+
91
+ const HYPO_DIRS = ['pages', 'projects', 'sources', 'journal/daily', 'journal/weekly', 'journal/monthly'];
92
+
93
+ function ensureDir(dir, dryRun) {
94
+ if (existsSync(dir)) return;
95
+ if (!dryRun) mkdirSync(dir, { recursive: true });
96
+ log('created', dir);
97
+ }
98
+
99
+ // ── template copy ────────────────────────────────────────────────────────────
100
+
101
+ function copyTemplate(srcName, destPath, dryRun, transform) {
102
+ const src = join(TEMPLATES, srcName);
103
+ if (!existsSync(src)) { log('errors', `template missing: ${srcName}`); return; }
104
+ if (existsSync(destPath)) { log('skipped', destPath); return; }
105
+ if (!dryRun) {
106
+ let content = readFileSync(src, 'utf-8');
107
+ content = content.replace(/YYYY-MM-DD/g, new Date().toISOString().slice(0, 10));
108
+ if (transform) content = transform(content);
109
+ writeFileSync(destPath, content);
110
+ }
111
+ log('created', destPath);
112
+ }
113
+
114
+ // ── hypo-config.md ───────────────────────────────────────────────────────────
115
+
116
+ function writeHypoConfig(hypoDir, dryRun) {
117
+ const dest = join(hypoDir, 'hypo-config.md');
118
+ if (existsSync(dest)) { log('skipped', dest); return; }
119
+ const today = new Date().toISOString().slice(0, 10);
120
+ const src = join(TEMPLATES, 'hypo-config.md');
121
+ const base = existsSync(src) ? readFileSync(src, 'utf-8') : '';
122
+ const content = base.replace(/YYYY-MM-DD/g, today);
123
+ if (!dryRun) writeFileSync(dest, content);
124
+ log('created', dest);
125
+ }
126
+
127
+ // ── .hypoignore ──────────────────────────────────────────────────────────────
128
+
129
+ function writeWikiignore(hypoDir, dryRun) {
130
+ const dest = join(hypoDir, '.hypoignore');
131
+ if (existsSync(dest)) { log('skipped', dest); return; }
132
+ const src = join(TEMPLATES, '.hypoignore');
133
+ const content = existsSync(src) ? readFileSync(src, 'utf-8') : '';
134
+ if (!dryRun) writeFileSync(dest, content);
135
+ log('created', dest);
136
+ }
137
+
138
+ // ── hook installation ────────────────────────────────────────────────────────
139
+
140
+ function loadHookMap() {
141
+ let cfg;
142
+ try {
143
+ cfg = JSON.parse(readFileSync(join(PKG_ROOT, 'hooks', 'hooks.json'), 'utf-8'));
144
+ } catch {
145
+ console.error(`Error: cannot read hooks/hooks.json from package root: ${PKG_ROOT}`);
146
+ process.exit(1);
147
+ }
148
+ if (!cfg || typeof cfg !== 'object' || Array.isArray(cfg)) {
149
+ console.error('Error: hooks/hooks.json must be a JSON object');
150
+ process.exit(1);
151
+ }
152
+ if (!cfg.hooks || typeof cfg.hooks !== 'object' || Array.isArray(cfg.hooks)) {
153
+ console.error('Error: hooks/hooks.json must contain a "hooks" object');
154
+ process.exit(1);
155
+ }
156
+ function _extractCommandFileName(command) {
157
+ if (typeof command !== 'string') return null;
158
+ const matches = [...command.matchAll(/(?:^|[\/\\])([^\/\\\s"'`]+\.mjs)(?=$|[\s"'`])/g)];
159
+ if (matches.length > 0) return matches[matches.length - 1][1];
160
+ const bare = command.match(/(?:^|\s)([^\/\\\s"'`]+\.mjs)(?=$|[\s"'`])/);
161
+ return bare ? bare[1] : null;
162
+ }
163
+
164
+ function _isHookFileName(file) {
165
+ return typeof file === 'string' && /^[^/\\\s]+\.mjs$/.test(file.trim());
166
+ }
167
+
168
+ function _isHookGroup(group) {
169
+ return group &&
170
+ typeof group === 'object' &&
171
+ !Array.isArray(group) &&
172
+ Array.isArray(group.hooks) &&
173
+ group.hooks.length > 0 &&
174
+ group.hooks.every(hook =>
175
+ hook &&
176
+ typeof hook === 'object' &&
177
+ !Array.isArray(hook) &&
178
+ hook.type === 'command' &&
179
+ _extractCommandFileName(hook.command)
180
+ );
181
+ }
182
+
183
+ // Extract .mjs file names from both old format (string[]) and new format (hook-group object[])
184
+ function _extractFileNames(groups) {
185
+ return groups.flatMap(group => {
186
+ if (typeof group === 'string') return [group.trim()];
187
+ return group.hooks.map(hook => _extractCommandFileName(hook.command));
188
+ });
189
+ }
190
+
191
+ for (const [event, groups] of Object.entries(cfg.hooks)) {
192
+ const valid = Array.isArray(groups) &&
193
+ groups.length > 0 &&
194
+ groups.every(group => _isHookFileName(group) || _isHookGroup(group)) &&
195
+ _extractFileNames(groups).length > 0;
196
+ if (!valid) {
197
+ console.error(`Error: hooks/hooks.json "hooks.${event}" must be a non-empty array of .mjs file names or Claude hook groups`);
198
+ process.exit(1);
199
+ }
200
+ }
201
+ if (cfg.shared !== undefined && (!Array.isArray(cfg.shared) || !cfg.shared.every(f => _isHookFileName(f)))) {
202
+ console.error('Error: hooks/hooks.json "shared" must be an array of .mjs file names');
203
+ process.exit(1);
204
+ }
205
+ return Object.fromEntries(Object.entries(cfg.hooks).map(([event, groups]) => [event, _extractFileNames(groups)]));
206
+ }
207
+
208
+ function installHooks(targetDir, dryRun) {
209
+ if (!existsSync(HOOKS_SRC)) { log('errors', `hooks source missing: ${HOOKS_SRC}`); return; }
210
+ if (!dryRun) mkdirSync(targetDir, { recursive: true });
211
+ for (const file of readdirSync(HOOKS_SRC)) {
212
+ if (!file.endsWith('.mjs')) continue;
213
+ const dest = join(targetDir, file);
214
+ if (existsSync(dest)) { log('skipped', dest); continue; }
215
+ if (!dryRun) copyFileSync(join(HOOKS_SRC, file), dest);
216
+ log('created', dest);
217
+ }
218
+ }
219
+
220
+ function mergeSettingsJson(settingsPath, hooksDir, dryRun, hookMap) {
221
+ let settings = {};
222
+ if (existsSync(settingsPath)) {
223
+ try { settings = JSON.parse(readFileSync(settingsPath, 'utf-8')); } catch {
224
+ log('errors', `settings.json is not valid JSON — fix or back it up before re-running: ${settingsPath}`);
225
+ return;
226
+ }
227
+ }
228
+ if (!settings.hooks) settings.hooks = {};
229
+
230
+ let changed = false;
231
+ for (const [event, files] of Object.entries(hookMap)) {
232
+ if (!Array.isArray(settings.hooks[event])) settings.hooks[event] = [];
233
+ for (const file of files) {
234
+ const cmd = `node ${hooksDir.replace(HOME, '$HOME')}/${file}`;
235
+ const already = settings.hooks[event]
236
+ .flatMap(g => g.hooks || [])
237
+ .some(h => h.command === cmd);
238
+ if (already) continue;
239
+ settings.hooks[event].push({ hooks: [{ type: 'command', command: cmd }] });
240
+ log('merged', `${event}: ${file}`);
241
+ changed = true;
242
+ }
243
+ }
244
+
245
+ if (changed && !dryRun) {
246
+ mkdirSync(join(settingsPath, '..'), { recursive: true });
247
+ writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
248
+ }
249
+ }
250
+
251
+ // ── pkg git hook ────────────────────────────────────────────────────────────
252
+
253
+ const PKG_GIT_HOOK_CONTENT = `#!/bin/sh
254
+ # Auto-sync hooks/ to ~/.claude/hooks/ when hook files change.
255
+ REPO_ROOT="$(git rev-parse --show-toplevel)"
256
+ changed=$(git diff --name-only HEAD~1 HEAD 2>/dev/null | grep '^hooks/')
257
+ if [ -z "$changed" ]; then
258
+ exit 0
259
+ fi
260
+ echo "[post-commit] hooks/ changed — syncing to ~/.claude/hooks/ ..."
261
+ node "$REPO_ROOT/scripts/upgrade.mjs" --apply
262
+ `;
263
+
264
+ function writePkgJson(dryRun) {
265
+ const dest = join(HOME, '.claude', 'hypo-pkg.json');
266
+ if (!dryRun) {
267
+ mkdirSync(join(HOME, '.claude'), { recursive: true });
268
+ writeFileSync(dest, JSON.stringify({ pkgRoot: PKG_ROOT }, null, 2) + '\n');
269
+ log('created', dest);
270
+ }
271
+ }
272
+
273
+ function installPkgGitHook(dryRun) {
274
+ const gitDir = join(PKG_ROOT, '.git');
275
+ if (!existsSync(gitDir)) return;
276
+ const hooksDir = join(gitDir, 'hooks');
277
+ const hookPath = join(hooksDir, 'post-commit');
278
+ if (existsSync(hookPath)) { log('skipped', hookPath); return; }
279
+ if (!dryRun) {
280
+ mkdirSync(hooksDir, { recursive: true });
281
+ writeFileSync(hookPath, PKG_GIT_HOOK_CONTENT, { mode: 0o755 });
282
+ }
283
+ log('created', hookPath);
284
+ }
285
+
286
+ // ── shell function setup ─────────────────────────────────────────────────────
287
+
288
+ const SHELL_MARKER_START = '# hypo-managed:shell-setup:start';
289
+ const SHELL_MARKER_END = '# hypo-managed:shell-setup:end';
290
+
291
+ function shellFunctionBlock() {
292
+ return `${SHELL_MARKER_START}
293
+ function claude() {
294
+ echo "{\\"cwd\\":\\"$(pwd)\\"}" | node "$HOME/.claude/hooks/hypo-session-start.mjs" > /dev/null 2>&1
295
+ command claude "$@"
296
+ }
297
+ ${SHELL_MARKER_END}`;
298
+ }
299
+
300
+ function detectShellConfig(customPath) {
301
+ if (customPath) return customPath;
302
+ const shell = process.env.SHELL || '';
303
+ if (shell.includes('zsh')) return join(HOME, '.zshrc');
304
+ if (shell.includes('bash')) return join(HOME, '.bashrc');
305
+ // fallback: prefer .zshrc if it exists, else .bashrc
306
+ const zshrc = join(HOME, '.zshrc');
307
+ return existsSync(zshrc) ? zshrc : join(HOME, '.bashrc');
308
+ }
309
+
310
+ function installShellFunction(shellConfigPath, dryRun) {
311
+ const block = shellFunctionBlock();
312
+
313
+ if (!existsSync(shellConfigPath)) {
314
+ if (!dryRun) writeFileSync(shellConfigPath, block + '\n');
315
+ log('created', `${shellConfigPath} (shell function)`);
316
+ return;
317
+ }
318
+
319
+ const content = readFileSync(shellConfigPath, 'utf-8');
320
+ const startIdx = content.indexOf(SHELL_MARKER_START);
321
+ const endIdx = content.indexOf(SHELL_MARKER_END);
322
+
323
+ if (startIdx !== -1 && endIdx !== -1) {
324
+ // Block exists — check if already up to date
325
+ const existing = content.slice(startIdx, endIdx + SHELL_MARKER_END.length);
326
+ if (existing === block) { log('skipped', `${shellConfigPath} (shell function up to date)`); return; }
327
+ // Replace stale block
328
+ const updated = content.slice(0, startIdx) + block + content.slice(endIdx + SHELL_MARKER_END.length);
329
+ if (!dryRun) writeFileSync(shellConfigPath, updated);
330
+ log('merged', `${shellConfigPath} (shell function updated)`);
331
+ return;
332
+ }
333
+
334
+ // No block — remove any legacy wiki-session-start function first, then append
335
+ const legacyPattern = /\n?# Wiki session context[^\n]*\nfunction claude\(\) \{[\s\S]+?\n\}\n?/g;
336
+ const cleaned = content.replace(legacyPattern, '\n');
337
+ const appended = cleaned.trimEnd() + '\n\n' + block + '\n';
338
+ if (!dryRun) writeFileSync(shellConfigPath, appended);
339
+ log('created', `${shellConfigPath} (shell function)`);
340
+ }
341
+
342
+ // ── from-remote clone ────────────────────────────────────────────────────────
343
+
344
+ function cloneFromRemote(url, hypoDir, dryRun) {
345
+ if (existsSync(hypoDir)) {
346
+ log('errors', `--from-remote: target directory already exists: ${hypoDir}. Remove it or choose a different --hypo-dir.`);
347
+ return false;
348
+ }
349
+ console.log(`Cloning ${url} → ${hypoDir} ...`);
350
+ if (!dryRun) {
351
+ const r = spawnSync('git', ['clone', url, hypoDir], { stdio: 'inherit' });
352
+ if (r.error || r.status !== 0) {
353
+ log('errors', `git clone failed: ${url}`);
354
+ return false;
355
+ }
356
+ if (!existsSync(join(hypoDir, 'hypo-config.md'))) {
357
+ spawnSync('rm', ['-rf', hypoDir]);
358
+ log('errors', `--from-remote: cloned repo is not a Hypomnema wiki (hypo-config.md missing). Removed ${hypoDir}.`);
359
+ return false;
360
+ }
361
+ }
362
+ log('created', hypoDir);
363
+ return true;
364
+ }
365
+
366
+ // ── git setup ────────────────────────────────────────────────────────────────
367
+
368
+ function git(hypoDir, args, opts = {}) {
369
+ return spawnSync('git', ['-C', hypoDir, ...args], { encoding: 'utf-8', ...opts });
370
+ }
371
+
372
+ function gitSetup(hypoDir, remote, dryRun) {
373
+ const isGit = existsSync(join(hypoDir, '.git'));
374
+ if (!isGit) {
375
+ if (!dryRun) {
376
+ const r = spawnSync('git', ['init', hypoDir], { stdio: 'ignore' });
377
+ if (r.error || r.status !== 0) {
378
+ log('errors', `git init failed in ${hypoDir}`);
379
+ return;
380
+ }
381
+ }
382
+ log('created', join(hypoDir, '.git'));
383
+ }
384
+ if (remote) {
385
+ const existing = git(hypoDir, ['remote', 'get-url', 'origin']);
386
+ if (existing.status === 0) {
387
+ const url = existing.stdout.trim();
388
+ if (url !== remote) log('skipped', `remote origin already set to ${url}`);
389
+ } else {
390
+ if (!dryRun) git(hypoDir, ['remote', 'add', 'origin', remote], { stdio: 'ignore' });
391
+ log('merged', `git remote origin → ${remote}`);
392
+ }
393
+ }
394
+ }
395
+
396
+ // ── first commit + push ──────────────────────────────────────────────────────
397
+
398
+ function firstCommit(hypoDir, remote, dryRun) {
399
+ const logR = git(hypoDir, ['log', '--oneline', '-1']);
400
+ if (logR.status === 0 && logR.stdout.trim()) {
401
+ log('skipped', 'first commit (repo already has commits)');
402
+ return;
403
+ }
404
+ const today = new Date().toISOString().slice(0, 10);
405
+ if (!dryRun) {
406
+ git(hypoDir, ['add', '-A'], { stdio: 'ignore' });
407
+ const commitR = git(hypoDir, ['commit', '-m', `chore: init hypomnema (${today})`]);
408
+ if (commitR.status !== 0) { log('errors', 'first commit failed'); return; }
409
+ if (remote) {
410
+ const actualOrigin = git(hypoDir, ['remote', 'get-url', 'origin']);
411
+ const pushTarget = actualOrigin.status === 0 ? actualOrigin.stdout.trim() : remote;
412
+ const pushR = git(hypoDir, ['push', '-u', 'origin', 'HEAD']);
413
+ if (pushR.status !== 0) log('errors', `git push failed: ${(pushR.stderr || '').trim()}`);
414
+ else log('merged', `pushed to ${pushTarget}`);
415
+ }
416
+ }
417
+ log('created', `first commit (${today})`);
418
+ }
419
+
420
+ // ── main ─────────────────────────────────────────────────────────────────────
421
+
422
+ const args = parseArgs(process.argv);
423
+
424
+ // Validate hooks.json before any file writes so a bad package leaves no partial state
425
+ const HOOK_MAP = (args.hooks || args.codex) ? loadHookMap() : null;
426
+
427
+ if (args.fromRemote) {
428
+ // ── from-remote path: clone → read config → install hooks ──────────────────
429
+ const cloned = cloneFromRemote(args.fromRemote, args.hypoDir, args.dryRun);
430
+ if (!cloned) {
431
+ console.error(results.errors.join('\n'));
432
+ process.exit(1);
433
+ }
434
+ } else {
435
+ // ── normal path: create structure + templates ───────────────────────────────
436
+ // 1. wiki directory structure
437
+ ensureDir(args.hypoDir, args.dryRun);
438
+ for (const d of HYPO_DIRS) ensureDir(join(args.hypoDir, d), args.dryRun);
439
+
440
+ // 2. template files
441
+ copyTemplate('index.md', join(args.hypoDir, 'index.md'), args.dryRun);
442
+ copyTemplate('hot.md', join(args.hypoDir, 'hot.md'), args.dryRun);
443
+ copyTemplate('log.md', join(args.hypoDir, 'log.md'), args.dryRun);
444
+ copyTemplate('SCHEMA.md', join(args.hypoDir, 'SCHEMA.md'), args.dryRun);
445
+ copyTemplate('hypo-guide.md', join(args.hypoDir, 'hypo-guide.md'), args.dryRun);
446
+ copyTemplate('Home.md', join(args.hypoDir, 'Home.md'), args.dryRun);
447
+ copyTemplate('Overview.md', join(args.hypoDir, 'Overview.md'), args.dryRun);
448
+ copyTemplate('hypo-help.md', join(args.hypoDir, 'hypo-help.md'), args.dryRun);
449
+ copyTemplate('hypo-automation.md',join(args.hypoDir, 'hypo-automation.md'),args.dryRun);
450
+ copyTemplate('session-state.md', join(args.hypoDir, 'session-state.md'), args.dryRun);
451
+ copyTemplate(join('pages', '_index.md'), join(args.hypoDir, 'pages', '_index.md'), args.dryRun);
452
+
453
+ // projects/_template structure
454
+ ensureDir(join(args.hypoDir, 'projects', '_template'), args.dryRun);
455
+ ensureDir(join(args.hypoDir, 'projects', '_template', 'decisions'), args.dryRun);
456
+ ensureDir(join(args.hypoDir, 'projects', '_template', 'session-log'), args.dryRun);
457
+ copyTemplate(join('projects', '_template', 'hot.md'), join(args.hypoDir, 'projects', '_template', 'hot.md'), args.dryRun);
458
+ copyTemplate(join('projects', '_template', 'index.md'), join(args.hypoDir, 'projects', '_template', 'index.md'), args.dryRun);
459
+ copyTemplate(join('projects', '_template', 'prd.md'), join(args.hypoDir, 'projects', '_template', 'prd.md'), args.dryRun);
460
+ copyTemplate(join('projects', '_template', 'session-state.md'),join(args.hypoDir, 'projects', '_template', 'session-state.md'),args.dryRun);
461
+
462
+ // 3. hypo-config.md + .hypoignore
463
+ writeHypoConfig(args.hypoDir, args.dryRun);
464
+ writeWikiignore(args.hypoDir, args.dryRun);
465
+ }
466
+
467
+ // 4. hooks
468
+
469
+ if (args.hooks) {
470
+ const claudeHooks = join(HOME, '.claude', 'hooks');
471
+ installHooks(claudeHooks, args.dryRun);
472
+ mergeSettingsJson(join(HOME, '.claude', 'settings.json'), claudeHooks, args.dryRun, HOOK_MAP);
473
+ writePkgJson(args.dryRun);
474
+ }
475
+
476
+ // 5. shell function (claude wrapper)
477
+ if (args.shellSetup) {
478
+ const shellConfigPath = detectShellConfig(args.shellConfig);
479
+ installShellFunction(shellConfigPath, args.dryRun);
480
+ }
481
+
482
+ // 6. codex hooks (optional)
483
+ if (args.codex) {
484
+ const codexHooks = join(HOME, '.codex', 'hooks');
485
+ installHooks(codexHooks, args.dryRun);
486
+ mergeSettingsJson(join(HOME, '.codex', 'settings.json'), codexHooks, args.dryRun, HOOK_MAP);
487
+ }
488
+
489
+ // 7. git setup (skip when cloned from remote — already has .git + remote)
490
+ if (args.gitInit && !args.fromRemote) {
491
+ gitSetup(args.hypoDir, args.gitRemote, args.dryRun);
492
+ }
493
+
494
+ // 8. pkg repo git hook (auto-sync hooks/ → ~/.claude/hooks/ on commit)
495
+ if (args.hooks) {
496
+ installPkgGitHook(args.dryRun);
497
+ }
498
+
499
+ // 9. first commit + push (skip when cloned from remote — already has commits)
500
+ if (args.gitInit && !args.fromRemote) {
501
+ firstCommit(args.hypoDir, args.gitRemote, args.dryRun);
502
+ }
503
+
504
+ // ── report ───────────────────────────────────────────────────────────────────
505
+
506
+ const lines = [];
507
+ if (results.created.length) lines.push(`✓ Created (${results.created.length}):\n${results.created.map(p => ` ${p}`).join('\n')}`);
508
+ if (results.skipped.length) lines.push(`⊘ Skipped / already exists (${results.skipped.length}):\n${results.skipped.map(p => ` ${p}`).join('\n')}`);
509
+ if (results.merged.length) lines.push(`↪ Merged into settings.json:\n${results.merged.map(p => ` ${p}`).join('\n')}`);
510
+ if (results.errors.length) lines.push(`✗ Errors:\n${results.errors.map(p => ` ${p}`).join('\n')}`);
511
+
512
+ if (args.dryRun) lines.unshift('[DRY RUN — no changes made]');
513
+
514
+ console.log(lines.join('\n\n'));
515
+ if (results.errors.length) process.exit(1);
@@ -0,0 +1,11 @@
1
+ export function parseFrontmatter(content) {
2
+ const m = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
3
+ if (!m) return null;
4
+ const fm = {};
5
+ for (const line of m[1].split(/\r?\n/)) {
6
+ const idx = line.indexOf(':');
7
+ if (idx < 0) continue;
8
+ fm[line.slice(0, idx).trim()] = line.slice(idx + 1).trim().replace(/\s*#.*$/, '').replace(/^["']|["']$/g, '');
9
+ }
10
+ return fm;
11
+ }
@@ -0,0 +1,54 @@
1
+ import { existsSync, readFileSync } from 'fs';
2
+ import { join, relative, basename } from 'path';
3
+
4
+ export function loadHypoIgnore(hypoDir) {
5
+ const ignorePath = join(hypoDir, '.hypoignore');
6
+ if (!existsSync(ignorePath)) return [];
7
+ return readFileSync(ignorePath, 'utf-8')
8
+ .split('\n')
9
+ .map(l => l.trim())
10
+ .filter(l => l && !l.startsWith('#'));
11
+ }
12
+
13
+ function globToRegex(glob) {
14
+ return new RegExp('^' +
15
+ glob
16
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&')
17
+ .replace(/\*\*/g, '\x00') // placeholder before single-* replacement
18
+ .replace(/\*/g, '[^/]*')
19
+ .replace(/\?/g, '[^/]')
20
+ .replace(/\x00/g, '.*') // restore ** → .*
21
+ + '$');
22
+ }
23
+
24
+ export function isIgnored(filePath, hypoDir, patterns) {
25
+ const rel = relative(hypoDir, filePath).replace(/\\/g, '/');
26
+ const base = basename(filePath);
27
+
28
+ for (const pattern of patterns) {
29
+ const isDir = pattern.endsWith('/');
30
+ if (isDir) {
31
+ const dir = pattern.slice(0, -1);
32
+ const isAnchored = dir.includes('/');
33
+ if (isAnchored) {
34
+ // e.g. pages/journal/ — anchored to wiki root, match prefix
35
+ const re = globToRegex(dir);
36
+ const parts = rel.split('/');
37
+ for (let i = dir.split('/').length; i <= parts.length; i++) {
38
+ if (re.test(parts.slice(0, i).join('/'))) return true;
39
+ }
40
+ } else {
41
+ // e.g. node_modules/ — unanchored, match any single component
42
+ const re = globToRegex(dir);
43
+ for (const part of rel.split('/')) {
44
+ if (re.test(part)) return true;
45
+ }
46
+ }
47
+ continue;
48
+ }
49
+ const hasSlash = pattern.includes('/');
50
+ const target = hasSlash ? rel : base;
51
+ if (globToRegex(pattern).test(target)) return true;
52
+ }
53
+ return false;
54
+ }
@@ -0,0 +1,53 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * hypo-root.mjs — resolve the Hypomnema root directory
4
+ *
5
+ * Resolution order:
6
+ * 1. HYPO_DIR environment variable
7
+ * 2. Scan common locations for hypo-config.md marker
8
+ * 3. Default: ~/hypomnema
9
+ */
10
+
11
+ import { existsSync } from 'fs';
12
+ import { join } from 'path';
13
+ import { homedir } from 'os';
14
+
15
+ const HOME = homedir();
16
+
17
+ /**
18
+ * Expand leading ~/ to the user's home directory.
19
+ * @param {string} p
20
+ * @returns {string}
21
+ */
22
+ export function expandHome(p) {
23
+ if (p === '~') return HOME;
24
+ if (p.startsWith('~/') || p.startsWith('~\\')) return join(HOME, p.slice(2));
25
+ return p;
26
+ }
27
+
28
+ /**
29
+ * Resolve the Hypomnema root directory.
30
+ * Checks HYPO_DIR env → hypo-config.md scan → ~/hypomnema default.
31
+ * @returns {string} absolute path to Hypomnema root
32
+ */
33
+ export function resolveHypoRoot() {
34
+ if (process.env.HYPO_DIR) {
35
+ return expandHome(process.env.HYPO_DIR);
36
+ }
37
+
38
+ const candidates = [
39
+ join(HOME, 'hypomnema'),
40
+ join(HOME, 'wiki'),
41
+ join(HOME, 'notes'),
42
+ join(HOME, 'knowledge'),
43
+ join(HOME, 'Documents', 'hypomnema'),
44
+ join(HOME, 'Documents', 'wiki'),
45
+ join(HOME, 'Documents', 'notes'),
46
+ ];
47
+
48
+ for (const c of candidates) {
49
+ if (existsSync(join(c, 'hypo-config.md'))) return c;
50
+ }
51
+
52
+ return join(HOME, 'hypomnema');
53
+ }