hypomnema 1.0.1 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.ko.md +12 -5
  4. package/README.md +12 -5
  5. package/commands/audit.md +46 -0
  6. package/commands/crystallize.md +113 -23
  7. package/commands/feedback.md +40 -26
  8. package/commands/ingest.md +31 -9
  9. package/commands/upgrade.md +2 -2
  10. package/docs/ARCHITECTURE.md +83 -9
  11. package/docs/CONTRIBUTING.md +2 -2
  12. package/hooks/hooks.json +39 -1
  13. package/hooks/hypo-auto-commit.mjs +23 -4
  14. package/hooks/hypo-auto-minimal-crystallize.mjs +145 -0
  15. package/hooks/hypo-auto-stage.mjs +9 -5
  16. package/hooks/hypo-compact-guard.mjs +33 -24
  17. package/hooks/hypo-cwd-change.mjs +107 -24
  18. package/hooks/hypo-file-watch.mjs +23 -10
  19. package/hooks/hypo-first-prompt.mjs +37 -23
  20. package/hooks/hypo-hot-rebuild.mjs +31 -8
  21. package/hooks/hypo-lookup.mjs +171 -65
  22. package/hooks/hypo-personal-check.mjs +207 -112
  23. package/hooks/hypo-pre-commit.mjs +46 -0
  24. package/hooks/hypo-session-end.mjs +58 -0
  25. package/hooks/hypo-session-record.mjs +60 -0
  26. package/hooks/hypo-session-start.mjs +312 -44
  27. package/hooks/hypo-shared.mjs +880 -28
  28. package/hooks/hypo-web-fetch-ingest.mjs +121 -0
  29. package/hooks/version-check-fetch.mjs +74 -0
  30. package/hooks/version-check.mjs +184 -0
  31. package/package.json +17 -3
  32. package/scripts/crystallize.mjs +623 -18
  33. package/scripts/doctor.mjs +739 -46
  34. package/scripts/feedback-sync.mjs +974 -0
  35. package/scripts/feedback.mjs +253 -44
  36. package/scripts/graph.mjs +35 -22
  37. package/scripts/ingest.mjs +89 -16
  38. package/scripts/init.mjs +442 -114
  39. package/scripts/lib/design-history-stale.mjs +83 -0
  40. package/scripts/lib/extensions.mjs +749 -0
  41. package/scripts/lib/frontmatter.mjs +5 -1
  42. package/scripts/lib/hypo-ignore.mjs +12 -10
  43. package/scripts/lib/pkg-json.mjs +23 -5
  44. package/scripts/lib/project-create.mjs +225 -0
  45. package/scripts/lib/schema-vocab.mjs +96 -0
  46. package/scripts/lint.mjs +238 -31
  47. package/scripts/query.mjs +26 -10
  48. package/scripts/resume.mjs +11 -5
  49. package/scripts/session-audit.mjs +277 -0
  50. package/scripts/smoke-pack.mjs +224 -0
  51. package/scripts/stats.mjs +24 -10
  52. package/scripts/uninstall.mjs +369 -48
  53. package/scripts/upgrade.mjs +766 -195
  54. package/scripts/verify.mjs +24 -14
  55. package/scripts/weekly-report.mjs +211 -0
  56. package/skills/crystallize/SKILL.md +24 -7
  57. package/skills/graph/SKILL.md +4 -0
  58. package/skills/ingest/SKILL.md +29 -5
  59. package/skills/lint/SKILL.md +4 -0
  60. package/skills/query/SKILL.md +4 -0
  61. package/skills/verify/SKILL.md +4 -0
  62. package/templates/.hypoignore +19 -2
  63. package/templates/Home.md +2 -0
  64. package/templates/SCHEMA.md +61 -6
  65. package/templates/extensions/agents/.gitkeep +0 -0
  66. package/templates/extensions/commands/.gitkeep +0 -0
  67. package/templates/extensions/hooks/.gitkeep +0 -0
  68. package/templates/extensions/skills/.gitkeep +0 -0
  69. package/templates/gitignore +5 -0
  70. package/templates/hot.md +2 -0
  71. package/templates/hypo-config.md +1 -1
  72. package/templates/hypo-guide.md +63 -1
  73. package/templates/hypo-help.md +1 -1
  74. package/templates/pages/observability/_index.md +77 -0
  75. package/templates/projects/_template/index.md +2 -2
  76. package/templates/projects/_template/prd.md +1 -1
package/scripts/lint.mjs CHANGED
@@ -18,6 +18,8 @@ import { join, relative, extname, basename } from 'path';
18
18
  import { resolveHypoRoot, expandHome } from './lib/hypo-root.mjs';
19
19
  import { SESSION_STATE_NEXT_HEADINGS } from '../hooks/hypo-shared.mjs';
20
20
  import { loadHypoIgnore, isIgnored } from './lib/hypo-ignore.mjs';
21
+ import { parseSchemaVocab, checkForbidden, parseSchemaPageDirs } from './lib/schema-vocab.mjs';
22
+ import { findDesignHistoryStale } from './lib/design-history-stale.mjs';
21
23
 
22
24
  // ── arg parsing ──────────────────────────────────────────────────────────────
23
25
 
@@ -25,8 +27,8 @@ function parseArgs(argv) {
25
27
  const args = { hypoDir: null, json: false, fix: false };
26
28
  for (const arg of argv.slice(2)) {
27
29
  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
+ else if (arg === '--json') args.json = true;
31
+ else if (arg === '--fix') args.fix = true;
30
32
  }
31
33
  if (!args.hypoDir) args.hypoDir = resolveHypoRoot();
32
34
  return args;
@@ -41,11 +43,69 @@ function parseFrontmatter(content) {
41
43
  for (const line of m[1].split('\n')) {
42
44
  const idx = line.indexOf(':');
43
45
  if (idx < 0) continue;
44
- fm[line.slice(0, idx).trim()] = line.slice(idx + 1).trim().replace(/^["']|["']$/g, '');
46
+ fm[line.slice(0, idx).trim()] = line
47
+ .slice(idx + 1)
48
+ .trim()
49
+ .replace(/^["']|["']$/g, '');
45
50
  }
46
51
  return fm;
47
52
  }
48
53
 
54
+ function parseTagsField(rawValue) {
55
+ if (rawValue == null) return null;
56
+ const trimmed = String(rawValue).trim();
57
+ if (!trimmed) return [];
58
+ if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
59
+ return trimmed
60
+ .slice(1, -1)
61
+ .split(',')
62
+ .map((t) => t.trim().replace(/^["']|["']$/g, ''))
63
+ .filter(Boolean);
64
+ }
65
+ return trimmed
66
+ .split(',')
67
+ .map((t) => t.trim().replace(/^["']|["']$/g, ''))
68
+ .filter(Boolean);
69
+ }
70
+
71
+ // type-conditional required fields (spec §6.3, SCHEMA.md §2)
72
+ const TYPE_CONDITIONAL_FIELDS = {
73
+ prd: ['status', 'started'],
74
+ adr: ['source', 'status', 'date'],
75
+ 'project-index': ['working_dir', 'status', 'started'],
76
+ 'tool-eval': ['status'],
77
+ postmortem: ['outcome'],
78
+ learning: ['source'],
79
+ // feedback: ADR 0031 / fix #37 — projection SoT requires full classification
80
+ feedback: [
81
+ 'status',
82
+ 'scope',
83
+ 'tier',
84
+ 'targets',
85
+ 'sensitivity',
86
+ 'priority',
87
+ 'memory_summary',
88
+ 'reason',
89
+ 'source',
90
+ ],
91
+ 'prompt-pattern': ['source'],
92
+ 'source-summary': ['sources'],
93
+ 'weekly-journal': ['week'],
94
+ };
95
+
96
+ const TYPE_ENUM_FIELDS = {
97
+ prd: { status: ['draft', 'active', 'completed', 'cancelled', 'archived'] },
98
+ adr: { status: ['proposed', 'accepted', 'deprecated', 'superseded'] },
99
+ 'tool-eval': { status: ['adopted', 'evaluating', 'rejected'] },
100
+ // feedback: ADR 0031 / fix #37 — sensitivity:private is forbidden (wiki is a
101
+ // git-pushed public surface)
102
+ feedback: {
103
+ status: ['active', 'superseded', 'archived'],
104
+ tier: ['L1', 'L2'],
105
+ sensitivity: ['public', 'sanitized'],
106
+ },
107
+ };
108
+
49
109
  // ── page collector ────────────────────────────────────────────────────────────
50
110
 
51
111
  function collectPages(dir, root, pages = [], ignorePatterns = []) {
@@ -55,6 +115,11 @@ function collectPages(dir, root, pages = [], ignorePatterns = []) {
55
115
  if (isIgnored(full, root, ignorePatterns)) continue;
56
116
  const st = statSync(full);
57
117
  if (st.isDirectory()) {
118
+ // `_`-prefixed dirs (e.g. pages/feedback/_drafts) hold scaffolds / scratch
119
+ // not yet promoted to content — skip so incomplete frontmatter never errors
120
+ // (mirrors loadFeedbackPages in feedback-sync.mjs). `_`-prefixed *files*
121
+ // (e.g. _index.md) are still linted.
122
+ if (entry.startsWith('_')) continue;
58
123
  collectPages(full, root, pages, ignorePatterns);
59
124
  } else if (extname(entry) === '.md' && !entry.startsWith('.')) {
60
125
  pages.push({ path: full, rel: relative(root, full) });
@@ -87,11 +152,11 @@ function buildSlugMap(pages) {
87
152
  // wikilinks between them.
88
153
  function stripNonWikilinkRegions(content) {
89
154
  let out = content;
90
- out = out.replace(/^[ \t]{0,3}```[\s\S]*?^[ \t]{0,3}```/gm, m => m.replace(/[^\n]/g, ' '));
91
- out = out.replace(/^[ \t]{0,3}~~~[\s\S]*?^[ \t]{0,3}~~~/gm, m => m.replace(/[^\n]/g, ' '));
92
- out = out.replace(/<!--[\s\S]*?-->/g, m => m.replace(/[^\n]/g, ' '));
93
- out = out.replace(/``[^`\n]*``/g, m => ' '.repeat(m.length));
94
- out = out.replace(/`[^`\n]*`/g, m => ' '.repeat(m.length));
155
+ out = out.replace(/^[ \t]{0,3}```[\s\S]*?^[ \t]{0,3}```/gm, (m) => m.replace(/[^\n]/g, ' '));
156
+ out = out.replace(/^[ \t]{0,3}~~~[\s\S]*?^[ \t]{0,3}~~~/gm, (m) => m.replace(/[^\n]/g, ' '));
157
+ out = out.replace(/<!--[\s\S]*?-->/g, (m) => m.replace(/[^\n]/g, ' '));
158
+ out = out.replace(/``[^`\n]*``/g, (m) => ' '.repeat(m.length));
159
+ out = out.replace(/`[^`\n]*`/g, (m) => ' '.repeat(m.length));
95
160
  return out;
96
161
  }
97
162
 
@@ -108,16 +173,33 @@ function extractWikilinks(content) {
108
173
 
109
174
  const REQUIRED_FIELDS = ['title', 'type'];
110
175
  const VALID_TYPES = [
111
- 'concept', 'source-summary', 'entity', 'tool-eval', 'prompt-pattern',
112
- 'playbook', 'learning', 'tip', 'feedback', 'reference', 'synthesis',
113
- 'weekly-journal', 'prd', 'adr', 'session-log', 'session-state',
114
- 'project-index', 'postmortem', 'open-questions', 'schema', 'source',
176
+ 'concept',
177
+ 'source-summary',
178
+ 'entity',
179
+ 'tool-eval',
180
+ 'prompt-pattern',
181
+ 'playbook',
182
+ 'learning',
183
+ 'tip',
184
+ 'feedback',
185
+ 'reference',
186
+ 'synthesis',
187
+ 'weekly-journal',
188
+ 'prd',
189
+ 'adr',
190
+ 'session-log',
191
+ 'session-state',
192
+ 'project-index',
193
+ 'postmortem',
194
+ 'open-questions',
195
+ 'schema',
196
+ 'source',
115
197
  ];
116
198
 
117
199
  const issues = [];
118
200
 
119
- function issue(severity, rel, msg, fullPath = null) {
120
- issues.push({ severity, file: rel, message: msg, path: fullPath });
201
+ function issue(severity, rel, msg, fullPath = null, id = null) {
202
+ issues.push({ severity, file: rel, message: msg, path: fullPath, id });
121
203
  }
122
204
 
123
205
  function hasHeading(content, heading) {
@@ -128,18 +210,43 @@ function hasHeading(content, heading) {
128
210
  function lintSessionStateHeadings(content, rel) {
129
211
  if (!rel.match(/^projects\/[^/]+\/session-state\.md$/)) return;
130
212
 
131
- if (!SESSION_STATE_NEXT_HEADINGS.some(heading => hasHeading(content, heading))) {
213
+ if (!SESSION_STATE_NEXT_HEADINGS.some((heading) => hasHeading(content, heading))) {
132
214
  issue(
133
215
  'error',
134
216
  rel,
135
- `Missing required session-state heading: one of ${SESSION_STATE_NEXT_HEADINGS.map(h => `## ${h}`).join(', ')}`
217
+ `Missing required session-state heading: one of ${SESSION_STATE_NEXT_HEADINGS.map((h) => `## ${h}`).join(', ')}`,
136
218
  );
137
219
  }
138
220
  }
139
221
 
140
- function lintPage({ path, rel }, slugMap) {
222
+ function lintPage({ path, rel }, slugMap, tagVocab, pageDirs) {
223
+ // Directory whitelist: a content page under pages/<subdir>/ must live in a
224
+ // SCHEMA-defined directory. Catches typo'd dirs (e.g. pages/learning/ vs the
225
+ // canonical pages/learnings/) regardless of frontmatter, since a directory
226
+ // absent from SCHEMA breaks wikilink resolution and crystallize routing.
227
+ // `_`-prefixed files (e.g. pages/observability/_index.md) are scaffold/section
228
+ // markers, not content — exempt them so packaged scaffold dirs that aren't a
229
+ // page *type* (e.g. observability, a topical grouping of reference pages) don't
230
+ // trip the guard. A real typo dir is created with content, not just an index.
231
+ // Skipped entirely when pageDirs is empty (SCHEMA.md or the table absent) for
232
+ // back-compat with minimal wikis — mirrors the tag-vocab check.
233
+ if (pageDirs && pageDirs.size > 0 && !basename(rel).startsWith('_')) {
234
+ const segs = rel.replace(/\\/g, '/').split('/');
235
+ if (segs[0] === 'pages' && segs.length > 2 && !pageDirs.has(segs[1])) {
236
+ issue(
237
+ 'error',
238
+ rel,
239
+ `Undefined pages/ directory: "pages/${segs[1]}/" not in SCHEMA.md (likely a typo — defined: ${[...pageDirs].sort().join(', ')})`,
240
+ );
241
+ }
242
+ }
243
+
141
244
  let content;
142
- try { content = readFileSync(path, 'utf-8'); } catch { return; }
245
+ try {
246
+ content = readFileSync(path, 'utf-8');
247
+ } catch {
248
+ return;
249
+ }
143
250
 
144
251
  if (!content.match(/^---\r?\n/)) {
145
252
  issue('warn', rel, 'No frontmatter found');
@@ -164,6 +271,63 @@ function lintPage({ path, rel }, slugMap) {
164
271
  issue('warn', rel, 'Missing frontmatter field: updated', path);
165
272
  }
166
273
 
274
+ // type-conditional required fields
275
+ if (fm.type && TYPE_CONDITIONAL_FIELDS[fm.type]) {
276
+ for (const field of TYPE_CONDITIONAL_FIELDS[fm.type]) {
277
+ if (!fm[field]) {
278
+ issue('error', rel, `Missing required field for type "${fm.type}": ${field}`);
279
+ }
280
+ }
281
+ }
282
+
283
+ // type-specific enum validation
284
+ if (fm.type && TYPE_ENUM_FIELDS[fm.type]) {
285
+ for (const [field, allowed] of Object.entries(TYPE_ENUM_FIELDS[fm.type])) {
286
+ if (fm[field] && !allowed.includes(fm[field])) {
287
+ issue(
288
+ 'error',
289
+ rel,
290
+ `Invalid value for ${field} on type "${fm.type}": "${fm[field]}" (allowed: ${allowed.join(', ')})`,
291
+ );
292
+ }
293
+ }
294
+ }
295
+
296
+ // feedback: scope vocabulary + conditional claude-learned fields (ADR 0031)
297
+ if (fm.type === 'feedback') {
298
+ const scope = fm.scope || '';
299
+ if (scope && scope !== 'global' && !/^project:[a-z0-9][a-z0-9-]*$/.test(scope)) {
300
+ issue('error', rel, `Invalid feedback scope: "${scope}" (allowed: global | project:<slug>)`);
301
+ }
302
+ const fbTargets = parseTagsField(fm.targets) || [];
303
+ if (fbTargets.includes('claude-learned')) {
304
+ for (const field of ['global_summary', 'promote_to_global']) {
305
+ if (!fm[field]) {
306
+ issue(
307
+ 'error',
308
+ rel,
309
+ `Missing required field for feedback with targets:claude-learned: ${field}`,
310
+ );
311
+ }
312
+ }
313
+ }
314
+ }
315
+
316
+ // tag vocabulary + forbidden patterns
317
+ const tags = parseTagsField(fm.tags);
318
+ if (tags && tagVocab && tagVocab.size > 0) {
319
+ for (const tag of tags) {
320
+ const forbidden = checkForbidden(tag);
321
+ if (forbidden) {
322
+ issue('error', rel, `Forbidden tag pattern (${forbidden}): "${tag}"`);
323
+ continue;
324
+ }
325
+ if (!tagVocab.has(tag)) {
326
+ issue('error', rel, `Unknown tag: "${tag}" (not in SCHEMA.md Tag Vocabulary)`);
327
+ }
328
+ }
329
+ }
330
+
167
331
  lintSessionStateHeadings(content, rel);
168
332
 
169
333
  for (const link of extractWikilinks(content)) {
@@ -178,17 +342,38 @@ function lintPage({ path, rel }, slugMap) {
178
342
  const args = parseArgs(process.argv);
179
343
 
180
344
  const ignorePatterns = loadHypoIgnore(args.hypoDir);
181
- const scanDirs = ['pages', 'projects'].map(d => join(args.hypoDir, d));
182
- const pages = scanDirs.flatMap(d => collectPages(d, args.hypoDir, [], ignorePatterns));
345
+ const scanDirs = ['pages', 'projects', 'journal'].map((d) => join(args.hypoDir, d));
346
+ const pages = scanDirs.flatMap((d) => collectPages(d, args.hypoDir, [], ignorePatterns));
183
347
  const slugMap = buildSlugMap(pages);
184
-
185
- for (const page of pages) lintPage(page, slugMap);
348
+ const tagVocab = parseSchemaVocab(args.hypoDir);
349
+ const pageDirs = parseSchemaPageDirs(args.hypoDir);
350
+
351
+ for (const page of pages) lintPage(page, slugMap, tagVocab, pageDirs);
352
+
353
+ // W8: design-history.md stale relative to session-log.md. Emitted once per
354
+ // project (not per page) — runs outside the page loop. POSIX-separated path
355
+ // literal (not path.join) so consumers can rely on `file.split('/')` shape
356
+ // regardless of host OS — `hooks/hypo-personal-check.mjs:246` depends on this.
357
+ for (const s of findDesignHistoryStale(args.hypoDir)) {
358
+ const gap = s.diffDays != null ? ` (${s.diffDays}일 차이)` : '';
359
+ issue(
360
+ 'warn',
361
+ `projects/${s.project}/design-history.md`,
362
+ `design-history stale: session-log 최신=${s.lastSession} > design-history 최신=${s.lastDesignHistory}${gap} — projects/${s.project}/design-history.md에 설계 변경 사항을 append 하세요`,
363
+ null,
364
+ 'W8',
365
+ );
366
+ }
186
367
 
187
368
  if (args.fix) {
188
369
  const today = new Date().toISOString().slice(0, 10);
189
370
  const fixed = new Set();
190
371
  for (const iss of issues) {
191
- if (iss.severity === 'warn' && iss.message === 'Missing frontmatter field: updated' && iss.path) {
372
+ if (
373
+ iss.severity === 'warn' &&
374
+ iss.message === 'Missing frontmatter field: updated' &&
375
+ iss.path
376
+ ) {
192
377
  const content = readFileSync(iss.path, 'utf-8');
193
378
  const fmMatch = /^---\r?\n[\s\S]*?\r?\n---/.exec(content);
194
379
  if (fmMatch) {
@@ -196,25 +381,47 @@ if (args.fix) {
196
381
  const closingTag = `${lineEnding}---`;
197
382
  const insertAt = fmMatch.index + fmMatch[0].lastIndexOf(closingTag);
198
383
  if (insertAt < 0) continue;
199
- const fixedContent = content.slice(0, insertAt) + `${lineEnding}updated: ${today}` + content.slice(insertAt);
384
+ const fixedContent =
385
+ content.slice(0, insertAt) + `${lineEnding}updated: ${today}` + content.slice(insertAt);
200
386
  writeFileSync(iss.path, fixedContent);
201
387
  fixed.add(iss.path);
202
388
  }
203
389
  }
204
390
  }
205
391
  if (fixed.size > 0) {
206
- issues.splice(0, issues.length, ...issues.filter(
207
- i => !(i.severity === 'warn' && i.message === 'Missing frontmatter field: updated' && fixed.has(i.path))
208
- ));
392
+ issues.splice(
393
+ 0,
394
+ issues.length,
395
+ ...issues.filter(
396
+ (i) =>
397
+ !(
398
+ i.severity === 'warn' &&
399
+ i.message === 'Missing frontmatter field: updated' &&
400
+ fixed.has(i.path)
401
+ ),
402
+ ),
403
+ );
209
404
  }
210
405
  }
211
406
 
212
- const errors = issues.filter(i => i.severity === 'error');
213
- const warns = issues.filter(i => i.severity === 'warn');
407
+ const errors = issues.filter((i) => i.severity === 'error');
408
+ const warns = issues.filter((i) => i.severity === 'warn');
214
409
 
215
410
  if (args.json) {
216
- const toOut = ({ severity, file, message }) => ({ severity, file, message });
217
- console.log(JSON.stringify({ ok: errors.length === 0, errors: errors.map(toOut), warns: warns.map(toOut), total: issues.length }, null, 2));
411
+ const toOut = ({ severity, file, message, id }) =>
412
+ id ? { severity, file, message, id } : { severity, file, message };
413
+ console.log(
414
+ JSON.stringify(
415
+ {
416
+ ok: errors.length === 0,
417
+ errors: errors.map(toOut),
418
+ warns: warns.map(toOut),
419
+ total: issues.length,
420
+ },
421
+ null,
422
+ 2,
423
+ ),
424
+ );
218
425
  } else {
219
426
  if (issues.length === 0) {
220
427
  console.log('✓ No lint issues found');
package/scripts/query.mjs CHANGED
@@ -27,9 +27,9 @@ function parseArgs(argv) {
27
27
  const args = { hypoDir: null, query: null, limit: 10, json: false };
28
28
  for (const arg of argv.slice(2)) {
29
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;
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
33
  }
34
34
  if (!args.hypoDir) args.hypoDir = resolveHypoRoot();
35
35
  return args;
@@ -57,12 +57,17 @@ function parseFrontmatter(content) {
57
57
  for (const line of m[1].split('\n')) {
58
58
  const idx = line.indexOf(':');
59
59
  if (idx < 0) continue;
60
- fm[line.slice(0, idx).trim()] = line.slice(idx + 1).trim().replace(/^["']|["']$/g, '');
60
+ fm[line.slice(0, idx).trim()] = line
61
+ .slice(idx + 1)
62
+ .trim()
63
+ .replace(/^["']|["']$/g, '');
61
64
  }
62
65
  return fm;
63
66
  }
64
67
 
65
- function escapeRegex(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }
68
+ function escapeRegex(s) {
69
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
70
+ }
66
71
 
67
72
  function scoreAndExcerpt(content, terms) {
68
73
  const lower = content.toLowerCase();
@@ -73,7 +78,7 @@ function scoreAndExcerpt(content, terms) {
73
78
  const lines = content.split('\n');
74
79
  let excerpt = '';
75
80
  for (const line of lines) {
76
- if (terms.some(t => line.toLowerCase().includes(t))) {
81
+ if (terms.some((t) => line.toLowerCase().includes(t))) {
77
82
  excerpt = line.trim().slice(0, 120);
78
83
  break;
79
84
  }
@@ -92,18 +97,28 @@ if (!args.query) {
92
97
 
93
98
  const terms = args.query.toLowerCase().split(/\s+/).filter(Boolean);
94
99
  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));
100
+ const scanDirs = ['pages', 'projects'].map((d) => join(args.hypoDir, d));
101
+ const files = scanDirs.flatMap((d) => collectMdFiles(d, args.hypoDir, [], ignorePatterns));
97
102
 
98
103
  const results = [];
99
104
 
100
105
  for (const { path, rel } of files) {
101
106
  let content;
102
- try { content = readFileSync(path, 'utf-8'); } catch { continue; }
107
+ try {
108
+ content = readFileSync(path, 'utf-8');
109
+ } catch {
110
+ continue;
111
+ }
103
112
  const { score, excerpt } = scoreAndExcerpt(content, terms);
104
113
  if (score === 0) continue;
105
114
  const fm = parseFrontmatter(content);
106
- results.push({ slug: rel.replace(/\.md$/, ''), title: fm.title || rel, type: fm.type || '', score, excerpt });
115
+ results.push({
116
+ slug: rel.replace(/\.md$/, ''),
117
+ title: fm.title || rel,
118
+ type: fm.type || '',
119
+ score,
120
+ excerpt,
121
+ });
107
122
  }
108
123
 
109
124
  results.sort((a, b) => b.score - a.score);
@@ -114,6 +129,7 @@ if (args.json) {
114
129
  } else {
115
130
  if (top.length === 0) {
116
131
  console.log(`No results for: ${args.query}`);
132
+ console.log(`→ 관련 페이지가 없습니다. 새 지식이 있다면 /hypo:ingest 로 추가해 보세요.`);
117
133
  } else {
118
134
  console.log(`Found ${results.length} result(s) for "${args.query}" (showing ${top.length}):\n`);
119
135
  for (const r of top) {
@@ -23,9 +23,9 @@ import { resolveHypoRoot, expandHome } from './lib/hypo-root.mjs';
23
23
  function parseArgs(argv) {
24
24
  const args = { hypoDir: null, project: null, json: false };
25
25
  for (const arg of argv.slice(2)) {
26
- if (arg.startsWith('--hypo-dir=')) args.hypoDir = expandHome(arg.slice(11));
26
+ if (arg.startsWith('--hypo-dir=')) args.hypoDir = expandHome(arg.slice(11));
27
27
  else if (arg.startsWith('--project=')) args.project = arg.slice(10);
28
- else if (arg === '--json') args.json = true;
28
+ else if (arg === '--json') args.json = true;
29
29
  }
30
30
  if (!args.hypoDir) args.hypoDir = resolveHypoRoot();
31
31
  return args;
@@ -40,8 +40,11 @@ function resolveActiveProject(hypoDir) {
40
40
  const content = readFileSync(hotPath, 'utf-8');
41
41
  // Canonical hot.md uses wikilinks: | name | date | [[projects/slug/hot]] |
42
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] }));
43
+ const wikiRows = [
44
+ ...content.matchAll(
45
+ /\|\s*([^|]+?)\s*\|\s*(\d{4}-\d{2}-\d{2})?\s*\|\s*\[\[projects\/([^\]/]+)\/[^\]]+\]\]/g,
46
+ ),
47
+ ].map((m) => ({ name: m[1].trim(), date: m[2] || '', slug: m[3] }));
45
48
  if (wikiRows.length > 0) {
46
49
  wikiRows.sort((a, b) => b.date.localeCompare(a.date));
47
50
  return wikiRows[0].slug;
@@ -60,7 +63,10 @@ function resolveActiveProject(hypoDir) {
60
63
  const ssPath = join(projectsDir, p, 'session-state.md');
61
64
  if (!existsSync(ssPath)) continue;
62
65
  const mtime = statSync(ssPath).mtimeMs;
63
- if (mtime > latestMtime) { latestMtime = mtime; latest = p; }
66
+ if (mtime > latestMtime) {
67
+ latestMtime = mtime;
68
+ latest = p;
69
+ }
64
70
  }
65
71
  return latest;
66
72
  }