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,210 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Hypomnema lint script
4
+ *
5
+ * Validates wiki pages for frontmatter correctness and broken wikilinks.
6
+ *
7
+ * Usage:
8
+ * node scripts/lint.mjs [options]
9
+ *
10
+ * Options:
11
+ * --hypo-dir=<path> Hypomnema root (default: resolved via HYPO_DIR / hypo-config.md / ~/hypomnema)
12
+ * --json Output results as JSON
13
+ * --fix Auto-add missing `updated` field (safe repairs only)
14
+ */
15
+
16
+ import { existsSync, readFileSync, writeFileSync, readdirSync, statSync } from 'fs';
17
+ import { join, relative, extname, basename } from 'path';
18
+ import { resolveHypoRoot, expandHome } from './lib/hypo-root.mjs';
19
+ import { SESSION_STATE_NEXT_HEADINGS } from '../hooks/hypo-shared.mjs';
20
+ import { loadHypoIgnore, isIgnored } from './lib/hypo-ignore.mjs';
21
+
22
+ // ── arg parsing ──────────────────────────────────────────────────────────────
23
+
24
+ function parseArgs(argv) {
25
+ const args = { hypoDir: null, json: false, fix: false };
26
+ for (const arg of argv.slice(2)) {
27
+ if (arg.startsWith('--hypo-dir=')) args.hypoDir = expandHome(arg.slice(11));
28
+ else if (arg === '--json') args.json = true;
29
+ else if (arg === '--fix') args.fix = true;
30
+ }
31
+ if (!args.hypoDir) args.hypoDir = resolveHypoRoot();
32
+ return args;
33
+ }
34
+
35
+ // ── frontmatter parser ────────────────────────────────────────────────────────
36
+
37
+ function parseFrontmatter(content) {
38
+ const m = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
39
+ if (!m) return null;
40
+ const fm = {};
41
+ for (const line of m[1].split('\n')) {
42
+ const idx = line.indexOf(':');
43
+ if (idx < 0) continue;
44
+ fm[line.slice(0, idx).trim()] = line.slice(idx + 1).trim().replace(/^["']|["']$/g, '');
45
+ }
46
+ return fm;
47
+ }
48
+
49
+ // ── page collector ────────────────────────────────────────────────────────────
50
+
51
+ function collectPages(dir, root, pages = [], ignorePatterns = []) {
52
+ if (!existsSync(dir)) return pages;
53
+ for (const entry of readdirSync(dir)) {
54
+ const full = join(dir, entry);
55
+ if (isIgnored(full, root, ignorePatterns)) continue;
56
+ const st = statSync(full);
57
+ if (st.isDirectory()) {
58
+ collectPages(full, root, pages, ignorePatterns);
59
+ } else if (extname(entry) === '.md' && !entry.startsWith('.')) {
60
+ pages.push({ path: full, rel: relative(root, full) });
61
+ }
62
+ }
63
+ return pages;
64
+ }
65
+
66
+ // ── slug map ─────────────────────────────────────────────────────────────────
67
+
68
+ function buildSlugMap(pages) {
69
+ const map = new Set();
70
+ for (const { rel } of pages) {
71
+ map.add(rel.replace(/\.md$/, '').replace(/\\/g, '/'));
72
+ map.add(basename(rel, '.md'));
73
+ }
74
+ return map;
75
+ }
76
+
77
+ // ── wikilink extractor ────────────────────────────────────────────────────────
78
+
79
+ function extractWikilinks(content) {
80
+ const links = [];
81
+ for (const m of content.matchAll(/\[\[([^\]|#]+?)(?:[|#][^\]]*?)?\]\]/g)) {
82
+ links.push(m[1].trim());
83
+ }
84
+ return links;
85
+ }
86
+
87
+ // ── lint checks ───────────────────────────────────────────────────────────────
88
+
89
+ const REQUIRED_FIELDS = ['title', 'type'];
90
+ const VALID_TYPES = [
91
+ 'concept', 'source-summary', 'entity', 'tool-eval', 'prompt-pattern',
92
+ 'playbook', 'learning', 'tip', 'feedback', 'reference', 'synthesis',
93
+ 'weekly-journal', 'prd', 'adr', 'session-log', 'session-state',
94
+ 'project-index', 'postmortem', 'open-questions', 'schema', 'source',
95
+ ];
96
+
97
+ const issues = [];
98
+
99
+ function issue(severity, rel, msg, fullPath = null) {
100
+ issues.push({ severity, file: rel, message: msg, path: fullPath });
101
+ }
102
+
103
+ function hasHeading(content, heading) {
104
+ const escaped = heading.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
105
+ return new RegExp(`^##\\s+${escaped}\\s*$`, 'm').test(content);
106
+ }
107
+
108
+ function lintSessionStateHeadings(content, rel) {
109
+ if (!rel.match(/^projects\/[^/]+\/session-state\.md$/)) return;
110
+
111
+ if (!SESSION_STATE_NEXT_HEADINGS.some(heading => hasHeading(content, heading))) {
112
+ issue(
113
+ 'error',
114
+ rel,
115
+ `Missing required session-state heading: one of ${SESSION_STATE_NEXT_HEADINGS.map(h => `## ${h}`).join(', ')}`
116
+ );
117
+ }
118
+ }
119
+
120
+ function lintPage({ path, rel }, slugMap) {
121
+ let content;
122
+ try { content = readFileSync(path, 'utf-8'); } catch { return; }
123
+
124
+ if (!content.match(/^---\r?\n/)) {
125
+ issue('warn', rel, 'No frontmatter found');
126
+ return;
127
+ }
128
+
129
+ const fm = parseFrontmatter(content);
130
+ if (!fm) {
131
+ issue('error', rel, 'Malformed frontmatter (unclosed ---)');
132
+ return;
133
+ }
134
+
135
+ for (const field of REQUIRED_FIELDS) {
136
+ if (!fm[field]) issue('error', rel, `Missing required frontmatter field: ${field}`);
137
+ }
138
+
139
+ if (fm.type && !VALID_TYPES.includes(fm.type)) {
140
+ issue('warn', rel, `Unknown type: "${fm.type}"`);
141
+ }
142
+
143
+ if (!fm.updated) {
144
+ issue('warn', rel, 'Missing frontmatter field: updated', path);
145
+ }
146
+
147
+ lintSessionStateHeadings(content, rel);
148
+
149
+ for (const link of extractWikilinks(content)) {
150
+ if (!slugMap.has(link)) {
151
+ issue('warn', rel, `Broken wikilink: [[${link}]]`);
152
+ }
153
+ }
154
+ }
155
+
156
+ // ── main ──────────────────────────────────────────────────────────────────────
157
+
158
+ const args = parseArgs(process.argv);
159
+
160
+ const ignorePatterns = loadHypoIgnore(args.hypoDir);
161
+ const scanDirs = ['pages', 'projects'].map(d => join(args.hypoDir, d));
162
+ const pages = scanDirs.flatMap(d => collectPages(d, args.hypoDir, [], ignorePatterns));
163
+ const slugMap = buildSlugMap(pages);
164
+
165
+ for (const page of pages) lintPage(page, slugMap);
166
+
167
+ if (args.fix) {
168
+ const today = new Date().toISOString().slice(0, 10);
169
+ const fixed = new Set();
170
+ for (const iss of issues) {
171
+ if (iss.severity === 'warn' && iss.message === 'Missing frontmatter field: updated' && iss.path) {
172
+ const content = readFileSync(iss.path, 'utf-8');
173
+ const fmMatch = /^---\r?\n[\s\S]*?\r?\n---/.exec(content);
174
+ if (fmMatch) {
175
+ const lineEnding = fmMatch[0].includes('\r\n') ? '\r\n' : '\n';
176
+ const closingTag = `${lineEnding}---`;
177
+ const insertAt = fmMatch.index + fmMatch[0].lastIndexOf(closingTag);
178
+ if (insertAt < 0) continue;
179
+ const fixedContent = content.slice(0, insertAt) + `${lineEnding}updated: ${today}` + content.slice(insertAt);
180
+ writeFileSync(iss.path, fixedContent);
181
+ fixed.add(iss.path);
182
+ }
183
+ }
184
+ }
185
+ if (fixed.size > 0) {
186
+ issues.splice(0, issues.length, ...issues.filter(
187
+ i => !(i.severity === 'warn' && i.message === 'Missing frontmatter field: updated' && fixed.has(i.path))
188
+ ));
189
+ }
190
+ }
191
+
192
+ const errors = issues.filter(i => i.severity === 'error');
193
+ const warns = issues.filter(i => i.severity === 'warn');
194
+
195
+ if (args.json) {
196
+ const toOut = ({ severity, file, message }) => ({ severity, file, message });
197
+ console.log(JSON.stringify({ ok: errors.length === 0, errors: errors.map(toOut), warns: warns.map(toOut), total: issues.length }, null, 2));
198
+ } else {
199
+ if (issues.length === 0) {
200
+ console.log('✓ No lint issues found');
201
+ } else {
202
+ for (const { severity, file, message } of issues) {
203
+ const icon = severity === 'error' ? '✗' : '⚠';
204
+ console.log(`${icon} ${file}: ${message}`);
205
+ }
206
+ console.log(`\n${errors.length} error(s), ${warns.length} warning(s)`);
207
+ }
208
+ }
209
+
210
+ process.exit(errors.length > 0 ? 1 : 0);
@@ -0,0 +1,124 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Hypomnema query script
4
+ *
5
+ * Full-text search across wiki pages and projects.
6
+ * Returns matching files with a context excerpt and frontmatter summary.
7
+ * Used by /hypo:query to surface relevant pages before Claude synthesizes.
8
+ *
9
+ * Usage:
10
+ * node scripts/query.mjs --q="<search terms>" [options]
11
+ *
12
+ * Options:
13
+ * --hypo-dir=<path> Hypomnema root (default: resolved via HYPO_DIR / hypo-config.md / ~/hypomnema)
14
+ * --q=<query> Search query (required)
15
+ * --limit=<n> Max results (default: 10)
16
+ * --json Output as JSON
17
+ */
18
+
19
+ import { existsSync, readFileSync, readdirSync, statSync } from 'fs';
20
+ import { join, relative, extname } from 'path';
21
+ import { resolveHypoRoot, expandHome } from './lib/hypo-root.mjs';
22
+ import { loadHypoIgnore, isIgnored } from './lib/hypo-ignore.mjs';
23
+
24
+ // ── arg parsing ──────────────────────────────────────────────────────────────
25
+
26
+ function parseArgs(argv) {
27
+ const args = { hypoDir: null, query: null, limit: 10, json: false };
28
+ for (const arg of argv.slice(2)) {
29
+ if (arg.startsWith('--hypo-dir=')) args.hypoDir = expandHome(arg.slice(11));
30
+ else if (arg.startsWith('--q=')) args.query = arg.slice(4);
31
+ else if (arg.startsWith('--limit=')) args.limit = parseInt(arg.slice(8), 10) || 10;
32
+ else if (arg === '--json') args.json = true;
33
+ }
34
+ if (!args.hypoDir) args.hypoDir = resolveHypoRoot();
35
+ return args;
36
+ }
37
+
38
+ // ── helpers ──────────────────────────────────────────────────────────────────
39
+
40
+ function collectMdFiles(dir, root, acc = [], ignorePatterns = []) {
41
+ if (!existsSync(dir)) return acc;
42
+ for (const entry of readdirSync(dir)) {
43
+ if (entry.startsWith('.')) continue;
44
+ const full = join(dir, entry);
45
+ if (isIgnored(full, root, ignorePatterns)) continue;
46
+ const st = statSync(full);
47
+ if (st.isDirectory()) collectMdFiles(full, root, acc, ignorePatterns);
48
+ else if (extname(entry) === '.md') acc.push({ path: full, rel: relative(root, full) });
49
+ }
50
+ return acc;
51
+ }
52
+
53
+ function parseFrontmatter(content) {
54
+ const m = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
55
+ if (!m) return {};
56
+ const fm = {};
57
+ for (const line of m[1].split('\n')) {
58
+ const idx = line.indexOf(':');
59
+ if (idx < 0) continue;
60
+ fm[line.slice(0, idx).trim()] = line.slice(idx + 1).trim().replace(/^["']|["']$/g, '');
61
+ }
62
+ return fm;
63
+ }
64
+
65
+ function escapeRegex(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }
66
+
67
+ function scoreAndExcerpt(content, terms) {
68
+ const lower = content.toLowerCase();
69
+ let score = 0;
70
+ for (const t of terms) score += (lower.match(new RegExp(escapeRegex(t), 'g')) || []).length;
71
+
72
+ // find first matching line for excerpt
73
+ const lines = content.split('\n');
74
+ let excerpt = '';
75
+ for (const line of lines) {
76
+ if (terms.some(t => line.toLowerCase().includes(t))) {
77
+ excerpt = line.trim().slice(0, 120);
78
+ break;
79
+ }
80
+ }
81
+ return { score, excerpt };
82
+ }
83
+
84
+ // ── main ─────────────────────────────────────────────────────────────────────
85
+
86
+ const args = parseArgs(process.argv);
87
+
88
+ if (!args.query) {
89
+ console.error('Error: --q=<query> is required');
90
+ process.exit(1);
91
+ }
92
+
93
+ const terms = args.query.toLowerCase().split(/\s+/).filter(Boolean);
94
+ const ignorePatterns = loadHypoIgnore(args.hypoDir);
95
+ const scanDirs = ['pages', 'projects'].map(d => join(args.hypoDir, d));
96
+ const files = scanDirs.flatMap(d => collectMdFiles(d, args.hypoDir, [], ignorePatterns));
97
+
98
+ const results = [];
99
+
100
+ for (const { path, rel } of files) {
101
+ let content;
102
+ try { content = readFileSync(path, 'utf-8'); } catch { continue; }
103
+ const { score, excerpt } = scoreAndExcerpt(content, terms);
104
+ if (score === 0) continue;
105
+ const fm = parseFrontmatter(content);
106
+ results.push({ slug: rel.replace(/\.md$/, ''), title: fm.title || rel, type: fm.type || '', score, excerpt });
107
+ }
108
+
109
+ results.sort((a, b) => b.score - a.score);
110
+ const top = results.slice(0, args.limit);
111
+
112
+ if (args.json) {
113
+ console.log(JSON.stringify(top, null, 2));
114
+ } else {
115
+ if (top.length === 0) {
116
+ console.log(`No results for: ${args.query}`);
117
+ } else {
118
+ console.log(`Found ${results.length} result(s) for "${args.query}" (showing ${top.length}):\n`);
119
+ for (const r of top) {
120
+ console.log(`[[${r.slug}]] — ${r.title}${r.type ? ` (${r.type})` : ''} [score: ${r.score}]`);
121
+ if (r.excerpt) console.log(` ${r.excerpt}`);
122
+ }
123
+ }
124
+ }
@@ -0,0 +1,115 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Hypomnema resume script
4
+ *
5
+ * Reads the session-state.md for a project and outputs the next-tasks section.
6
+ * Used by /hypo:resume to surface what was left off before Claude continues.
7
+ *
8
+ * Usage:
9
+ * node scripts/resume.mjs [options]
10
+ *
11
+ * Options:
12
+ * --hypo-dir=<path> Hypomnema root (default: resolved via HYPO_DIR / hypo-config.md / ~/hypomnema)
13
+ * --project=<name> Project name (default: most recently active from hot.md)
14
+ * --json Output as JSON
15
+ */
16
+
17
+ import { existsSync, readFileSync, readdirSync, statSync } from 'fs';
18
+ import { join } from 'path';
19
+ import { resolveHypoRoot, expandHome } from './lib/hypo-root.mjs';
20
+
21
+ // ── arg parsing ──────────────────────────────────────────────────────────────
22
+
23
+ function parseArgs(argv) {
24
+ const args = { hypoDir: null, project: null, json: false };
25
+ for (const arg of argv.slice(2)) {
26
+ if (arg.startsWith('--hypo-dir=')) args.hypoDir = expandHome(arg.slice(11));
27
+ else if (arg.startsWith('--project=')) args.project = arg.slice(10);
28
+ else if (arg === '--json') args.json = true;
29
+ }
30
+ if (!args.hypoDir) args.hypoDir = resolveHypoRoot();
31
+ return args;
32
+ }
33
+
34
+ // ── active project from hot.md ───────────────────────────────────────────────
35
+
36
+ function resolveActiveProject(hypoDir) {
37
+ const hotPath = join(hypoDir, 'hot.md');
38
+ if (!existsSync(hotPath)) return null;
39
+
40
+ const content = readFileSync(hotPath, 'utf-8');
41
+ // Canonical hot.md uses wikilinks: | name | date | [[projects/slug/hot]] |
42
+ // Pick the most recent row by the date column when present.
43
+ const wikiRows = [...content.matchAll(/\|\s*([^|]+?)\s*\|\s*(\d{4}-\d{2}-\d{2})?\s*\|\s*\[\[projects\/([^\]/]+)\/[^\]]+\]\]/g)]
44
+ .map(m => ({ name: m[1].trim(), date: m[2] || '', slug: m[3] }));
45
+ if (wikiRows.length > 0) {
46
+ wikiRows.sort((a, b) => b.date.localeCompare(a.date));
47
+ return wikiRows[0].slug;
48
+ }
49
+ // Legacy markdown-link rows: | [name](projects/name/...) | ...
50
+ const mdRow = content.match(/\|\s*\[([^\]]+)\]\(projects\/([^/)]+)/);
51
+ if (mdRow) return mdRow[2];
52
+
53
+ // fallback: most recently modified project with a session-state.md
54
+ const projectsDir = join(hypoDir, 'projects');
55
+ if (!existsSync(projectsDir)) return null;
56
+
57
+ let latest = null;
58
+ let latestMtime = 0;
59
+ for (const p of readdirSync(projectsDir)) {
60
+ const ssPath = join(projectsDir, p, 'session-state.md');
61
+ if (!existsSync(ssPath)) continue;
62
+ const mtime = statSync(ssPath).mtimeMs;
63
+ if (mtime > latestMtime) { latestMtime = mtime; latest = p; }
64
+ }
65
+ return latest;
66
+ }
67
+
68
+ // ── read session state ────────────────────────────────────────────────────────
69
+
70
+ function readSessionState(hypoDir, project) {
71
+ const ssPath = join(hypoDir, 'projects', project, 'session-state.md');
72
+ if (!existsSync(ssPath)) return null;
73
+ return readFileSync(ssPath, 'utf-8');
74
+ }
75
+
76
+ function readHot(hypoDir, project) {
77
+ const hotPath = join(hypoDir, 'projects', project, 'hot.md');
78
+ if (!existsSync(hotPath)) return null;
79
+ return readFileSync(hotPath, 'utf-8');
80
+ }
81
+
82
+ // ── main ─────────────────────────────────────────────────────────────────────
83
+
84
+ const args = parseArgs(process.argv);
85
+
86
+ const project = args.project || resolveActiveProject(args.hypoDir);
87
+
88
+ if (!project) {
89
+ console.error('Error: no active project found. Use --project=<name> or create a hot.md entry.');
90
+ process.exit(1);
91
+ }
92
+
93
+ const sessionState = readSessionState(args.hypoDir, project);
94
+ if (!sessionState) {
95
+ console.error(`Error: no session-state.md found for project "${project}"`);
96
+ process.exit(1);
97
+ }
98
+
99
+ const hotContent = readHot(args.hypoDir, project);
100
+
101
+ if (args.json) {
102
+ console.log(JSON.stringify({ project, sessionState, hot: hotContent }, null, 2));
103
+ process.exit(0);
104
+ }
105
+
106
+ console.log(`Project: ${project}`);
107
+ console.log(`State: projects/${project}/session-state.md\n`);
108
+ console.log('─'.repeat(60));
109
+ console.log(sessionState.trim());
110
+
111
+ if (hotContent) {
112
+ console.log('\n' + '─'.repeat(60));
113
+ console.log('Background (hot.md):');
114
+ console.log(hotContent.trim());
115
+ }
@@ -0,0 +1,132 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Hypomnema stats script
4
+ *
5
+ * Reports statistics about the wiki: page counts by type, project count,
6
+ * source count, ADR count, and last activity date.
7
+ *
8
+ * Usage:
9
+ * node scripts/stats.mjs [options]
10
+ *
11
+ * Options:
12
+ * --hypo-dir=<path> Hypomnema root (default: resolved via HYPO_DIR / hypo-config.md / ~/hypomnema)
13
+ * --json Output as JSON
14
+ */
15
+
16
+ import { existsSync, readFileSync, readdirSync, statSync } from 'fs';
17
+ import { join, extname } from 'path';
18
+ import { resolveHypoRoot, expandHome } from './lib/hypo-root.mjs';
19
+ import { loadHypoIgnore, isIgnored } from './lib/hypo-ignore.mjs';
20
+
21
+ // ── arg parsing ──────────────────────────────────────────────────────────────
22
+
23
+ function parseArgs(argv) {
24
+ const args = { hypoDir: null, json: false };
25
+ for (const arg of argv.slice(2)) {
26
+ if (arg.startsWith('--hypo-dir=')) args.hypoDir = expandHome(arg.slice(11));
27
+ else if (arg === '--json') args.json = true;
28
+ }
29
+ if (!args.hypoDir) args.hypoDir = resolveHypoRoot();
30
+ return args;
31
+ }
32
+
33
+ // ── helpers ──────────────────────────────────────────────────────────────────
34
+
35
+ function collectMdFiles(dir, acc = [], hypoDir = '', ignorePatterns = []) {
36
+ if (!existsSync(dir)) return acc;
37
+ for (const entry of readdirSync(dir)) {
38
+ if (entry.startsWith('.')) continue;
39
+ const full = join(dir, entry);
40
+ if (hypoDir && isIgnored(full, hypoDir, ignorePatterns)) continue;
41
+ const st = statSync(full);
42
+ if (st.isDirectory()) collectMdFiles(full, acc, hypoDir, ignorePatterns);
43
+ else if (extname(entry) === '.md') acc.push(full);
44
+ }
45
+ return acc;
46
+ }
47
+
48
+ function parseFrontmatter(content) {
49
+ const m = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
50
+ if (!m) return null;
51
+ const fm = {};
52
+ for (const line of m[1].split('\n')) {
53
+ const idx = line.indexOf(':');
54
+ if (idx < 0) continue;
55
+ fm[line.slice(0, idx).trim()] = line.slice(idx + 1).trim().replace(/^["']|["']$/g, '');
56
+ }
57
+ return fm;
58
+ }
59
+
60
+ function listDirs(dir) {
61
+ if (!existsSync(dir)) return [];
62
+ return readdirSync(dir).filter(e => {
63
+ if (e.startsWith('.')) return false;
64
+ return statSync(join(dir, e)).isDirectory();
65
+ });
66
+ }
67
+
68
+ function getLastActivity(hypoDir) {
69
+ const logPath = join(hypoDir, 'log.md');
70
+ if (!existsSync(logPath)) return null;
71
+ const content = readFileSync(logPath, 'utf-8');
72
+ const dates = [...content.matchAll(/\b(\d{4}-\d{2}-\d{2})\b/g)].map(m => m[1]);
73
+ return dates.length ? dates[dates.length - 1] : null;
74
+ }
75
+
76
+ // ── main ─────────────────────────────────────────────────────────────────────
77
+
78
+ const args = parseArgs(process.argv);
79
+
80
+ const ignorePatterns = loadHypoIgnore(args.hypoDir);
81
+ const pageFiles = collectMdFiles(join(args.hypoDir, 'pages'), [], args.hypoDir, ignorePatterns);
82
+ const projects = listDirs(join(args.hypoDir, 'projects'));
83
+ const sources = existsSync(join(args.hypoDir, 'sources'))
84
+ ? readdirSync(join(args.hypoDir, 'sources')).filter(e => !e.startsWith('.') && !isIgnored(join(args.hypoDir, 'sources', e), args.hypoDir, ignorePatterns))
85
+ : [];
86
+
87
+ const typeCounts = {};
88
+ let missingFrontmatter = 0;
89
+
90
+ for (const f of pageFiles) {
91
+ let content;
92
+ try { content = readFileSync(f, 'utf-8'); } catch { continue; }
93
+ const fm = parseFrontmatter(content);
94
+ if (!fm) { missingFrontmatter++; continue; }
95
+ const t = fm.type || 'unknown';
96
+ typeCounts[t] = (typeCounts[t] || 0) + 1;
97
+ }
98
+
99
+ let adrCount = 0;
100
+ for (const p of projects) {
101
+ const decisionsDir = join(args.hypoDir, 'projects', p, 'decisions');
102
+ if (existsSync(decisionsDir)) {
103
+ adrCount += readdirSync(decisionsDir).filter(f => f.endsWith('.md')).length;
104
+ }
105
+ }
106
+
107
+ const lastActivity = getLastActivity(args.hypoDir);
108
+
109
+ const stats = {
110
+ pages: { total: pageFiles.length, byType: typeCounts, missingFrontmatter },
111
+ projects: projects.length,
112
+ sources: sources.length,
113
+ adrs: adrCount,
114
+ lastActivity,
115
+ };
116
+
117
+ if (args.json) {
118
+ console.log(JSON.stringify(stats, null, 2));
119
+ } else {
120
+ console.log(`Pages: ${pageFiles.length} total`);
121
+ const typeEntries = Object.entries(typeCounts).sort((a, b) => b[1] - a[1]);
122
+ if (typeEntries.length) {
123
+ console.log(` by type: ${typeEntries.map(([t, n]) => `${t} (${n})`).join(', ')}`);
124
+ }
125
+ if (missingFrontmatter) {
126
+ console.log(` missing frontmatter: ${missingFrontmatter}`);
127
+ }
128
+ console.log(`Projects: ${projects.length}`);
129
+ console.log(`Sources: ${sources.length}`);
130
+ console.log(`ADRs: ${adrCount}`);
131
+ if (lastActivity) console.log(`Last activity: ${lastActivity}`);
132
+ }