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,153 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Hypomnema crystallize script
4
+ *
5
+ * Finds synthesis candidates: pages that share tags, unlinked pages,
6
+ * and draft pages that could be crystallized into stable knowledge.
7
+ * Used by /hypo:crystallize to surface what Claude should synthesize.
8
+ *
9
+ * Usage:
10
+ * node scripts/crystallize.mjs [options]
11
+ *
12
+ * Options:
13
+ * --hypo-dir=<path> Hypomnema root (default: resolved via HYPO_DIR / hypo-config.md / ~/hypomnema)
14
+ * --min-group=<n> Min pages per tag group to report (default: 2)
15
+ * --json Output as JSON
16
+ */
17
+
18
+ import { existsSync, readFileSync, readdirSync, statSync } from 'fs';
19
+ import { join, relative, extname } from 'path';
20
+ import { resolveHypoRoot, expandHome } from './lib/hypo-root.mjs';
21
+ import { loadHypoIgnore, isIgnored } from './lib/hypo-ignore.mjs';
22
+
23
+ // ── arg parsing ──────────────────────────────────────────────────────────────
24
+
25
+ function parseArgs(argv) {
26
+ const args = { hypoDir: null, minGroup: 2, json: false };
27
+ for (const arg of argv.slice(2)) {
28
+ if (arg.startsWith('--hypo-dir=')) args.hypoDir = expandHome(arg.slice(11));
29
+ else if (arg.startsWith('--min-group=')) args.minGroup = parseInt(arg.slice(12), 10) || 2;
30
+ else if (arg === '--json') args.json = true;
31
+ }
32
+ if (!args.hypoDir) args.hypoDir = resolveHypoRoot();
33
+ return args;
34
+ }
35
+
36
+ // ── helpers ──────────────────────────────────────────────────────────────────
37
+
38
+ function collectPages(dir, root, acc = [], ignorePatterns = []) {
39
+ if (!existsSync(dir)) return acc;
40
+ for (const entry of readdirSync(dir)) {
41
+ if (entry.startsWith('.')) continue;
42
+ const full = join(dir, entry);
43
+ if (isIgnored(full, root, ignorePatterns)) continue;
44
+ const st = statSync(full);
45
+ if (st.isDirectory()) collectPages(full, root, acc, ignorePatterns);
46
+ else if (extname(entry) === '.md') {
47
+ acc.push({ path: full, rel: relative(root, full) });
48
+ }
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 null;
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 parseTags(fm) {
66
+ if (!fm.tags) return [];
67
+ const raw = fm.tags.trim().replace(/^\[|\]$/g, '');
68
+ return raw.split(',').map(t => t.trim()).filter(Boolean);
69
+ }
70
+
71
+ function extractWikilinks(content) {
72
+ return [...content.matchAll(/\[\[([^\]|#]+?)(?:[|#][^\]]*?)?\]\]/g)].map(m => m[1].trim());
73
+ }
74
+
75
+ // ── main ─────────────────────────────────────────────────────────────────────
76
+
77
+ const args = parseArgs(process.argv);
78
+
79
+ const ignorePatterns = loadHypoIgnore(args.hypoDir);
80
+ const pagesDir = join(args.hypoDir, 'pages');
81
+ const pages = collectPages(pagesDir, args.hypoDir, [], ignorePatterns);
82
+
83
+ const tagGroups = {}; // tag → [{ slug, title }]
84
+ const unlinked = []; // pages with no outbound wikilinks
85
+ const drafts = []; // pages tagged draft
86
+
87
+ for (const { path, rel } of pages) {
88
+ let content;
89
+ try { content = readFileSync(path, 'utf-8'); } catch { continue; }
90
+ const fm = parseFrontmatter(content);
91
+ if (!fm) continue;
92
+
93
+ const slug = rel.replace(/\.md$/, '');
94
+ const title = fm.title || slug;
95
+ const tags = parseTags(fm);
96
+
97
+ // tag groups
98
+ for (const tag of tags) {
99
+ if (!tagGroups[tag]) tagGroups[tag] = [];
100
+ tagGroups[tag].push({ slug, title });
101
+ }
102
+
103
+ // draft detection
104
+ if (tags.includes('draft') || fm.confidence === 'speculative') {
105
+ drafts.push({ slug, title, confidence: fm.confidence });
106
+ }
107
+
108
+ // unlinked (no outbound wikilinks in body)
109
+ const body = content.replace(/^---[\s\S]*?---/, '');
110
+ const links = extractWikilinks(body);
111
+ if (links.length === 0) unlinked.push({ slug, title });
112
+ }
113
+
114
+ // filter tag groups by min-group
115
+ const synthesisGroups = Object.entries(tagGroups)
116
+ .filter(([, pages]) => pages.length >= args.minGroup)
117
+ .sort((a, b) => b[1].length - a[1].length)
118
+ .map(([tag, pages]) => ({ tag, pages }));
119
+
120
+ if (args.json) {
121
+ console.log(JSON.stringify({ synthesisGroups, unlinked, drafts }, null, 2));
122
+ process.exit(0);
123
+ }
124
+
125
+ let found = false;
126
+
127
+ if (synthesisGroups.length > 0) {
128
+ found = true;
129
+ console.log(`Synthesis candidates by tag (${synthesisGroups.length} group(s)):\n`);
130
+ for (const { tag, pages: grp } of synthesisGroups) {
131
+ console.log(` [${tag}] (${grp.length} pages):`);
132
+ for (const p of grp) console.log(` [[${p.slug}]] — ${p.title}`);
133
+ }
134
+ console.log('');
135
+ }
136
+
137
+ if (unlinked.length > 0) {
138
+ found = true;
139
+ console.log(`Unlinked pages (no outbound [[wikilinks]]) — ${unlinked.length}:`);
140
+ for (const p of unlinked) console.log(` [[${p.slug}]] — ${p.title}`);
141
+ console.log('');
142
+ }
143
+
144
+ if (drafts.length > 0) {
145
+ found = true;
146
+ console.log(`Draft/speculative pages ready to crystallize — ${drafts.length}:`);
147
+ for (const p of drafts) console.log(` [[${p.slug}]] — ${p.title}`);
148
+ console.log('');
149
+ }
150
+
151
+ if (!found) {
152
+ console.log('✓ No crystallization candidates found — Hypomnema looks well-connected.');
153
+ }
@@ -0,0 +1,361 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Hypomnema doctor script
4
+ *
5
+ * Verifies the health of a Hypomnema wiki installation.
6
+ *
7
+ * Usage:
8
+ * node scripts/doctor.mjs [options]
9
+ *
10
+ * Options:
11
+ * --hypo-dir=<path> Hypomnema root directory (default: resolved via HYPO_DIR / hypo-config.md scan / ~/hypomnema)
12
+ * --json Output results as JSON
13
+ */
14
+
15
+ import { existsSync, readFileSync, readdirSync, statSync } from 'fs';
16
+ import { join, relative, extname } from 'path';
17
+ import { homedir } from 'os';
18
+ import { spawnSync } from 'child_process';
19
+ import { fileURLToPath } from 'url';
20
+ import { resolveHypoRoot, expandHome } from './lib/hypo-root.mjs';
21
+ import { loadHypoIgnore, isIgnored } from './lib/hypo-ignore.mjs';
22
+ import { parseFrontmatter } from './lib/frontmatter.mjs';
23
+
24
+ const HOME = homedir();
25
+ const SCRIPT_DIR = fileURLToPath(new URL('.', import.meta.url));
26
+ const PKG_ROOT = join(SCRIPT_DIR, '..');
27
+
28
+ // ── arg parsing ──────────────────────────────────────────────────────────────
29
+
30
+ function parseArgs(argv) {
31
+ const args = { hypoDir: null, json: false };
32
+ for (const arg of argv.slice(2)) {
33
+ if (arg.startsWith('--hypo-dir=')) args.hypoDir = expandHome(arg.slice(11));
34
+ else if (arg === '--json') args.json = true;
35
+ }
36
+ if (!args.hypoDir) args.hypoDir = resolveHypoRoot();
37
+ return args;
38
+ }
39
+
40
+ // ── result tracking ──────────────────────────────────────────────────────────
41
+
42
+ const checks = [];
43
+
44
+ function pass(label, detail = '') { checks.push({ status: 'pass', label, detail }); }
45
+ function warn(label, detail = '') { checks.push({ status: 'warn', label, detail }); }
46
+ function fail(label, detail = '') { checks.push({ status: 'fail', label, detail }); }
47
+
48
+ // ── hook map (loaded from hooks/hooks.json — single source of truth) ─────────
49
+
50
+ let _hookConfig;
51
+ try {
52
+ _hookConfig = JSON.parse(readFileSync(join(PKG_ROOT, 'hooks', 'hooks.json'), 'utf-8'));
53
+ } catch {
54
+ console.error(`Error: cannot read hooks/hooks.json from package root: ${PKG_ROOT}`);
55
+ process.exit(1);
56
+ }
57
+ if (!_hookConfig || typeof _hookConfig !== 'object' || Array.isArray(_hookConfig)) {
58
+ console.error('Error: hooks/hooks.json must be a JSON object');
59
+ process.exit(1);
60
+ }
61
+ if (!_hookConfig.hooks || typeof _hookConfig.hooks !== 'object' || Array.isArray(_hookConfig.hooks)) {
62
+ console.error('Error: hooks/hooks.json must contain a "hooks" object');
63
+ process.exit(1);
64
+ }
65
+ function _extractCommandFileName(command) {
66
+ if (typeof command !== 'string') return null;
67
+ const matches = [...command.matchAll(/(?:^|[\/\\])([^\/\\\s"'`]+\.mjs)(?=$|[\s"'`])/g)];
68
+ if (matches.length > 0) return matches[matches.length - 1][1];
69
+ const bare = command.match(/(?:^|\s)([^\/\\\s"'`]+\.mjs)(?=$|[\s"'`])/);
70
+ return bare ? bare[1] : null;
71
+ }
72
+
73
+ function _isHookFileName(file) {
74
+ return typeof file === 'string' && /^[^/\\\s]+\.mjs$/.test(file.trim());
75
+ }
76
+
77
+ function _isHookGroup(group) {
78
+ return group &&
79
+ typeof group === 'object' &&
80
+ !Array.isArray(group) &&
81
+ Array.isArray(group.hooks) &&
82
+ group.hooks.length > 0 &&
83
+ group.hooks.every(hook =>
84
+ hook &&
85
+ typeof hook === 'object' &&
86
+ !Array.isArray(hook) &&
87
+ hook.type === 'command' &&
88
+ _extractCommandFileName(hook.command)
89
+ );
90
+ }
91
+
92
+ // Extract .mjs file names from both old format (string[]) and new format (hook-group object[])
93
+ function _extractFileNames(groups) {
94
+ return groups.flatMap(group => {
95
+ if (typeof group === 'string') return [group.trim()];
96
+ return group.hooks.map(hook => _extractCommandFileName(hook.command));
97
+ });
98
+ }
99
+
100
+ for (const [event, groups] of Object.entries(_hookConfig.hooks)) {
101
+ const valid = Array.isArray(groups) &&
102
+ groups.length > 0 &&
103
+ groups.every(group => _isHookFileName(group) || _isHookGroup(group)) &&
104
+ _extractFileNames(groups).length > 0;
105
+ if (!valid) {
106
+ console.error(`Error: hooks/hooks.json "hooks.${event}" must be a non-empty array of .mjs file names or Claude hook groups`);
107
+ process.exit(1);
108
+ }
109
+ }
110
+ if (_hookConfig.shared !== undefined && (!Array.isArray(_hookConfig.shared) || !_hookConfig.shared.every(f => _isHookFileName(f)))) {
111
+ console.error('Error: hooks/hooks.json "shared" must be an array of .mjs file names');
112
+ process.exit(1);
113
+ }
114
+
115
+ const HOOK_MAP = Object.fromEntries(Object.entries(_hookConfig.hooks).map(([e, gs]) => [e, _extractFileNames(gs)]));
116
+ const SHARED_FILES = _hookConfig.shared ?? [];
117
+
118
+ // ── checks ───────────────────────────────────────────────────────────────────
119
+
120
+ function checkHypoRoot(hypoDir) {
121
+ if (!existsSync(hypoDir)) {
122
+ fail('Wiki root exists', hypoDir);
123
+ return false;
124
+ }
125
+ pass('Wiki root exists', hypoDir);
126
+
127
+ if (existsSync(join(hypoDir, 'hypo-config.md'))) {
128
+ pass('hypo-config.md marker');
129
+ } else {
130
+ warn('hypo-config.md marker', 'Missing — wiki root resolution may fall back to default');
131
+ }
132
+ return true;
133
+ }
134
+
135
+ function checkDirectories(hypoDir) {
136
+ const required = ['pages', 'projects', 'sources'];
137
+ for (const d of required) {
138
+ if (existsSync(join(hypoDir, d))) {
139
+ pass(`Directory: ${d}/`);
140
+ } else {
141
+ fail(`Directory: ${d}/`, `Run /hypo:init to create missing directories`);
142
+ }
143
+ }
144
+ }
145
+
146
+ function checkFiles(hypoDir) {
147
+ const required = ['index.md', 'hot.md', 'log.md', '.hypoignore', 'SCHEMA.md', 'hypo-guide.md'];
148
+ for (const f of required) {
149
+ if (existsSync(join(hypoDir, f))) {
150
+ pass(`File: ${f}`);
151
+ } else {
152
+ warn(`File: ${f}`, 'Expected baseline file is missing');
153
+ }
154
+ }
155
+ }
156
+
157
+ function checkHooks() {
158
+ const claudeHooks = join(HOME, '.claude', 'hooks');
159
+ const allFiles = [...Object.values(HOOK_MAP).flat(), ...SHARED_FILES];
160
+
161
+ let missing = 0;
162
+ for (const file of allFiles) {
163
+ if (!existsSync(join(claudeHooks, file))) missing++;
164
+ }
165
+
166
+ if (missing === 0) {
167
+ pass('Hook files installed', claudeHooks);
168
+ } else if (missing < allFiles.length) {
169
+ warn('Hook files installed', `${missing}/${allFiles.length} missing in ${claudeHooks}`);
170
+ } else {
171
+ fail('Hook files installed', `No hook files found in ${claudeHooks} — run /hypo:init`);
172
+ }
173
+ }
174
+
175
+ function checkSettingsJson() {
176
+ const settingsPath = join(HOME, '.claude', 'settings.json');
177
+ if (!existsSync(settingsPath)) {
178
+ warn('settings.json hook registrations', 'settings.json not found');
179
+ return;
180
+ }
181
+
182
+ let settings;
183
+ try {
184
+ settings = JSON.parse(readFileSync(settingsPath, 'utf-8'));
185
+ } catch {
186
+ fail('settings.json hook registrations', 'settings.json is not valid JSON');
187
+ return;
188
+ }
189
+
190
+ const hooksDir = join(HOME, '.claude', 'hooks');
191
+ let registered = 0;
192
+ let total = 0;
193
+
194
+ for (const [event, files] of Object.entries(HOOK_MAP)) {
195
+ for (const file of files) {
196
+ total++;
197
+ const cmd = `node ${hooksDir.replace(HOME, '$HOME')}/${file}`;
198
+ const found = (Array.isArray(settings.hooks?.[event]) ? settings.hooks[event] : [])
199
+ .flatMap(g => g.hooks || [])
200
+ .some(h => h.command === cmd);
201
+ if (found) registered++;
202
+ }
203
+ }
204
+
205
+ if (registered === total) {
206
+ pass('settings.json hook registrations', `${registered}/${total} registered`);
207
+ } else if (registered > 0) {
208
+ warn('settings.json hook registrations', `${registered}/${total} registered — run /hypo:init to merge missing entries`);
209
+ } else {
210
+ fail('settings.json hook registrations', `0/${total} registered — run /hypo:init`);
211
+ }
212
+ }
213
+
214
+ function checkGit(hypoDir) {
215
+ if (!existsSync(join(hypoDir, '.git'))) {
216
+ warn('Git repository', 'Not a git repo — run /hypo:init with git-remote option for sync/backup');
217
+ return;
218
+ }
219
+ pass('Git repository');
220
+
221
+ const remote = spawnSync('git', ['-C', hypoDir, 'remote', 'get-url', 'origin'], { encoding: 'utf-8' });
222
+ if (remote.status === 0 && remote.stdout.trim()) {
223
+ pass('Git remote origin', remote.stdout.trim());
224
+ } else {
225
+ warn('Git remote origin', 'No remote configured — wiki will not sync/backup automatically');
226
+ }
227
+ }
228
+
229
+ function checkBrokenLinks(hypoDir, ignorePatterns = []) {
230
+ const mdFiles = collectMdFiles(hypoDir, [], hypoDir, ignorePatterns);
231
+ const slugSet = buildSlugSet(mdFiles, hypoDir);
232
+ const broken = [];
233
+
234
+ for (const file of mdFiles) {
235
+ const raw = readFileSync(file, 'utf-8');
236
+ const content = raw.replace(/<!--[\s\S]*?-->/g, '').replace(/`[^`\n]+`/g, '');
237
+ const links = [...content.matchAll(/\[\[([^\]|#\n]+?)(?:[|#][^\]]*?)?\]\]/g)].map(m => m[1].trim());
238
+ for (const link of links) {
239
+ // skip object-path references (e.g. [[hooks.SessionStart]])
240
+ if (link.includes('.') && !link.endsWith('.md')) continue;
241
+ // skip template placeholders (e.g. [[projects/<project-name>/prd]])
242
+ if (link.includes('<') || link.includes('>')) continue;
243
+ const slug = link.replace(/\.md$/, '');
244
+ if (!slugSet.has(slug) && !slugSet.has(slug.toLowerCase())) {
245
+ broken.push({ file: relative(hypoDir, file), link });
246
+ }
247
+ }
248
+ }
249
+
250
+ if (broken.length === 0) {
251
+ pass('Broken wiki links', `Scanned ${mdFiles.length} files`);
252
+ } else {
253
+ const sample = broken.slice(0, 5).map(b => `${b.file} → [[${b.link}]]`).join(', ');
254
+ const extra = broken.length > 5 ? ` (+${broken.length - 5} more)` : '';
255
+ warn('Broken wiki links', `${broken.length} broken: ${sample}${extra}`);
256
+ }
257
+ }
258
+
259
+ function collectMdFiles(dir, acc = [], hypoDir = '', ignorePatterns = []) {
260
+ if (!existsSync(dir)) return acc;
261
+ for (const entry of readdirSync(dir)) {
262
+ if (entry.startsWith('.')) continue;
263
+ const full = join(dir, entry);
264
+ if (hypoDir && isIgnored(full, hypoDir, ignorePatterns)) continue;
265
+ const st = statSync(full);
266
+ if (st.isDirectory()) collectMdFiles(full, acc, hypoDir, ignorePatterns);
267
+ else if (extname(entry) === '.md') acc.push(full);
268
+ }
269
+ return acc;
270
+ }
271
+
272
+ function buildSlugSet(files, hypoDir) {
273
+ const set = new Set();
274
+ for (const f of files) {
275
+ const rel = relative(hypoDir, f).replace(/\.md$/, '');
276
+ // add all path suffixes: pages/learnings/foo → also learnings/foo, foo
277
+ const parts = rel.split('/');
278
+ for (let i = 0; i < parts.length; i++) {
279
+ const suffix = parts.slice(i).join('/');
280
+ set.add(suffix);
281
+ set.add(suffix.toLowerCase());
282
+ }
283
+ }
284
+ return set;
285
+ }
286
+
287
+ function checkVerifyBy(hypoDir, ignorePatterns = []) {
288
+ const today = new Date().toISOString().slice(0, 10);
289
+ const mdFiles = collectMdFiles(hypoDir, [], hypoDir, ignorePatterns);
290
+ const overdue = [];
291
+ const missing = [];
292
+
293
+ for (const file of mdFiles) {
294
+ const content = readFileSync(file, 'utf-8');
295
+ const fm = parseFrontmatter(content);
296
+ if (!fm) continue;
297
+
298
+ const type = fm.type || '';
299
+ if (!['adr', 'page', 'learning'].includes(type)) continue;
300
+
301
+ // verify_by = natural-language question; verify_by_date = ISO date deadline
302
+ if (!fm.verify_by) {
303
+ missing.push(relative(hypoDir, file));
304
+ }
305
+ if (fm.verify_by_date && /^\d{4}-\d{2}-\d{2}$/.test(fm.verify_by_date) && fm.verify_by_date < today) {
306
+ overdue.push({ file: relative(hypoDir, file), due: fm.verify_by_date });
307
+ }
308
+ }
309
+
310
+ if (overdue.length > 0) {
311
+ const sample = overdue.slice(0, 3).map(o => `${o.file} (due ${o.due})`).join(', ');
312
+ const extra = overdue.length > 3 ? ` (+${overdue.length - 3} more)` : '';
313
+ warn('verify_by_date overdue', `${overdue.length} overdue: ${sample}${extra}`);
314
+ } else {
315
+ pass('verify_by_date overdue', 'No overdue pages');
316
+ }
317
+
318
+ if (missing.length > 0) {
319
+ warn('verify_by coverage', `${missing.length} pages (adr/page/learning) missing verify_by question`);
320
+ } else {
321
+ pass('verify_by coverage', 'All tracked pages have verify_by question');
322
+ }
323
+ }
324
+
325
+ // ── main ─────────────────────────────────────────────────────────────────────
326
+
327
+ const args = parseArgs(process.argv);
328
+
329
+ const ignorePatterns = loadHypoIgnore(args.hypoDir);
330
+ const rootOk = checkHypoRoot(args.hypoDir);
331
+ if (rootOk) {
332
+ checkDirectories(args.hypoDir);
333
+ checkFiles(args.hypoDir);
334
+ checkBrokenLinks(args.hypoDir, ignorePatterns);
335
+ checkVerifyBy(args.hypoDir, ignorePatterns);
336
+ }
337
+ checkHooks();
338
+ checkSettingsJson();
339
+ checkGit(args.hypoDir);
340
+
341
+ // ── report ───────────────────────────────────────────────────────────────────
342
+
343
+ if (args.json) {
344
+ console.log(JSON.stringify(checks, null, 2));
345
+ } else {
346
+ const icons = { pass: '✓', warn: '⚠', fail: '✗' };
347
+ for (const c of checks) {
348
+ const detail = c.detail ? ` — ${c.detail}` : '';
349
+ console.log(`${icons[c.status]} ${c.label}${detail}`);
350
+ }
351
+
352
+ const fails = checks.filter(c => c.status === 'fail').length;
353
+ const warns = checks.filter(c => c.status === 'warn').length;
354
+ const passes = checks.filter(c => c.status === 'pass').length;
355
+
356
+ console.log('');
357
+ console.log(`Result: ${passes} passed, ${warns} warnings, ${fails} failed`);
358
+ if (fails > 0) console.log('Run /hypo:init to repair installation issues.');
359
+ }
360
+
361
+ if (checks.some(c => c.status === 'fail')) process.exit(1);
@@ -0,0 +1,130 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Hypomnema feedback script
4
+ *
5
+ * Creates or appends to pages/feedback/<topic>.md with a feedback entry.
6
+ * Also appends a log entry to log.md.
7
+ * Used by /hypo:feedback after Claude drafts the feedback content.
8
+ *
9
+ * Usage:
10
+ * node scripts/feedback.mjs --topic=<slug> --entry=<text> [options]
11
+ *
12
+ * Options:
13
+ * --hypo-dir=<path> Hypomnema root (default: resolved via HYPO_DIR / hypo-config.md / ~/hypomnema)
14
+ * --topic=<slug> Feedback topic slug (e.g. "response-length", "code-style")
15
+ * --entry=<text> Feedback entry text (one-line rule or short paragraph)
16
+ * --dry-run Preview without writing
17
+ * --list List existing feedback topics
18
+ */
19
+
20
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from 'fs';
21
+ import { join, resolve, sep } from 'path';
22
+ import { resolveHypoRoot, expandHome } from './lib/hypo-root.mjs';
23
+
24
+ // ── arg parsing ──────────────────────────────────────────────────────────────
25
+
26
+ function parseArgs(argv) {
27
+ const args = { hypoDir: null, topic: null, entry: null, dryRun: false, list: 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('--topic=')) args.topic = arg.slice(8);
31
+ else if (arg.startsWith('--entry=')) args.entry = arg.slice(8);
32
+ else if (arg === '--dry-run') args.dryRun = true;
33
+ else if (arg === '--list') args.list = true;
34
+ }
35
+ if (!args.hypoDir) args.hypoDir = resolveHypoRoot();
36
+ return args;
37
+ }
38
+
39
+ // ── list mode ────────────────────────────────────────────────────────────────
40
+
41
+ function listTopics(hypoDir) {
42
+ const feedbackDir = join(hypoDir, 'pages', 'feedback');
43
+ if (!existsSync(feedbackDir)) {
44
+ console.log('No feedback pages found.');
45
+ return;
46
+ }
47
+ const files = readdirSync(feedbackDir).filter(f => f.endsWith('.md'));
48
+ if (files.length === 0) {
49
+ console.log('No feedback pages found.');
50
+ return;
51
+ }
52
+ console.log(`Feedback topics (${files.length}):`);
53
+ for (const f of files) console.log(` ${f.replace(/\.md$/, '')}`);
54
+ }
55
+
56
+ // ── write feedback entry ──────────────────────────────────────────────────────
57
+
58
+ function writeFeedback(hypoDir, topic, entry, dryRun) {
59
+ const feedbackDir = join(hypoDir, 'pages', 'feedback');
60
+ const filePath = join(feedbackDir, `${topic}.md`);
61
+ const today = new Date().toISOString().slice(0, 10);
62
+
63
+ let content;
64
+
65
+ if (existsSync(filePath)) {
66
+ content = readFileSync(filePath, 'utf-8');
67
+ const newEntry = `\n## ${today}\n\n${entry}\n`;
68
+ content = content.trimEnd() + '\n' + newEntry;
69
+ } else {
70
+ content = `---
71
+ title: "Feedback: ${topic}"
72
+ type: feedback
73
+ updated: ${today}
74
+ tags: [feedback]
75
+ ---
76
+
77
+ # Feedback: ${topic}
78
+
79
+ ## ${today}
80
+
81
+ ${entry}
82
+ `;
83
+ }
84
+
85
+ if (dryRun) {
86
+ console.log('[DRY RUN — no changes made]\n');
87
+ console.log(`Would write to: ${filePath}\n`);
88
+ console.log(content);
89
+ return;
90
+ }
91
+
92
+ if (!existsSync(feedbackDir)) mkdirSync(feedbackDir, { recursive: true });
93
+ writeFileSync(filePath, content);
94
+ console.log(`✓ Written: ${filePath}`);
95
+
96
+ // append to log.md
97
+ const logPath = join(hypoDir, 'log.md');
98
+ const logEntry = `\n- ${today} feedback: [[pages/feedback/${topic}]] — ${entry.split('\n')[0].slice(0, 80)}\n`;
99
+ if (existsSync(logPath)) {
100
+ const log = readFileSync(logPath, 'utf-8');
101
+ writeFileSync(logPath, log.trimEnd() + logEntry);
102
+ console.log(`↪ Appended to log.md`);
103
+ }
104
+ }
105
+
106
+ // ── main ─────────────────────────────────────────────────────────────────────
107
+
108
+ const args = parseArgs(process.argv);
109
+
110
+ if (args.list) {
111
+ listTopics(args.hypoDir);
112
+ process.exit(0);
113
+ }
114
+
115
+ if (!args.topic) {
116
+ console.error('Error: --topic=<slug> is required (or use --list)');
117
+ process.exit(1);
118
+ }
119
+
120
+ if (!/^[a-z0-9][a-z0-9._-]*$/i.test(args.topic)) {
121
+ console.error('Error: --topic must be a simple slug (letters, digits, hyphen, dot, underscore)');
122
+ process.exit(1);
123
+ }
124
+
125
+ if (!args.entry) {
126
+ console.error('Error: --entry=<text> is required');
127
+ process.exit(1);
128
+ }
129
+
130
+ writeFeedback(args.hypoDir, args.topic, args.entry, args.dryRun);