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
@@ -8,48 +8,233 @@
8
8
  */
9
9
 
10
10
  import { readFileSync, writeFileSync, existsSync, readdirSync, statSync } from 'fs';
11
- import { homedir, tmpdir } from 'os';
12
- import { join } from 'path';
13
- import { spawnSync } from 'child_process';
14
- import { HYPO_DIR, buildOutput, SESSION_STATE_NEXT_HEADINGS } from './hypo-shared.mjs';
11
+ import { homedir } from 'os';
12
+ import { join, dirname } from 'path';
13
+ import { fileURLToPath } from 'url';
14
+ import { spawnSync, spawn } from 'child_process';
15
+ import {
16
+ HYPO_DIR,
17
+ buildOutput,
18
+ SESSION_STATE_NEXT_HEADINGS,
19
+ formatGrowthMetrics,
20
+ readSyncState,
21
+ clearSyncState,
22
+ readClearMarker,
23
+ clearClearMarker,
24
+ loadHypoIgnore,
25
+ isIgnored,
26
+ sessionMarkerPath,
27
+ shouldSuggestProjectCreation,
28
+ buildProjectSuggestionLine,
29
+ recordSuggestionCooldown,
30
+ } from './hypo-shared.mjs';
31
+ import {
32
+ defaultCachePath,
33
+ detectChannel,
34
+ readCache,
35
+ cacheIsFresh,
36
+ computeNotice,
37
+ markNotified,
38
+ isOptedOut,
39
+ } from './version-check.mjs';
40
+
41
+ // Privacy guard: refuse to read+inject .hypoignore-matched
42
+ // wiki files into additionalContext. Without this, a user who lists
43
+ // `projects/private/hot.md` in .hypoignore would still see SECRET emit because
44
+ // session-start reads hot/state paths directly.
45
+ function readIfNotIgnored(path, maxChars, patterns) {
46
+ if (!path) return null;
47
+ if (patterns.length > 0 && isIgnored(path, HYPO_DIR, patterns)) return null;
48
+ return readFileSync(path, 'utf-8').slice(0, maxChars);
49
+ }
50
+
51
+ // Directory of the running hook, and the install root one level up
52
+ // (<root>/hooks/...). The root is derived from the RUNNING hook path rather
53
+ // than ~/.claude/hypo-pkg.json so a dual install (npm + plugin) or a stale
54
+ // metadata file can't mislabel the channel (teams review (b), 2026-05-21).
55
+ const HOOK_DIR = dirname(fileURLToPath(import.meta.url));
56
+ const ACTIVE_ROOT = dirname(HOOK_DIR);
57
+
58
+ function readInstalledVersion(root) {
59
+ try {
60
+ return JSON.parse(readFileSync(join(root, 'package.json'), 'utf-8')).version || null;
61
+ } catch {
62
+ return null;
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Update-notifier (teams-reviewed 2026-05-21). Reads ONLY the cache — never a
68
+ * synchronous network call. When the cache is stale, fires a detached worker to
69
+ * refresh it (shown next session). Fully best-effort: any failure returns ''.
70
+ */
71
+ function buildUpdateNotice() {
72
+ try {
73
+ if (isOptedOut()) return '';
74
+ const cachePath = defaultCachePath();
75
+
76
+ let root = ACTIVE_ROOT;
77
+ let version = readInstalledVersion(root);
78
+ if (!version) {
79
+ try {
80
+ const meta = JSON.parse(readFileSync(join(homedir(), '.claude', 'hypo-pkg.json'), 'utf-8'));
81
+ root = meta.pkgRoot || root;
82
+ version = meta.pkgVersion || readInstalledVersion(root);
83
+ } catch {
84
+ /* fallback unavailable */
85
+ }
86
+ }
87
+ if (!version) return '';
88
+
89
+ const channel = detectChannel(root);
90
+ const cache = readCache(cachePath);
91
+
92
+ if (!cacheIsFresh(cache)) {
93
+ try {
94
+ const worker = join(HOOK_DIR, 'version-check-fetch.mjs');
95
+ if (existsSync(worker)) {
96
+ const child = spawn(process.execPath, [worker, cachePath], {
97
+ detached: true,
98
+ stdio: 'ignore',
99
+ });
100
+ // spawn() failures (EAGAIN/EMFILE/ENOENT) surface ASYNChronously on
101
+ // the child's 'error' event — the try/catch above only catches the
102
+ // synchronous throw. Without this listener an unhandled 'error' would
103
+ // crash SessionStart, violating the best-effort contract.
104
+ child.on('error', () => {});
105
+ child.unref();
106
+ }
107
+ } catch {
108
+ /* spawn is best-effort */
109
+ }
110
+ }
111
+
112
+ const notice = computeNotice(cache, channel, version);
113
+ if (!notice) return '';
114
+ markNotified(cachePath, channel, notice.latest);
115
+ return notice.line;
116
+ } catch {
117
+ return '';
118
+ }
119
+ }
15
120
 
16
121
  const PROJECTS_DIR = join(HYPO_DIR, 'projects');
122
+ const GROWTH_CACHE = join(HYPO_DIR, '.cache', 'last-session-growth.json');
123
+
124
+ function readLastGrowthLine() {
125
+ if (!existsSync(GROWTH_CACHE)) return '';
126
+ try {
127
+ const stats = JSON.parse(readFileSync(GROWTH_CACHE, 'utf-8'));
128
+ return formatGrowthMetrics('start', stats);
129
+ } catch {
130
+ return '';
131
+ }
132
+ }
133
+
134
+ /**
135
+ * fix #25 PR-A2 (ADR 0022 amendment 2026-05-14): if the prior session ended
136
+ * via `/clear`, hypo-session-end stashed its identity in `.cache/clear-marker.json`.
137
+ * Read it (with 7-day stale guard), unlink it (one-shot), and return a
138
+ * `[WIKI_AUTOCLOSE]` recovery line for additionalContext + stderr.
139
+ *
140
+ * @param {string|undefined} source SessionStart payload `source` field
141
+ * @returns {string} recovery line, or '' when no recovery is needed
142
+ */
143
+ function buildClearRecoveryLine(source) {
144
+ if (source !== 'clear') return '';
145
+ const marker = readClearMarker(HYPO_DIR);
146
+ if (!marker) return '';
147
+ clearClearMarker(HYPO_DIR);
148
+ const prevId = marker.prev_session_id || 'unknown';
149
+ const prevTr = marker.prev_transcript_path || null;
150
+ const prevCwd = marker.prev_cwd || null;
151
+ const trLine = prevTr ? `\n prev_transcript: ${prevTr}` : '';
152
+ const cwdLine = prevCwd ? `\n prev_cwd: ${prevCwd}` : '';
153
+ return (
154
+ `[WIKI_AUTOCLOSE] 이전 세션(${prevId})이 /clear로 강제 종료됨.${trLine}${cwdLine}\n` +
155
+ ` session-close가 미완료라면 지금 즉시 실행할 것 ` +
156
+ `(hot.md + session-state.md + log.md 최소 갱신).`
157
+ );
158
+ }
17
159
 
160
+ /** Pull the wiki repo. Returns true only when the pull actually succeeded. */
18
161
  function gitPull(dir) {
19
- if (!existsSync(join(dir, '.git'))) return;
20
- spawnSync('git', ['-C', dir, 'pull', '--ff-only', '--quiet'], { stdio: 'pipe', timeout: 10000 });
162
+ if (!existsSync(join(dir, '.git'))) return false;
163
+ const r = spawnSync('git', ['-C', dir, 'pull', '--ff-only', '--quiet'], {
164
+ stdio: 'pipe',
165
+ timeout: 10000,
166
+ });
167
+ return r.status === 0;
168
+ }
169
+
170
+ /**
171
+ * fix #10: surface unresolved sync failures recorded by a prior session's
172
+ * Stop hook (#9). The entry is cleared only once this session's pull has
173
+ * succeeded AND there is no unpushed commit left behind by a failed push
174
+ * (`[ahead N]`).
175
+ *
176
+ * Resolution deliberately checks only the ahead-of-remote state, not the full
177
+ * working tree: uncommitted/untracked files are not a sync failure, and a
178
+ * fresh `hypo init` wiki does not git-ignore `.cache/`, so a broader cleanliness
179
+ * check would see the sync-state file itself and never clear.
180
+ *
181
+ * @returns {string} a `[WIKI: last sync failed: ...]` line, or '' when clear.
182
+ */
183
+ function syncStateNotice(pullOk) {
184
+ const { entries, parseError } = readSyncState(HYPO_DIR);
185
+ // A corrupt JSONL file is still an "open" failure — surface it (doctor warns
186
+ // too) but never clear it, so the unreadable record survives for inspection.
187
+ if (parseError) return '[WIKI: last sync failed: sync-state.json unreadable — inspect manually]';
188
+ if (entries.length === 0) return '';
189
+ let resolved = false;
190
+ if (pullOk) {
191
+ const r = spawnSync('git', ['-C', HYPO_DIR, 'status', '--branch', '--porcelain'], {
192
+ encoding: 'utf-8',
193
+ timeout: 10000,
194
+ });
195
+ resolved = r.status === 0 && !/\[ahead \d+\]/.test(r.stdout || '');
196
+ }
197
+ if (resolved) {
198
+ clearSyncState(HYPO_DIR);
199
+ return '';
200
+ }
201
+ const last = entries[entries.length - 1];
202
+ return `[WIKI: last sync failed: ${last.op || '?'} — ${last.error || 'unknown'}]`;
21
203
  }
22
- const GLOBAL_HOT = join(HYPO_DIR, 'hot.md');
23
- const HOT_CHARS = 2000;
24
- const STATE_CHARS = 2000;
204
+ const GLOBAL_HOT = join(HYPO_DIR, 'hot.md');
205
+ const HOT_CHARS = 2000;
206
+ const STATE_CHARS = 2000;
25
207
 
26
208
  function parseFrontmatterField(content, key) {
27
209
  const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
28
210
  if (!match) return null;
29
- const line = match[1].split('\n').find(l => l.startsWith(`${key}:`));
211
+ const line = match[1].split('\n').find((l) => l.startsWith(`${key}:`));
30
212
  if (!line) return null;
31
- return line.slice(key.length + 1).trim().replace(/^['"]|['"]$/g, '');
213
+ return line
214
+ .slice(key.length + 1)
215
+ .trim()
216
+ .replace(/^['"]|['"]$/g, '');
32
217
  }
33
218
 
34
219
  function findProjectFiles(cwd) {
35
220
  if (!existsSync(PROJECTS_DIR)) return null;
36
221
  for (const proj of readdirSync(PROJECTS_DIR)) {
37
- const projDir = join(PROJECTS_DIR, proj);
222
+ const projDir = join(PROJECTS_DIR, proj);
38
223
  if (!statSync(projDir).isDirectory()) continue;
39
224
  const indexPath = join(projDir, 'index.md');
40
225
  if (!existsSync(indexPath)) continue;
41
- const content = readFileSync(indexPath, 'utf-8');
226
+ const content = readFileSync(indexPath, 'utf-8');
42
227
  const workingDir = parseFrontmatterField(content, 'working_dir');
43
228
  if (!workingDir) continue;
44
229
  const resolved = workingDir.startsWith('~/')
45
230
  ? join(homedir(), workingDir.slice(2))
46
231
  : workingDir;
47
232
  if (cwd === resolved || cwd.startsWith(resolved + '/')) {
48
- const hotPath = join(projDir, 'hot.md');
233
+ const hotPath = join(projDir, 'hot.md');
49
234
  const statePath = join(projDir, 'session-state.md');
50
235
  return {
51
236
  proj,
52
- hotPath: existsSync(hotPath) ? hotPath : null,
237
+ hotPath: existsSync(hotPath) ? hotPath : null,
53
238
  statePath: existsSync(statePath) ? statePath : null,
54
239
  };
55
240
  }
@@ -71,18 +256,20 @@ function printTerminalSummary(proj, hotContent, stateContent) {
71
256
  const nextFromState = stateContent
72
257
  ? extractSection(stateContent, SESSION_STATE_NEXT_HEADINGS)
73
258
  : null;
74
- const next = nextFromState
75
- ?? extractSection(hotContent ?? '', SESSION_STATE_NEXT_HEADINGS);
259
+ const next = nextFromState ?? extractSection(hotContent ?? '', SESSION_STATE_NEXT_HEADINGS);
76
260
  const prev = hotContent
77
- ? (extractSection(hotContent, '직전 세션 \\([^)]+\\)')
78
- ?? extractSection(hotContent, '직전 세션.*')
79
- ?? extractSection(hotContent, 'Last Session.*'))
261
+ ? (extractSection(hotContent, '직전 세션 \\([^)]+\\)') ??
262
+ extractSection(hotContent, '직전 세션.*') ??
263
+ extractSection(hotContent, 'Last Session.*'))
80
264
  : null;
81
265
  const lines = ['', `\x1b[36m[Hypomnema]\x1b[0m project: \x1b[1m${proj}\x1b[0m`];
82
266
  if (prev) lines.push(` prev: ${prev.split('\n')[0].replace(/^\*\*|\*\*$/g, '')}`);
83
267
  if (next) {
84
268
  lines.push(' next:');
85
- next.split('\n').slice(0, 20).forEach(l => lines.push(` ${l}`));
269
+ next
270
+ .split('\n')
271
+ .slice(0, 20)
272
+ .forEach((l) => lines.push(` ${l}`));
86
273
  }
87
274
  lines.push('');
88
275
  process.stderr.write(lines.join('\n'));
@@ -90,52 +277,133 @@ function printTerminalSummary(proj, hotContent, stateContent) {
90
277
 
91
278
  let raw = '';
92
279
  process.stdin.setEncoding('utf-8');
93
- process.stdin.on('data', chunk => raw += chunk);
280
+ process.stdin.on('data', (chunk) => (raw += chunk));
94
281
  process.stdin.on('end', () => {
95
282
  try {
96
283
  let data = {};
97
- try { data = JSON.parse(raw); } catch {}
284
+ try {
285
+ data = JSON.parse(raw);
286
+ } catch {}
98
287
 
99
- gitPull(HYPO_DIR);
288
+ const pullOk = gitPull(HYPO_DIR);
289
+ const syncLine = syncStateNotice(pullOk);
290
+ const growthLine = readLastGrowthLine();
291
+ // fix #25 PR-A2 (ADR 0022 amendment): on source='clear', surface the dying
292
+ // session's identity that hypo-session-end stashed so Claude can recover
293
+ // session-close work that /clear skipped. One-shot: marker is unlinked
294
+ // immediately after read.
295
+ const clearRecoveryLine = buildClearRecoveryLine(data.source);
296
+ const updateLine = buildUpdateNotice();
297
+ // Intentional dual emit: stderr (yellow/cyan) is the human-visible nudge in
298
+ // the terminal; noticePrefix injects the same plain-text lines into the
299
+ // LLM's additionalContext so model and user start the session looking at
300
+ // the same state. ANSI escapes are kept out of additionalContext on purpose.
301
+ const notices = [syncLine, growthLine, clearRecoveryLine, updateLine].filter(Boolean);
302
+ let noticePrefix = notices.length ? `${notices.join('\n\n')}\n\n` : '';
303
+ if (syncLine) process.stderr.write(`\n\x1b[33m${syncLine}\x1b[0m\n`);
304
+ if (growthLine) process.stderr.write(`\n\x1b[36m${growthLine}\x1b[0m\n`);
305
+ if (clearRecoveryLine)
306
+ process.stderr.write(`\n\x1b[33m${clearRecoveryLine.split('\n')[0]}\x1b[0m\n`);
307
+ if (updateLine) process.stderr.write(`\n\x1b[33m${updateLine}\x1b[0m\n`);
100
308
  const cwd = data.cwd || data.directory || process.cwd();
101
309
  const sessionId = data.session_id || 'default';
102
- const MARKER_FILE = join(tmpdir(), `hypo-session-marker-${sessionId}.json`);
310
+ const MARKER_FILE = sessionMarkerPath(sessionId);
103
311
  const hit = findProjectFiles(cwd);
104
312
 
313
+ const ignorePatterns = loadHypoIgnore(HYPO_DIR);
314
+
105
315
  if (hit) {
106
- const hotContent = hit.hotPath ? readFileSync(hit.hotPath, 'utf-8').slice(0, HOT_CHARS) : null;
107
- const stateContent = hit.statePath ? readFileSync(hit.statePath, 'utf-8').slice(0, STATE_CHARS) : null;
316
+ const hotContent = readIfNotIgnored(hit.hotPath, HOT_CHARS, ignorePatterns);
317
+ const stateContent = readIfNotIgnored(hit.statePath, STATE_CHARS, ignorePatterns);
108
318
 
109
319
  if (hotContent || stateContent) {
110
320
  printTerminalSummary(hit.proj, hotContent, stateContent);
111
- writeFileSync(MARKER_FILE, JSON.stringify({ proj: hit.proj, hotPath: hit.hotPath, statePath: hit.statePath, hasSnapshot: true, ts: Date.now() }));
321
+ writeFileSync(
322
+ MARKER_FILE,
323
+ JSON.stringify({
324
+ proj: hit.proj,
325
+ hotPath: hit.hotPath,
326
+ statePath: hit.statePath,
327
+ hasSnapshot: true,
328
+ ts: Date.now(),
329
+ }),
330
+ );
112
331
  const parts = [];
113
- if (hotContent) parts.push(`[HOT]\n${hotContent}`);
332
+ if (hotContent) parts.push(`[HOT]\n${hotContent}`);
114
333
  if (stateContent) parts.push(`[SESSION STATE — 다음 작업]\n${stateContent}`);
115
- console.log(JSON.stringify(
116
- buildOutput(`[WIKI HOT CACHE: project=${hit.proj}]\n\n${parts.join('\n\n')}`, { continue: true, suppressOutput: true })
117
- ));
334
+ console.log(
335
+ JSON.stringify(
336
+ buildOutput(
337
+ `${noticePrefix}[WIKI HOT CACHE: project=${hit.proj}]\n\n${parts.join('\n\n')}`,
338
+ { continue: true, suppressOutput: true },
339
+ ),
340
+ ),
341
+ );
118
342
  } else {
119
- process.stderr.write(`\n\x1b[36m[Hypomnema]\x1b[0m project: \x1b[1m${hit.proj}\x1b[0m (no snapshot yet)\n\n`);
120
- writeFileSync(MARKER_FILE, JSON.stringify({ proj: hit.proj, hotPath: null, ts: Date.now() }));
121
- console.log(JSON.stringify(
122
- buildOutput(`[WIKI HOT CACHE: project=${hit.proj}, no snapshot yet]`, { continue: true, suppressOutput: true })
123
- ));
343
+ process.stderr.write(
344
+ `\n\x1b[36m[Hypomnema]\x1b[0m project: \x1b[1m${hit.proj}\x1b[0m (no snapshot yet)\n\n`,
345
+ );
346
+ writeFileSync(
347
+ MARKER_FILE,
348
+ JSON.stringify({ proj: hit.proj, hotPath: null, ts: Date.now() }),
349
+ );
350
+ console.log(
351
+ JSON.stringify(
352
+ buildOutput(`${noticePrefix}[WIKI HOT CACHE: project=${hit.proj}, no snapshot yet]`, {
353
+ continue: true,
354
+ suppressOutput: true,
355
+ }),
356
+ ),
357
+ );
124
358
  }
125
359
  return;
126
360
  }
127
361
 
362
+ // MISS: cwd matches no project. fix #23 / ADR 0023 — offer to create one
363
+ // when the ADR trigger conditions hold (git repo + project marker + no
364
+ // cooldown + not previously declined). The actual scaffold is the LLM's
365
+ // job on a "Y" reply (scripts/lib/project-create.mjs); the hook only nudges.
366
+ if (shouldSuggestProjectCreation(cwd, HYPO_DIR)) {
367
+ const suggestLine = buildProjectSuggestionLine(cwd);
368
+ notices.push(suggestLine);
369
+ noticePrefix = `${notices.join('\n\n')}\n\n`;
370
+ recordSuggestionCooldown(HYPO_DIR, cwd);
371
+ process.stderr.write(`\n\x1b[33m${suggestLine}\x1b[0m\n`);
372
+ }
373
+
128
374
  if (!existsSync(GLOBAL_HOT)) {
129
- console.log(JSON.stringify({ continue: true, suppressOutput: true }));
375
+ const notice = notices.join('\n\n');
376
+ if (notice) {
377
+ console.log(JSON.stringify(buildOutput(notice, { continue: true, suppressOutput: true })));
378
+ } else {
379
+ console.log(JSON.stringify({ continue: true, suppressOutput: true }));
380
+ }
130
381
  return;
131
382
  }
132
383
 
133
- const globalContent = readFileSync(GLOBAL_HOT, 'utf-8').slice(0, HOT_CHARS);
134
- console.log(JSON.stringify(
135
- buildOutput(`[WIKI HOT CACHE: global no project matched cwd=${cwd}]\n\n${globalContent}`, { continue: true, suppressOutput: true })
136
- ));
137
-
138
- } catch {
384
+ const globalContent = readIfNotIgnored(GLOBAL_HOT, HOT_CHARS, ignorePatterns);
385
+ if (!globalContent) {
386
+ // GLOBAL_HOT exists but is empty or .hypoignore'd still surface any
387
+ // pending notices (sync state, growth, AND the auto-project offer), which
388
+ // would otherwise be silently dropped here (codex review 2026-05-22).
389
+ const notice = notices.join('\n\n');
390
+ if (notice) {
391
+ console.log(JSON.stringify(buildOutput(notice, { continue: true, suppressOutput: true })));
392
+ } else {
393
+ console.log(JSON.stringify({ continue: true, suppressOutput: true }));
394
+ }
395
+ return;
396
+ }
397
+ console.log(
398
+ JSON.stringify(
399
+ buildOutput(
400
+ `${noticePrefix}[WIKI HOT CACHE: global — no project matched cwd=${cwd}]\n\n${globalContent}`,
401
+ { continue: true, suppressOutput: true },
402
+ ),
403
+ ),
404
+ );
405
+ } catch (err) {
406
+ process.stderr.write(`[hypo-session-start] error: ${err?.message ?? String(err)}\n`);
139
407
  console.log(JSON.stringify({ continue: true, suppressOutput: true }));
140
408
  }
141
409
  });