hypomnema 1.1.0 → 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 (72) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.ko.md +4 -2
  4. package/README.md +4 -2
  5. package/commands/audit.md +2 -2
  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 +1 -1
  11. package/docs/CONTRIBUTING.md +1 -1
  12. package/hooks/hooks.json +30 -1
  13. package/hooks/hypo-auto-commit.mjs +10 -4
  14. package/hooks/hypo-auto-minimal-crystallize.mjs +145 -0
  15. package/hooks/hypo-auto-stage.mjs +4 -3
  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 +22 -10
  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 +11 -5
  26. package/hooks/hypo-session-start.mjs +298 -52
  27. package/hooks/hypo-shared.mjs +793 -37
  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 +730 -47
  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 +398 -113
  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 +37 -27
  50. package/scripts/smoke-pack.mjs +224 -0
  51. package/scripts/stats.mjs +24 -10
  52. package/scripts/uninstall.mjs +363 -49
  53. package/scripts/upgrade.mjs +706 -202
  54. package/scripts/verify.mjs +24 -14
  55. package/scripts/weekly-report.mjs +59 -25
  56. package/skills/crystallize/SKILL.md +20 -7
  57. package/skills/ingest/SKILL.md +25 -5
  58. package/templates/.hypoignore +16 -2
  59. package/templates/Home.md +2 -0
  60. package/templates/SCHEMA.md +61 -6
  61. package/templates/extensions/agents/.gitkeep +0 -0
  62. package/templates/extensions/commands/.gitkeep +0 -0
  63. package/templates/extensions/hooks/.gitkeep +0 -0
  64. package/templates/extensions/skills/.gitkeep +0 -0
  65. package/templates/gitignore +5 -0
  66. package/templates/hot.md +2 -0
  67. package/templates/hypo-config.md +1 -1
  68. package/templates/hypo-guide.md +42 -2
  69. package/templates/hypo-help.md +1 -1
  70. package/templates/pages/observability/_index.md +77 -0
  71. package/templates/projects/_template/index.md +2 -2
  72. package/templates/projects/_template/prd.md +1 -1
@@ -8,13 +8,118 @@
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, formatGrowthMetrics } 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';
15
40
 
16
- const PROJECTS_DIR = join(HYPO_DIR, 'projects');
17
- const GROWTH_CACHE = join(HYPO_DIR, '.cache', 'last-session-growth.json');
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
+ }
120
+
121
+ const PROJECTS_DIR = join(HYPO_DIR, 'projects');
122
+ const GROWTH_CACHE = join(HYPO_DIR, '.cache', 'last-session-growth.json');
18
123
 
19
124
  function readLastGrowthLine() {
20
125
  if (!existsSync(GROWTH_CACHE)) return '';
@@ -26,41 +131,110 @@ function readLastGrowthLine() {
26
131
  }
27
132
  }
28
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
+ }
159
+
160
+ /** Pull the wiki repo. Returns true only when the pull actually succeeded. */
29
161
  function gitPull(dir) {
30
- if (!existsSync(join(dir, '.git'))) return;
31
- 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'}]`;
32
203
  }
33
- const GLOBAL_HOT = join(HYPO_DIR, 'hot.md');
34
- const HOT_CHARS = 2000;
35
- const STATE_CHARS = 2000;
204
+ const GLOBAL_HOT = join(HYPO_DIR, 'hot.md');
205
+ const HOT_CHARS = 2000;
206
+ const STATE_CHARS = 2000;
36
207
 
37
208
  function parseFrontmatterField(content, key) {
38
209
  const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
39
210
  if (!match) return null;
40
- const line = match[1].split('\n').find(l => l.startsWith(`${key}:`));
211
+ const line = match[1].split('\n').find((l) => l.startsWith(`${key}:`));
41
212
  if (!line) return null;
42
- return line.slice(key.length + 1).trim().replace(/^['"]|['"]$/g, '');
213
+ return line
214
+ .slice(key.length + 1)
215
+ .trim()
216
+ .replace(/^['"]|['"]$/g, '');
43
217
  }
44
218
 
45
219
  function findProjectFiles(cwd) {
46
220
  if (!existsSync(PROJECTS_DIR)) return null;
47
221
  for (const proj of readdirSync(PROJECTS_DIR)) {
48
- const projDir = join(PROJECTS_DIR, proj);
222
+ const projDir = join(PROJECTS_DIR, proj);
49
223
  if (!statSync(projDir).isDirectory()) continue;
50
224
  const indexPath = join(projDir, 'index.md');
51
225
  if (!existsSync(indexPath)) continue;
52
- const content = readFileSync(indexPath, 'utf-8');
226
+ const content = readFileSync(indexPath, 'utf-8');
53
227
  const workingDir = parseFrontmatterField(content, 'working_dir');
54
228
  if (!workingDir) continue;
55
229
  const resolved = workingDir.startsWith('~/')
56
230
  ? join(homedir(), workingDir.slice(2))
57
231
  : workingDir;
58
232
  if (cwd === resolved || cwd.startsWith(resolved + '/')) {
59
- const hotPath = join(projDir, 'hot.md');
233
+ const hotPath = join(projDir, 'hot.md');
60
234
  const statePath = join(projDir, 'session-state.md');
61
235
  return {
62
236
  proj,
63
- hotPath: existsSync(hotPath) ? hotPath : null,
237
+ hotPath: existsSync(hotPath) ? hotPath : null,
64
238
  statePath: existsSync(statePath) ? statePath : null,
65
239
  };
66
240
  }
@@ -82,18 +256,20 @@ function printTerminalSummary(proj, hotContent, stateContent) {
82
256
  const nextFromState = stateContent
83
257
  ? extractSection(stateContent, SESSION_STATE_NEXT_HEADINGS)
84
258
  : null;
85
- const next = nextFromState
86
- ?? extractSection(hotContent ?? '', SESSION_STATE_NEXT_HEADINGS);
259
+ const next = nextFromState ?? extractSection(hotContent ?? '', SESSION_STATE_NEXT_HEADINGS);
87
260
  const prev = hotContent
88
- ? (extractSection(hotContent, '직전 세션 \\([^)]+\\)')
89
- ?? extractSection(hotContent, '직전 세션.*')
90
- ?? extractSection(hotContent, 'Last Session.*'))
261
+ ? (extractSection(hotContent, '직전 세션 \\([^)]+\\)') ??
262
+ extractSection(hotContent, '직전 세션.*') ??
263
+ extractSection(hotContent, 'Last Session.*'))
91
264
  : null;
92
265
  const lines = ['', `\x1b[36m[Hypomnema]\x1b[0m project: \x1b[1m${proj}\x1b[0m`];
93
266
  if (prev) lines.push(` prev: ${prev.split('\n')[0].replace(/^\*\*|\*\*$/g, '')}`);
94
267
  if (next) {
95
268
  lines.push(' next:');
96
- 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}`));
97
273
  }
98
274
  lines.push('');
99
275
  process.stderr.write(lines.join('\n'));
@@ -101,63 +277,133 @@ function printTerminalSummary(proj, hotContent, stateContent) {
101
277
 
102
278
  let raw = '';
103
279
  process.stdin.setEncoding('utf-8');
104
- process.stdin.on('data', chunk => raw += chunk);
280
+ process.stdin.on('data', (chunk) => (raw += chunk));
105
281
  process.stdin.on('end', () => {
106
282
  try {
107
283
  let data = {};
108
- try { data = JSON.parse(raw); } catch {}
284
+ try {
285
+ data = JSON.parse(raw);
286
+ } catch {}
109
287
 
110
- gitPull(HYPO_DIR);
288
+ const pullOk = gitPull(HYPO_DIR);
289
+ const syncLine = syncStateNotice(pullOk);
111
290
  const growthLine = readLastGrowthLine();
112
- // Intentional dual emit: stderr (cyan) is the human-visible nudge in the
113
- // terminal; growthPrefix injects the same plain-text line into the LLM's
114
- // additionalContext so model and user start the session looking at the
115
- // same state. ANSI escapes are kept out of additionalContext on purpose.
116
- const growthPrefix = growthLine ? `${growthLine}\n\n` : '';
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`);
117
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`);
118
308
  const cwd = data.cwd || data.directory || process.cwd();
119
309
  const sessionId = data.session_id || 'default';
120
- const MARKER_FILE = join(tmpdir(), `hypo-session-marker-${sessionId}.json`);
310
+ const MARKER_FILE = sessionMarkerPath(sessionId);
121
311
  const hit = findProjectFiles(cwd);
122
312
 
313
+ const ignorePatterns = loadHypoIgnore(HYPO_DIR);
314
+
123
315
  if (hit) {
124
- const hotContent = hit.hotPath ? readFileSync(hit.hotPath, 'utf-8').slice(0, HOT_CHARS) : null;
125
- 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);
126
318
 
127
319
  if (hotContent || stateContent) {
128
320
  printTerminalSummary(hit.proj, hotContent, stateContent);
129
- 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
+ );
130
331
  const parts = [];
131
- if (hotContent) parts.push(`[HOT]\n${hotContent}`);
332
+ if (hotContent) parts.push(`[HOT]\n${hotContent}`);
132
333
  if (stateContent) parts.push(`[SESSION STATE — 다음 작업]\n${stateContent}`);
133
- console.log(JSON.stringify(
134
- buildOutput(`${growthPrefix}[WIKI HOT CACHE: project=${hit.proj}]\n\n${parts.join('\n\n')}`, { continue: true, suppressOutput: true })
135
- ));
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
+ );
136
342
  } else {
137
- process.stderr.write(`\n\x1b[36m[Hypomnema]\x1b[0m project: \x1b[1m${hit.proj}\x1b[0m (no snapshot yet)\n\n`);
138
- writeFileSync(MARKER_FILE, JSON.stringify({ proj: hit.proj, hotPath: null, ts: Date.now() }));
139
- console.log(JSON.stringify(
140
- buildOutput(`${growthPrefix}[WIKI HOT CACHE: project=${hit.proj}, no snapshot yet]`, { continue: true, suppressOutput: true })
141
- ));
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
+ );
142
358
  }
143
359
  return;
144
360
  }
145
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
+
146
374
  if (!existsSync(GLOBAL_HOT)) {
147
- if (growthLine) {
148
- console.log(JSON.stringify(buildOutput(growthLine, { 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 })));
149
378
  } else {
150
379
  console.log(JSON.stringify({ continue: true, suppressOutput: true }));
151
380
  }
152
381
  return;
153
382
  }
154
383
 
155
- const globalContent = readFileSync(GLOBAL_HOT, 'utf-8').slice(0, HOT_CHARS);
156
- console.log(JSON.stringify(
157
- buildOutput(`${growthPrefix}[WIKI HOT CACHE: global no project matched cwd=${cwd}]\n\n${globalContent}`, { continue: true, suppressOutput: true })
158
- ));
159
-
160
- } 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`);
161
407
  console.log(JSON.stringify({ continue: true, suppressOutput: true }));
162
408
  }
163
409
  });