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
@@ -20,24 +20,44 @@ import { fileURLToPath } from 'url';
20
20
  import { resolveHypoRoot, expandHome } from './lib/hypo-root.mjs';
21
21
  import { loadHypoIgnore, isIgnored } from './lib/hypo-ignore.mjs';
22
22
  import { parseFrontmatter } from './lib/frontmatter.mjs';
23
+ import { readSyncState, projectSuggestionsPath } from '../hooks/hypo-shared.mjs';
24
+ import {
25
+ discoverExtensions,
26
+ parseManifest,
27
+ buildExpectedSettingsEntries,
28
+ readExtensionPkgStateNoMutate,
29
+ collectOurOccurrences,
30
+ pickCanonicalOccurrence,
31
+ EXT_TYPES,
32
+ CODEX_TYPES,
33
+ } from './lib/extensions.mjs';
34
+ import { sha256, readFileIfRegular } from './lib/pkg-json.mjs';
23
35
 
24
- const HOME = homedir();
36
+ const HOME = homedir();
25
37
  const SCRIPT_DIR = fileURLToPath(new URL('.', import.meta.url));
26
- const PKG_ROOT = join(SCRIPT_DIR, '..');
38
+ const PKG_ROOT = join(SCRIPT_DIR, '..');
27
39
 
28
40
  // Shown after every fatal package-integrity error. These conditions mean the
29
41
  // shipped hooks/hooks.json is missing or malformed — never a user mistake —
30
42
  // so the only useful next step is a re-install of the package.
31
- const PKG_INTEGRITY_HINT = '→ This indicates a corrupt or incomplete install. Re-install with `npm install -g hypomnema` (or re-install the Claude Code plugin).';
43
+ const PKG_INTEGRITY_HINT =
44
+ '→ This indicates a corrupt or incomplete install. Re-install with `npm install -g hypomnema` (or re-install the Claude Code plugin).';
32
45
 
33
46
  // ── arg parsing ──────────────────────────────────────────────────────────────
34
47
 
35
48
  function parseArgs(argv) {
36
- const args = { hypoDir: null, json: false };
49
+ const args = { hypoDir: null, json: false, codex: false, claudeHome: null, projectId: null };
37
50
  for (const arg of argv.slice(2)) {
38
51
  if (arg.startsWith('--hypo-dir=')) args.hypoDir = expandHome(arg.slice(11));
39
- else if (arg === '--json') args.json = true;
52
+ else if (arg === '--json') args.json = true;
53
+ else if (arg === '--codex') args.codex = true;
54
+ else if (arg.startsWith('--claude-home=')) args.claudeHome = expandHome(arg.slice(14));
55
+ else if (arg.startsWith('--project-id=')) args.projectId = arg.slice(13);
40
56
  }
57
+ if (!args.claudeHome) args.claudeHome = join(HOME, '.claude');
58
+ // projectId intentionally left null when not user-supplied — let feedback-sync
59
+ // derive it and exercise its own "derived dir missing → skip MEMORY" path so
60
+ // doctor reports "unresolved/skipped" rather than a misleading stale warning.
41
61
  if (!args.hypoDir) args.hypoDir = resolveHypoRoot();
42
62
  return args;
43
63
  }
@@ -46,9 +66,15 @@ function parseArgs(argv) {
46
66
 
47
67
  const checks = [];
48
68
 
49
- function pass(label, detail = '') { checks.push({ status: 'pass', label, detail }); }
50
- function warn(label, detail = '') { checks.push({ status: 'warn', label, detail }); }
51
- function fail(label, detail = '') { checks.push({ status: 'fail', label, detail }); }
69
+ function pass(label, detail = '') {
70
+ checks.push({ status: 'pass', label, detail });
71
+ }
72
+ function warn(label, detail = '') {
73
+ checks.push({ status: 'warn', label, detail });
74
+ }
75
+ function fail(label, detail = '') {
76
+ checks.push({ status: 'fail', label, detail });
77
+ }
52
78
 
53
79
  // ── hook map (loaded from hooks/hooks.json — single source of truth) ─────────
54
80
 
@@ -65,7 +91,11 @@ if (!_hookConfig || typeof _hookConfig !== 'object' || Array.isArray(_hookConfig
65
91
  console.error(PKG_INTEGRITY_HINT);
66
92
  process.exit(1);
67
93
  }
68
- if (!_hookConfig.hooks || typeof _hookConfig.hooks !== 'object' || Array.isArray(_hookConfig.hooks)) {
94
+ if (
95
+ !_hookConfig.hooks ||
96
+ typeof _hookConfig.hooks !== 'object' ||
97
+ Array.isArray(_hookConfig.hooks)
98
+ ) {
69
99
  console.error('Error: hooks/hooks.json must contain a "hooks" object');
70
100
  console.error(PKG_INTEGRITY_HINT);
71
101
  process.exit(1);
@@ -83,50 +113,93 @@ function _isHookFileName(file) {
83
113
  }
84
114
 
85
115
  function _isHookGroup(group) {
86
- return group &&
116
+ return (
117
+ group &&
87
118
  typeof group === 'object' &&
88
119
  !Array.isArray(group) &&
89
120
  Array.isArray(group.hooks) &&
90
121
  group.hooks.length > 0 &&
91
- group.hooks.every(hook =>
92
- hook &&
93
- typeof hook === 'object' &&
94
- !Array.isArray(hook) &&
95
- hook.type === 'command' &&
96
- _extractCommandFileName(hook.command)
97
- );
122
+ group.hooks.every(
123
+ (hook) =>
124
+ hook &&
125
+ typeof hook === 'object' &&
126
+ !Array.isArray(hook) &&
127
+ hook.type === 'command' &&
128
+ _extractCommandFileName(hook.command),
129
+ )
130
+ );
98
131
  }
99
132
 
100
133
  // Extract .mjs file names from both old format (string[]) and new format (hook-group object[])
101
134
  function _extractFileNames(groups) {
102
- return groups.flatMap(group => {
135
+ return groups.flatMap((group) => {
103
136
  if (typeof group === 'string') return [group.trim()];
104
- return group.hooks.map(hook => _extractCommandFileName(hook.command));
137
+ return group.hooks.map((hook) => _extractCommandFileName(hook.command));
105
138
  });
106
139
  }
107
140
 
108
141
  for (const [event, groups] of Object.entries(_hookConfig.hooks)) {
109
- const valid = Array.isArray(groups) &&
142
+ const valid =
143
+ Array.isArray(groups) &&
110
144
  groups.length > 0 &&
111
- groups.every(group => _isHookFileName(group) || _isHookGroup(group)) &&
145
+ groups.every((group) => _isHookFileName(group) || _isHookGroup(group)) &&
112
146
  _extractFileNames(groups).length > 0;
113
147
  if (!valid) {
114
- console.error(`Error: hooks/hooks.json "hooks.${event}" must be a non-empty array of .mjs file names or Claude hook groups`);
148
+ console.error(
149
+ `Error: hooks/hooks.json "hooks.${event}" must be a non-empty array of .mjs file names or Claude hook groups`,
150
+ );
115
151
  console.error(PKG_INTEGRITY_HINT);
116
152
  process.exit(1);
117
153
  }
118
154
  }
119
- if (_hookConfig.shared !== undefined && (!Array.isArray(_hookConfig.shared) || !_hookConfig.shared.every(f => _isHookFileName(f)))) {
155
+ if (
156
+ _hookConfig.shared !== undefined &&
157
+ (!Array.isArray(_hookConfig.shared) || !_hookConfig.shared.every((f) => _isHookFileName(f)))
158
+ ) {
120
159
  console.error('Error: hooks/hooks.json "shared" must be an array of .mjs file names');
121
160
  console.error(PKG_INTEGRITY_HINT);
122
161
  process.exit(1);
123
162
  }
124
163
 
125
- const HOOK_MAP = Object.fromEntries(Object.entries(_hookConfig.hooks).map(([e, gs]) => [e, _extractFileNames(gs)]));
164
+ const HOOK_MAP = Object.fromEntries(
165
+ Object.entries(_hookConfig.hooks).map(([e, gs]) => [e, _extractFileNames(gs)]),
166
+ );
126
167
  const SHARED_FILES = _hookConfig.shared ?? [];
127
168
 
128
169
  // ── checks ───────────────────────────────────────────────────────────────────
129
170
 
171
+ function checkExternalDeps() {
172
+ const nodeMajor = parseInt(process.versions.node.split('.')[0], 10);
173
+ if (nodeMajor >= 18) {
174
+ pass('Node.js ≥ 18', `v${process.versions.node}`);
175
+ } else {
176
+ fail('Node.js ≥ 18', `v${process.versions.node} — upgrade to Node.js 18+`);
177
+ }
178
+
179
+ const npm = spawnSync('npm', ['--version'], { encoding: 'utf-8' });
180
+ if (npm.status === 0) {
181
+ pass('npm', `v${npm.stdout.trim()}`);
182
+ } else {
183
+ fail('npm', 'Not found — install npm');
184
+ }
185
+
186
+ const git = spawnSync('git', ['--version'], { encoding: 'utf-8' });
187
+ if (git.status === 0) {
188
+ pass('git', git.stdout.trim().replace('git version ', 'v'));
189
+ } else {
190
+ fail('git', 'Not found — install git');
191
+ }
192
+
193
+ const shell = process.env.SHELL || '';
194
+ if (shell.endsWith('zsh') || shell.endsWith('bash')) {
195
+ pass('Shell (zsh/bash)', shell);
196
+ } else if (!shell) {
197
+ warn('Shell (zsh/bash)', '$SHELL not set');
198
+ } else {
199
+ warn('Shell (zsh/bash)', `${shell} — zsh or bash recommended`);
200
+ }
201
+ }
202
+
130
203
  function checkHypoRoot(hypoDir) {
131
204
  if (!existsSync(hypoDir)) {
132
205
  fail('Wiki root exists', hypoDir);
@@ -143,7 +216,17 @@ function checkHypoRoot(hypoDir) {
143
216
  }
144
217
 
145
218
  function checkDirectories(hypoDir) {
146
- const required = ['pages', 'projects', 'sources'];
219
+ const required = [
220
+ 'pages',
221
+ 'projects',
222
+ 'sources',
223
+ // Extensions baseline (ADR 0024). Existence only — SHA / settings /
224
+ // manifest integrity is E5 (#33).
225
+ 'extensions/hooks',
226
+ 'extensions/commands',
227
+ 'extensions/skills',
228
+ 'extensions/agents',
229
+ ];
147
230
  for (const d of required) {
148
231
  if (existsSync(join(hypoDir, d))) {
149
232
  pass(`Directory: ${d}/`);
@@ -206,8 +289,8 @@ function checkSettingsJson() {
206
289
  total++;
207
290
  const cmd = `node ${hooksDir.replace(HOME, '$HOME')}/${file}`;
208
291
  const found = (Array.isArray(settings.hooks?.[event]) ? settings.hooks[event] : [])
209
- .flatMap(g => g.hooks || [])
210
- .some(h => h.command === cmd);
292
+ .flatMap((g) => g.hooks || [])
293
+ .some((h) => h.command === cmd);
211
294
  if (found) registered++;
212
295
  }
213
296
  }
@@ -215,25 +298,111 @@ function checkSettingsJson() {
215
298
  if (registered === total) {
216
299
  pass('settings.json hook registrations', `${registered}/${total} registered`);
217
300
  } else if (registered > 0) {
218
- warn('settings.json hook registrations', `${registered}/${total} registered — run /hypo:init to merge missing entries`);
301
+ warn(
302
+ 'settings.json hook registrations',
303
+ `${registered}/${total} registered — run /hypo:init to merge missing entries`,
304
+ );
219
305
  } else {
220
306
  fail('settings.json hook registrations', `0/${total} registered — run /hypo:init`);
221
307
  }
308
+
309
+ // fix #7: stale hypo-* entries (uninstall remnants).
310
+ // hypo-ext-* commands are user-extension entries (ADR 0024) — not core hooks,
311
+ // so they are intentionally absent from HOOK_MAP. Excluded here; their
312
+ // integrity (SHA + manifest + entry match) is checked separately in E5 (#33).
313
+ const isExtCommand = (cmd) => /(?:^|[/\s])hypo-ext-[^/\s]+\.mjs(?=$|["'\s])/.test(cmd);
314
+ const expectedCmds = new Set(
315
+ Object.entries(HOOK_MAP).flatMap(([, files]) =>
316
+ files.map((f) => `node ${hooksDir.replace(HOME, '$HOME')}/${f}`),
317
+ ),
318
+ );
319
+ const stale = [];
320
+ for (const [, groups] of Object.entries(settings.hooks || {})) {
321
+ if (!Array.isArray(groups)) continue;
322
+ for (const g of groups) {
323
+ if (!g || typeof g !== 'object') continue;
324
+ for (const h of g.hooks || []) {
325
+ if (
326
+ typeof h.command === 'string' &&
327
+ /hypo-[^/]+\.mjs/.test(h.command) &&
328
+ !isExtCommand(h.command) &&
329
+ !expectedCmds.has(h.command)
330
+ ) {
331
+ stale.push(h.command);
332
+ }
333
+ }
334
+ }
335
+ }
336
+ if (stale.length > 0) {
337
+ warn(
338
+ 'settings.json stale hypo-* entries',
339
+ `${stale.length} unrecognised hypo-* command(s) — run /hypo:uninstall then /hypo:init: ${stale.slice(0, 3).join(', ')}`,
340
+ );
341
+ } else {
342
+ pass('settings.json stale hypo-* entries', 'None');
343
+ }
344
+
345
+ // fix #7: duplicate hypo-* entries per event
346
+ const dupes = [];
347
+ for (const [event, groups] of Object.entries(settings.hooks || {})) {
348
+ if (!Array.isArray(groups)) continue;
349
+ const seen = new Set();
350
+ for (const g of groups) {
351
+ if (!g || typeof g !== 'object') continue;
352
+ for (const h of g.hooks || []) {
353
+ if (typeof h.command !== 'string' || !/hypo-[^/]+\.mjs/.test(h.command)) continue;
354
+ if (isExtCommand(h.command)) continue; // ext duplicates are E5's concern (#33)
355
+ if (seen.has(h.command)) dupes.push(`${event}:${h.command}`);
356
+ else seen.add(h.command);
357
+ }
358
+ }
359
+ }
360
+ if (dupes.length > 0) {
361
+ warn(
362
+ 'settings.json duplicate hypo-* entries',
363
+ `${dupes.length} duplicate(s) — run /hypo:init to repair: ${dupes.slice(0, 2).join(', ')}`,
364
+ );
365
+ } else {
366
+ pass('settings.json duplicate hypo-* entries', 'None');
367
+ }
222
368
  }
223
369
 
224
370
  function checkGit(hypoDir) {
225
371
  if (!existsSync(join(hypoDir, '.git'))) {
226
- warn('Git repository', 'Not a git repo — run /hypo:init with git-remote option for sync/backup');
372
+ warn(
373
+ 'Git repository',
374
+ 'Not a git repo — run /hypo:init with git-remote option for sync/backup',
375
+ );
227
376
  return;
228
377
  }
229
378
  pass('Git repository');
230
379
 
231
- const remote = spawnSync('git', ['-C', hypoDir, 'remote', 'get-url', 'origin'], { encoding: 'utf-8' });
380
+ const remote = spawnSync('git', ['-C', hypoDir, 'remote', 'get-url', 'origin'], {
381
+ encoding: 'utf-8',
382
+ });
232
383
  if (remote.status === 0 && remote.stdout.trim()) {
233
384
  pass('Git remote origin', remote.stdout.trim());
234
385
  } else {
235
386
  warn('Git remote origin', 'No remote configured — wiki will not sync/backup automatically');
236
387
  }
388
+
389
+ const preCommitPath = join(hypoDir, '.git', 'hooks', 'pre-commit');
390
+ if (existsSync(preCommitPath)) {
391
+ const content = readFileSync(preCommitPath, 'utf-8');
392
+ if (content.includes('# hypo-managed:pre-commit:start')) {
393
+ pass('.git/hooks/pre-commit', 'Hypomnema .hypoignore guard installed');
394
+ } else {
395
+ warn(
396
+ '.git/hooks/pre-commit',
397
+ 'Exists but not managed by Hypomnema — manual git add can bypass .hypoignore',
398
+ );
399
+ }
400
+ } else {
401
+ warn(
402
+ '.git/hooks/pre-commit',
403
+ 'Not installed — run /hypo:init to install .hypoignore guard (fix #24)',
404
+ );
405
+ }
237
406
  }
238
407
 
239
408
  function checkBrokenLinks(hypoDir, ignorePatterns = []) {
@@ -244,7 +413,9 @@ function checkBrokenLinks(hypoDir, ignorePatterns = []) {
244
413
  for (const file of mdFiles) {
245
414
  const raw = readFileSync(file, 'utf-8');
246
415
  const content = raw.replace(/<!--[\s\S]*?-->/g, '').replace(/`[^`\n]+`/g, '');
247
- const links = [...content.matchAll(/\[\[([^\]|#\n]+?)(?:[|#][^\]]*?)?\]\]/g)].map(m => m[1].trim());
416
+ const links = [...content.matchAll(/\[\[([^\]|#\n]+?)(?:[|#][^\]]*?)?\]\]/g)].map((m) =>
417
+ m[1].trim(),
418
+ );
248
419
  for (const link of links) {
249
420
  // skip object-path references (e.g. [[hooks.SessionStart]])
250
421
  if (link.includes('.') && !link.endsWith('.md')) continue;
@@ -260,8 +431,11 @@ function checkBrokenLinks(hypoDir, ignorePatterns = []) {
260
431
  if (broken.length === 0) {
261
432
  pass('Broken wiki links', `Scanned ${mdFiles.length} files`);
262
433
  } else {
263
- const sample = broken.slice(0, 5).map(b => `${b.file} → [[${b.link}]]`).join(', ');
264
- const extra = broken.length > 5 ? ` (+${broken.length - 5} more)` : '';
434
+ const sample = broken
435
+ .slice(0, 5)
436
+ .map((b) => `${b.file} → [[${b.link}]]`)
437
+ .join(', ');
438
+ const extra = broken.length > 5 ? ` (+${broken.length - 5} more)` : '';
265
439
  warn('Broken wiki links', `${broken.length} broken: ${sample}${extra}`);
266
440
  }
267
441
  }
@@ -272,7 +446,7 @@ function collectMdFiles(dir, acc = [], hypoDir = '', ignorePatterns = []) {
272
446
  if (entry.startsWith('.')) continue;
273
447
  const full = join(dir, entry);
274
448
  if (hypoDir && isIgnored(full, hypoDir, ignorePatterns)) continue;
275
- const st = statSync(full);
449
+ const st = statSync(full);
276
450
  if (st.isDirectory()) collectMdFiles(full, acc, hypoDir, ignorePatterns);
277
451
  else if (extname(entry) === '.md') acc.push(full);
278
452
  }
@@ -295,14 +469,14 @@ function buildSlugSet(files, hypoDir) {
295
469
  }
296
470
 
297
471
  function checkVerifyBy(hypoDir, ignorePatterns = []) {
298
- const today = new Date().toISOString().slice(0, 10);
299
- const mdFiles = collectMdFiles(hypoDir, [], hypoDir, ignorePatterns);
300
- const overdue = [];
301
- const missing = [];
472
+ const today = new Date().toISOString().slice(0, 10);
473
+ const mdFiles = collectMdFiles(hypoDir, [], hypoDir, ignorePatterns);
474
+ const overdue = [];
475
+ const missing = [];
302
476
 
303
477
  for (const file of mdFiles) {
304
478
  const content = readFileSync(file, 'utf-8');
305
- const fm = parseFrontmatter(content);
479
+ const fm = parseFrontmatter(content);
306
480
  if (!fm) continue;
307
481
 
308
482
  const type = fm.type || '';
@@ -312,30 +486,533 @@ function checkVerifyBy(hypoDir, ignorePatterns = []) {
312
486
  if (!fm.verify_by) {
313
487
  missing.push(relative(hypoDir, file));
314
488
  }
315
- if (fm.verify_by_date && /^\d{4}-\d{2}-\d{2}$/.test(fm.verify_by_date) && fm.verify_by_date < today) {
489
+ if (
490
+ fm.verify_by_date &&
491
+ /^\d{4}-\d{2}-\d{2}$/.test(fm.verify_by_date) &&
492
+ fm.verify_by_date < today
493
+ ) {
316
494
  overdue.push({ file: relative(hypoDir, file), due: fm.verify_by_date });
317
495
  }
318
496
  }
319
497
 
320
498
  if (overdue.length > 0) {
321
- const sample = overdue.slice(0, 3).map(o => `${o.file} (due ${o.due})`).join(', ');
322
- const extra = overdue.length > 3 ? ` (+${overdue.length - 3} more)` : '';
499
+ const sample = overdue
500
+ .slice(0, 3)
501
+ .map((o) => `${o.file} (due ${o.due})`)
502
+ .join(', ');
503
+ const extra = overdue.length > 3 ? ` (+${overdue.length - 3} more)` : '';
323
504
  warn('verify_by_date overdue', `${overdue.length} overdue: ${sample}${extra}`);
324
505
  } else {
325
506
  pass('verify_by_date overdue', 'No overdue pages');
326
507
  }
327
508
 
328
509
  if (missing.length > 0) {
329
- warn('verify_by coverage', `${missing.length} pages (adr/page/learning) missing verify_by question`);
510
+ warn(
511
+ 'verify_by coverage',
512
+ `${missing.length} pages (adr/page/learning) missing verify_by question`,
513
+ );
330
514
  } else {
331
515
  pass('verify_by coverage', 'All tracked pages have verify_by question');
332
516
  }
333
517
  }
334
518
 
519
+ function checkSyncState(hypoDir) {
520
+ // "open" = file exists with ≥1 entries; session-start clears once
521
+ // sync is healthy again. Schema + parsing live in hooks/hypo-shared.mjs.
522
+ const { entries, parseError } = readSyncState(hypoDir);
523
+
524
+ if (parseError) {
525
+ warn('Sync state', 'Cannot parse .cache/sync-state.json — inspect manually');
526
+ return;
527
+ }
528
+
529
+ if (entries.length === 0) {
530
+ pass('Sync state', 'No unresolved sync failures');
531
+ } else {
532
+ const last = entries[entries.length - 1];
533
+ warn(
534
+ 'Sync state',
535
+ `${entries.length} unresolved failure(s) — last: ${last.op || '?'} at ${last.timestamp || '?'}. Inspect .cache/sync-state.json or push/pull manually to clear.`,
536
+ );
537
+ }
538
+ }
539
+
540
+ function checkProjectSuggestions(hypoDir) {
541
+ // fix #23 / ADR 0023: the auto-project skip-persistence store. Absent file is
542
+ // healthy (no offers declined yet). Validate the RAW JSON shape here rather
543
+ // than via readProjectSuggestions(): that helper deliberately normalizes a
544
+ // non-array `skips` to [] for fail-open hook reads, which would mask a
545
+ // malformed file and silently break permanent "N" suppression (codex review
546
+ // 2026-05-22). Doctor must catch the malformation the helper hides.
547
+ const path = projectSuggestionsPath(hypoDir);
548
+ if (!existsSync(path)) {
549
+ pass('Auto-project suggestions', 'No skip-persistence file (clean)');
550
+ return;
551
+ }
552
+ let data;
553
+ try {
554
+ data = JSON.parse(readFileSync(path, 'utf-8'));
555
+ } catch {
556
+ warn(
557
+ 'Auto-project suggestions',
558
+ 'Cannot parse .cache/project-suggestions.json — inspect manually',
559
+ );
560
+ return;
561
+ }
562
+ if (data === null || typeof data !== 'object' || Array.isArray(data)) {
563
+ warn('Auto-project suggestions', 'project-suggestions.json must be a JSON object');
564
+ return;
565
+ }
566
+ if (!Array.isArray(data.skips)) {
567
+ warn(
568
+ 'Auto-project suggestions',
569
+ '`skips` must be an array — declined-cwd suppression will not work',
570
+ );
571
+ return;
572
+ }
573
+ if (
574
+ data.cooldowns !== undefined &&
575
+ (typeof data.cooldowns !== 'object' || data.cooldowns === null || Array.isArray(data.cooldowns))
576
+ ) {
577
+ warn('Auto-project suggestions', '`cooldowns` must be a plain object');
578
+ return;
579
+ }
580
+ const bad = data.skips.filter((s) => !s || typeof s.cwd !== 'string' || !s.cwd);
581
+ if (bad.length > 0) {
582
+ warn(
583
+ 'Auto-project suggestions',
584
+ `${bad.length} malformed skip entr(ies) missing a string \`cwd\` in .cache/project-suggestions.json`,
585
+ );
586
+ } else {
587
+ pass('Auto-project suggestions', `${data.skips.length} declined cwd(s) recorded`);
588
+ }
589
+ }
590
+
591
+ function checkCodexPaths() {
592
+ const codexHooks = join(HOME, '.codex', 'hooks');
593
+ const allFiles = [...Object.values(HOOK_MAP).flat(), ...SHARED_FILES];
594
+
595
+ let missing = 0;
596
+ for (const file of allFiles) {
597
+ if (!existsSync(join(codexHooks, file))) missing++;
598
+ }
599
+
600
+ if (missing === 0) {
601
+ pass('Codex hook files installed', codexHooks);
602
+ } else if (missing < allFiles.length) {
603
+ warn(
604
+ 'Codex hook files installed',
605
+ `${missing}/${allFiles.length} missing in ${codexHooks} — run /hypo:init --codex`,
606
+ );
607
+ } else {
608
+ fail(
609
+ 'Codex hook files installed',
610
+ `No hook files found in ${codexHooks} — run /hypo:init --codex`,
611
+ );
612
+ }
613
+
614
+ const settingsPath = join(HOME, '.codex', 'settings.json');
615
+ if (!existsSync(settingsPath)) {
616
+ warn(
617
+ 'Codex settings.json hook registrations',
618
+ 'settings.json not found — run /hypo:init --codex',
619
+ );
620
+ return;
621
+ }
622
+
623
+ let settings;
624
+ try {
625
+ settings = JSON.parse(readFileSync(settingsPath, 'utf-8'));
626
+ } catch {
627
+ fail('Codex settings.json hook registrations', 'settings.json is not valid JSON');
628
+ return;
629
+ }
630
+
631
+ const hooksDir = codexHooks;
632
+ let registered = 0;
633
+ let total = 0;
634
+
635
+ for (const [event, files] of Object.entries(HOOK_MAP)) {
636
+ for (const file of files) {
637
+ total++;
638
+ const cmd = `node ${hooksDir.replace(HOME, '$HOME')}/${file}`;
639
+ const found = (Array.isArray(settings.hooks?.[event]) ? settings.hooks[event] : [])
640
+ .flatMap((g) => g.hooks || [])
641
+ .some((h) => h.command === cmd);
642
+ if (found) registered++;
643
+ }
644
+ }
645
+
646
+ if (registered === total) {
647
+ pass('Codex settings.json hook registrations', `${registered}/${total} registered`);
648
+ } else if (registered > 0) {
649
+ warn(
650
+ 'Codex settings.json hook registrations',
651
+ `${registered}/${total} registered — run /hypo:init --codex`,
652
+ );
653
+ } else {
654
+ fail(
655
+ 'Codex settings.json hook registrations',
656
+ `0/${total} registered — run /hypo:init --codex`,
657
+ );
658
+ }
659
+ }
660
+
661
+ // ── extensions integrity (ADR 0024, E5) ───────────────────────────
662
+
663
+ // Detect drift between the user's `~/hypomnema/extensions/` source, the recorded
664
+ // per-target SHA map (`hypo-pkg.json`), and the installed copies + settings.json
665
+ // entries under `~/.claude` (or `~/.codex` with --codex). Reuses E2's read-only
666
+ // helpers (plan §5 D4) — never re-derives discovery/manifest/SHA logic.
667
+ //
668
+ // Severity taxonomy (plan §5 #4 pins manifest; the rest mirror the slash-command
669
+ // / settings-stale precedent that recoverable drift is a warn, not a ship blocker):
670
+ // manifest malformed (parse fail / unknown event) → FAIL (won't self-heal; §5 #4)
671
+ // manifest missing → warn (hook just won't register; §5 #4)
672
+ // installed copy SHA ≠ recorded (user-modified) → warn (--force-extensions recovers)
673
+ // recorded entry but copy absent / non-regular → warn (upgrade --apply recovers)
674
+ // expected settings entry missing → warn (upgrade --apply recovers)
675
+ // orphan settings entry (source removed) → warn (uninstall recovers; boost #2)
676
+ // A malformed manifest failing here is what makes the §5.1.3 `fails=0` ship gate
677
+ // actually cover §8.12-7(c) — asserted by the doctor-extensions-integrity test.
678
+ //
679
+ // E5 is doctor SURFACE for extensions integrity. The mixed-group surgical
680
+ // *write* (preserve sibling-plugin hooks, swap only ours) used to be deferred
681
+ // here; fix #47 (ADR 0024 amendment 2026-05-23) lifted that deferral —
682
+ // registerSettings (extensions.mjs:478 docstring) now does occurrence-first +
683
+ // 8-rank canonical write, and the (b) loop below mirrors that read-path via
684
+ // collectOurOccurrences so a valid mixed-group occurrence is no longer warned
685
+ // as `not registered`.
686
+ function checkExtensions(hypoDir, claudeHome, target = 'claude') {
687
+ const extDir = join(hypoDir, 'extensions');
688
+ // E1 baseline absent (e.g. --from-remote clone, plan §5 #8) → nothing to check.
689
+ if (!existsSync(extDir)) return;
690
+
691
+ const root = target === 'codex' ? join(HOME, '.codex') : claudeHome;
692
+ const label = target === 'codex' ? 'Codex extensions integrity' : 'Extensions integrity';
693
+ // The per-target SHA map lives in ~/.claude/hypo-pkg.json under
694
+ // `extensions.{claude,codex}` (sync writes both targets into the one file —
695
+ // upgrade.mjs:681), so the pkg path is always claude regardless of target.
696
+ const pkgPath = join(claudeHome, 'hypo-pkg.json');
697
+ const settingsPath = join(root, 'settings.json');
698
+ const hooksDir = join(root, 'hooks');
699
+ const types = target === 'codex' ? CODEX_TYPES : EXT_TYPES;
700
+
701
+ const patterns = loadHypoIgnore(hypoDir);
702
+ const discovered = discoverExtensions(extDir, patterns, hypoDir);
703
+
704
+ const problems = [];
705
+
706
+ // (c) manifest health — hooks only (plan §0 D3: non-hook manifests don't register).
707
+ for (const ext of discovered.hooks) {
708
+ if (!ext.manifestPath) {
709
+ problems.push({
710
+ severity: 'warn',
711
+ msg: `${ext.name}.manifest.json missing — hook will not auto-register`,
712
+ });
713
+ continue;
714
+ }
715
+ const parsed = parseManifest(ext.manifestPath);
716
+ if (!parsed.ok) {
717
+ problems.push({ severity: 'fail', msg: `${ext.manifestName} malformed (${parsed.error})` });
718
+ }
719
+ }
720
+
721
+ // (a) hard-copy SHA: recorded SHA vs the installed copy on disk.
722
+ const recorded = readExtensionPkgStateNoMutate(pkgPath, target);
723
+ for (const [key, recSHA] of Object.entries(recorded)) {
724
+ // Skip keys outside this target's covered types (defensive: a Claude run records
725
+ // skills/agents that a codex target never installs — don't false-flag them).
726
+ if (!types.includes(key.split('/')[0])) continue;
727
+ const destPath = join(root, key);
728
+ if (!existsSync(destPath)) {
729
+ problems.push({
730
+ severity: 'warn',
731
+ msg: `${key} recorded but not installed — run upgrade --apply`,
732
+ });
733
+ continue;
734
+ }
735
+ const onDisk = readFileIfRegular(destPath);
736
+ if (onDisk === null) {
737
+ problems.push({ severity: 'warn', msg: `${key} is not a regular file — left untouched` });
738
+ continue;
739
+ }
740
+ if (sha256(onDisk) !== recSHA) {
741
+ problems.push({
742
+ severity: 'warn',
743
+ msg: `${key} modified since install (drift) — use --force-extensions`,
744
+ });
745
+ }
746
+ }
747
+
748
+ // (b) settings.json entries. Distinguish three states:
749
+ // - malformed JSON → skip (checkSettingsJson / checkCodexPaths already FAILs it;
750
+ // piling on a misleading "not registered" warn helps no one)
751
+ // - missing file / no `hooks` object → treat as an EMPTY hooks map, so a synced
752
+ // extension whose registration is absent still surfaces as expected-but-missing
753
+ // (§8.12-7(b)). A missing file alone with no extensions yields no problem (gate-safe).
754
+ let settingsParseFailed = false;
755
+ let hooksObj = {};
756
+ if (existsSync(settingsPath)) {
757
+ try {
758
+ const parsed = JSON.parse(readFileSync(settingsPath, 'utf-8'));
759
+ if (
760
+ parsed &&
761
+ parsed.hooks &&
762
+ typeof parsed.hooks === 'object' &&
763
+ !Array.isArray(parsed.hooks)
764
+ ) {
765
+ hooksObj = parsed.hooks;
766
+ }
767
+ } catch {
768
+ settingsParseFailed = true;
769
+ }
770
+ }
771
+ if (!settingsParseFailed) {
772
+ const expected = buildExpectedSettingsEntries(discovered.hooks, hooksDir);
773
+
774
+ // (b) for each registrable hook: locate every occurrence of our command
775
+ // (single-hook OR mixed group, fix #47) and pick the canonical via the
776
+ // SAME 8-rank logic registerSettings uses.
777
+ // Without this mirror, doctor picked the first traversal-order occurrence
778
+ // under the target event and warned "differs" even when a later
779
+ // occurrence was the rank-1 canonical that upgrade --apply silently
780
+ // accepts — a confusing gap between report and action.
781
+ //
782
+ // Outcomes:
783
+ // - no occurrence in any event → warn `not registered`
784
+ // - canonical on a non-target event → warn `not registered under <event>`
785
+ // - canonical rank 1/2 (exact) → no drift; pass through
786
+ // (ext-command duplicates are surfaced separately below — core
787
+ // duplicate-warn at line ~344 excludes hypo-ext-* on purpose)
788
+ // - canonical rank 3/4/5 on target → warn `settings entry differs`
789
+ //
790
+ // Mixed-group: a foreign sibling sharing our matcher group does NOT itself
791
+ // count as drift — only our own hook fields ({type, command, timeout?}) and
792
+ // the group's matcher are compared. Doctor never inspects foreign hook
793
+ // shape (no peering into `if`/`args`/`async`/`statusMessage` etc.).
794
+ for (const entry of expected) {
795
+ const desiredHook = { type: 'command', command: entry.command };
796
+ if (entry.timeout) desiredHook.timeout = entry.timeout;
797
+
798
+ const occurrences = collectOurOccurrences(hooksObj, entry.command);
799
+ const picked = pickCanonicalOccurrence(occurrences, entry, desiredHook);
800
+ if (!picked) {
801
+ problems.push({
802
+ severity: 'warn',
803
+ msg: `${entry.name} not registered under ${entry.event} — run upgrade --apply`,
804
+ });
805
+ continue;
806
+ }
807
+ if (picked.occ.event !== entry.event) {
808
+ problems.push({
809
+ severity: 'warn',
810
+ msg: `${entry.name} not registered under ${entry.event} — run upgrade --apply`,
811
+ });
812
+ } else if (picked.rank >= 3) {
813
+ // ranks 3/4/5 — on target event, but hook or matcher drifted.
814
+ // The manifest boundary normalize
815
+ // (extensions.mjs:178) collapses `matcher: ""` → absent only on the
816
+ // manifest path. A hand-edited settings.json with `matcher: ""` still
817
+ // mismatches an absent manifest matcher (rankOccurrence treats "" vs
818
+ // undefined as non-equal). Surface the empty-string-vs-absent
819
+ // equivalence ONLY when the hook itself is otherwise exact —
820
+ // otherwise the specific message would hide a co-occurring hook /
821
+ // timeout drift. The hookExact
822
+ // comparison mirrors rankOccurrence's own canonical check
823
+ // (extensions.mjs ~580) so doctor's report tracks --apply intent.
824
+ const hookExact = JSON.stringify(picked.occ.hook) === JSON.stringify(desiredHook);
825
+ if (picked.occ.group.matcher === '' && entry.matcher === undefined && hookExact) {
826
+ problems.push({
827
+ severity: 'warn',
828
+ msg: `${entry.name} settings has matcher: "" (equivalent to absent) — run upgrade --apply to normalize`,
829
+ });
830
+ } else {
831
+ problems.push({
832
+ severity: 'warn',
833
+ msg: `${entry.name} settings entry differs from manifest (matcher/hook/timeout) — run upgrade --apply`,
834
+ });
835
+ }
836
+ }
837
+ // ext-aware duplicate surface: core duplicate-warn at checkSettingsJson
838
+ // intentionally skips hypo-ext-* (line ~353 isExtCommand guard). With
839
+ // the canonical-pick above, exact rank-1 duplicates would otherwise be
840
+ // invisible to doctor until upgrade --apply runs cleanup. Surface them
841
+ // here so the report still names the work that --apply will do.
842
+ if (occurrences.length > 1) {
843
+ problems.push({
844
+ severity: 'warn',
845
+ msg: `${entry.name} has ${occurrences.length} occurrences in settings — run upgrade --apply to clean up`,
846
+ });
847
+ }
848
+ }
849
+
850
+ // orphan (boost #2): a hypo-ext-* command in settings with no source extension.
851
+ // E4 excluded hypo-ext-* from the core stale checker (doctor.mjs:302), so this
852
+ // is the ONLY place orphaned extension entries are caught.
853
+ //
854
+ // Two distinct orphan classes:
855
+ // - source-removed: settings entry whose source file is gone → uninstall
856
+ // - unregistrable : source file present but manifest malformed/non-hook,
857
+ // so (b) above skipped it and (c) only FAIL/warned the
858
+ // manifest itself, never naming the stale settings entry.
859
+ // Surfaced separately so the user knows the lingering
860
+ // entry needs cleanup independent of the manifest fix.
861
+ const cmdFor = (ext) => `node ${hooksDir.replace(HOME, '$HOME')}/${ext.file}`;
862
+ const sourceCmds = new Set(discovered.hooks.map(cmdFor));
863
+ const unregistrableCmds = new Set();
864
+ for (const ext of discovered.hooks) {
865
+ if (!ext.manifestPath) continue; // (c-warn) already names this case
866
+ const parsed = parseManifest(ext.manifestPath);
867
+ if (!parsed.ok || !parsed.registrable) unregistrableCmds.add(cmdFor(ext));
868
+ }
869
+ // A single hypo-ext-* command can appear
870
+ // in multiple groups/events when settings.json was hand-edited or migrated
871
+ // across events. The registrable-entry duplicate surface above
872
+ // (`occurrences.length > 1`) only iterates `expected`, so orphan-class
873
+ // duplicates were silently de-duped to a single warn. Count occurrences per
874
+ // orphan command and append `(N occurrences)` when count > 1.
875
+ //
876
+ // Order: check `unregistrableCmds` BEFORE `sourceCmds` — a malformed or
877
+ // non-hook manifest still has the source file present, so the
878
+ // `sourceCmds.has` check would otherwise misclassify them as non-orphan.
879
+ const orphanInfo = new Map(); // command → { kind, count }
880
+ for (const groups of Object.values(hooksObj)) {
881
+ if (!Array.isArray(groups)) continue;
882
+ for (const g of groups) {
883
+ if (!g || typeof g !== 'object') continue;
884
+ if (!Array.isArray(g.hooks)) continue;
885
+ for (const h of g.hooks) {
886
+ if (typeof h.command !== 'string') continue;
887
+ if (!/(?:^|[/\s])hypo-ext-[^/\s]+\.mjs(?=$|["'\s])/.test(h.command)) continue;
888
+ let kind = null;
889
+ if (unregistrableCmds.has(h.command)) kind = 'unregistrable';
890
+ else if (!sourceCmds.has(h.command)) kind = 'source-removed';
891
+ else continue;
892
+ const info = orphanInfo.get(h.command);
893
+ if (info) info.count += 1;
894
+ else orphanInfo.set(h.command, { kind, count: 1 });
895
+ }
896
+ }
897
+ }
898
+ for (const [cmd, { kind, count }] of orphanInfo) {
899
+ const suffix = count > 1 ? ` (${count} occurrences)` : '';
900
+ const msg =
901
+ kind === 'unregistrable'
902
+ ? `orphan settings entry (${cmd}) — manifest unregistrable${suffix}; run uninstall`
903
+ : `orphan settings entry (${cmd}) — source extension removed${suffix}; run uninstall`;
904
+ problems.push({ severity: 'warn', msg });
905
+ }
906
+ }
907
+
908
+ if (problems.length === 0) {
909
+ pass(label, 'All extensions consistent');
910
+ return;
911
+ }
912
+ const hasFail = problems.some((p) => p.severity === 'fail');
913
+ const sample = problems
914
+ .slice(0, 4)
915
+ .map((p) => p.msg)
916
+ .join('; ');
917
+ const extra = problems.length > 4 ? ` (+${problems.length - 4} more)` : '';
918
+ if (hasFail) fail(label, `${sample}${extra}`);
919
+ else warn(label, `${sample}${extra}`);
920
+ }
921
+
922
+ // ── feedback projection (ADR 0031) ──────────────────────────────
923
+
924
+ // Spawn feedback-sync.mjs --check --json and map its drift report onto doctor's
925
+ // pass/warn/fail. Integrity violations (exit-3 class: conflict / unpaired marker
926
+ // / intruder line / block out of container) are FAIL. Plain drift, build errors,
927
+ // over-cap, and an unresolved project-id are WARN — a fresh system that has not
928
+ // run `feedback-sync --write` yet is normal and must not block doctor.
929
+ function checkFeedbackProjection(hypoDir, claudeHome, projectId) {
930
+ const cliPath = join(PKG_ROOT, 'scripts', 'feedback-sync.mjs');
931
+ const cliArgs = [
932
+ cliPath,
933
+ '--check',
934
+ '--json',
935
+ '--no-input', // never let the child block on a TTY prompt under doctor
936
+ `--hypo-dir=${hypoDir}`,
937
+ `--claude-home=${claudeHome}`,
938
+ ];
939
+ // forward --project-id ONLY when the user supplied one; otherwise let
940
+ // feedback-sync derive it and run its derived-missing → skip-MEMORY path
941
+ if (projectId) cliArgs.push(`--project-id=${projectId}`);
942
+ const r = spawnSync(process.execPath, cliArgs, { encoding: 'utf-8' });
943
+
944
+ // feedback-sync exits non-zero on drift/over-cap/conflict — that is expected
945
+ // and still prints a JSON report on stdout. Only treat a missing process or
946
+ // unparseable stdout as a doctor-level problem (warn, never crash).
947
+ if (r.error || r.status === null) {
948
+ warn(
949
+ 'Feedback projection',
950
+ `feedback-sync could not run: ${r.error?.message || 'no exit code'}`,
951
+ );
952
+ return;
953
+ }
954
+ let report;
955
+ try {
956
+ report = JSON.parse(r.stdout);
957
+ } catch {
958
+ warn('Feedback projection', 'feedback-sync produced no JSON report — inspect manually');
959
+ return;
960
+ }
961
+ if (report.error) {
962
+ warn('Feedback projection', report.error);
963
+ return;
964
+ }
965
+
966
+ const targets = Object.entries(report.targets || {});
967
+
968
+ // 1) integrity violations → FAIL
969
+ const broken = targets.find(
970
+ ([, t]) => (t.conflicts && t.conflicts.length) || t.unpaired || t.intruder || t.outOfContainer,
971
+ );
972
+ if (broken) {
973
+ const [name, t] = broken;
974
+ const reason =
975
+ t.conflicts && t.conflicts.length
976
+ ? `conflict (${t.conflicts.join(', ')})`
977
+ : t.unpaired
978
+ ? 'unpaired managed marker'
979
+ : t.intruder
980
+ ? 'hand-edited line inside managed region'
981
+ : 'managed block outside its container';
982
+ fail(
983
+ 'Feedback projection integrity',
984
+ `${name}: ${reason} — run \`hypomnema feedback-sync --import-target-change\` to reconcile`,
985
+ );
986
+ } else {
987
+ // 2) build error → WARN
988
+ const buildErr = targets.find(([, t]) => t.buildError);
989
+ if (buildErr) {
990
+ warn('Feedback projection', buildErr[1].buildError);
991
+ } else if (targets.find(([, t]) => t.overCap)) {
992
+ warn('Feedback projection', 'projection over cap — demote/archive feedback pages');
993
+ } else if (targets.find(([, t]) => t.dirty)) {
994
+ warn('Feedback projection', 'projections stale — run `hypomnema feedback-sync --write`');
995
+ } else {
996
+ const candidates = targets.reduce((n, [, t]) => n + (t.candidates || 0), 0);
997
+ if (candidates > 0) pass('Feedback projection', 'in sync');
998
+ else pass('Feedback projection', 'no projection candidates');
999
+ }
1000
+ }
1001
+
1002
+ // 3) unresolved project-id is a separate, non-fatal concern (MEMORY skipped)
1003
+ if (report.projectIdResolved === false) {
1004
+ warn(
1005
+ 'Feedback projection',
1006
+ `project-id ${report.projectId} unresolved — MEMORY projection skipped; pass --project-id`,
1007
+ );
1008
+ }
1009
+ }
1010
+
335
1011
  // ── main ─────────────────────────────────────────────────────────────────────
336
1012
 
337
1013
  const args = parseArgs(process.argv);
338
1014
 
1015
+ checkExternalDeps();
339
1016
  const ignorePatterns = loadHypoIgnore(args.hypoDir);
340
1017
  const rootOk = checkHypoRoot(args.hypoDir);
341
1018
  if (rootOk) {
@@ -346,6 +1023,12 @@ if (rootOk) {
346
1023
  }
347
1024
  checkHooks();
348
1025
  checkSettingsJson();
1026
+ if (args.codex) checkCodexPaths();
1027
+ if (rootOk) checkExtensions(args.hypoDir, args.claudeHome, 'claude');
1028
+ if (rootOk && args.codex) checkExtensions(args.hypoDir, args.claudeHome, 'codex');
1029
+ if (rootOk) checkSyncState(args.hypoDir);
1030
+ if (rootOk) checkProjectSuggestions(args.hypoDir);
1031
+ if (rootOk) checkFeedbackProjection(args.hypoDir, args.claudeHome, args.projectId);
349
1032
  checkGit(args.hypoDir);
350
1033
 
351
1034
  // ── report ───────────────────────────────────────────────────────────────────
@@ -359,13 +1042,13 @@ if (args.json) {
359
1042
  console.log(`${icons[c.status]} ${c.label}${detail}`);
360
1043
  }
361
1044
 
362
- const fails = checks.filter(c => c.status === 'fail').length;
363
- const warns = checks.filter(c => c.status === 'warn').length;
364
- const passes = checks.filter(c => c.status === 'pass').length;
1045
+ const fails = checks.filter((c) => c.status === 'fail').length;
1046
+ const warns = checks.filter((c) => c.status === 'warn').length;
1047
+ const passes = checks.filter((c) => c.status === 'pass').length;
365
1048
 
366
1049
  console.log('');
367
1050
  console.log(`Result: ${passes} passed, ${warns} warnings, ${fails} failed`);
368
1051
  if (fails > 0) console.log('Run /hypo:init to repair installation issues.');
369
1052
  }
370
1053
 
371
- if (checks.some(c => c.status === 'fail')) process.exit(1);
1054
+ if (checks.some((c) => c.status === 'fail')) process.exit(1);