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
@@ -20,19 +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, '..');
39
+
40
+ // Shown after every fatal package-integrity error. These conditions mean the
41
+ // shipped hooks/hooks.json is missing or malformed — never a user mistake —
42
+ // so the only useful next step is a re-install of the package.
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).';
27
45
 
28
46
  // ── arg parsing ──────────────────────────────────────────────────────────────
29
47
 
30
48
  function parseArgs(argv) {
31
- const args = { hypoDir: null, json: false };
49
+ const args = { hypoDir: null, json: false, codex: false, claudeHome: null, projectId: null };
32
50
  for (const arg of argv.slice(2)) {
33
51
  if (arg.startsWith('--hypo-dir=')) args.hypoDir = expandHome(arg.slice(11));
34
- 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);
35
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.
36
61
  if (!args.hypoDir) args.hypoDir = resolveHypoRoot();
37
62
  return args;
38
63
  }
@@ -41,9 +66,15 @@ function parseArgs(argv) {
41
66
 
42
67
  const checks = [];
43
68
 
44
- function pass(label, detail = '') { checks.push({ status: 'pass', label, detail }); }
45
- function warn(label, detail = '') { checks.push({ status: 'warn', label, detail }); }
46
- function fail(label, detail = '') { checks.push({ status: 'fail', label, detail }); }
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
+ }
47
78
 
48
79
  // ── hook map (loaded from hooks/hooks.json — single source of truth) ─────────
49
80
 
@@ -52,14 +83,21 @@ try {
52
83
  _hookConfig = JSON.parse(readFileSync(join(PKG_ROOT, 'hooks', 'hooks.json'), 'utf-8'));
53
84
  } catch {
54
85
  console.error(`Error: cannot read hooks/hooks.json from package root: ${PKG_ROOT}`);
86
+ console.error(PKG_INTEGRITY_HINT);
55
87
  process.exit(1);
56
88
  }
57
89
  if (!_hookConfig || typeof _hookConfig !== 'object' || Array.isArray(_hookConfig)) {
58
90
  console.error('Error: hooks/hooks.json must be a JSON object');
91
+ console.error(PKG_INTEGRITY_HINT);
59
92
  process.exit(1);
60
93
  }
61
- 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
+ ) {
62
99
  console.error('Error: hooks/hooks.json must contain a "hooks" object');
100
+ console.error(PKG_INTEGRITY_HINT);
63
101
  process.exit(1);
64
102
  }
65
103
  function _extractCommandFileName(command) {
@@ -75,48 +113,93 @@ function _isHookFileName(file) {
75
113
  }
76
114
 
77
115
  function _isHookGroup(group) {
78
- return group &&
116
+ return (
117
+ group &&
79
118
  typeof group === 'object' &&
80
119
  !Array.isArray(group) &&
81
120
  Array.isArray(group.hooks) &&
82
121
  group.hooks.length > 0 &&
83
- group.hooks.every(hook =>
84
- hook &&
85
- typeof hook === 'object' &&
86
- !Array.isArray(hook) &&
87
- hook.type === 'command' &&
88
- _extractCommandFileName(hook.command)
89
- );
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
+ );
90
131
  }
91
132
 
92
133
  // Extract .mjs file names from both old format (string[]) and new format (hook-group object[])
93
134
  function _extractFileNames(groups) {
94
- return groups.flatMap(group => {
135
+ return groups.flatMap((group) => {
95
136
  if (typeof group === 'string') return [group.trim()];
96
- return group.hooks.map(hook => _extractCommandFileName(hook.command));
137
+ return group.hooks.map((hook) => _extractCommandFileName(hook.command));
97
138
  });
98
139
  }
99
140
 
100
141
  for (const [event, groups] of Object.entries(_hookConfig.hooks)) {
101
- const valid = Array.isArray(groups) &&
142
+ const valid =
143
+ Array.isArray(groups) &&
102
144
  groups.length > 0 &&
103
- groups.every(group => _isHookFileName(group) || _isHookGroup(group)) &&
145
+ groups.every((group) => _isHookFileName(group) || _isHookGroup(group)) &&
104
146
  _extractFileNames(groups).length > 0;
105
147
  if (!valid) {
106
- console.error(`Error: hooks/hooks.json "hooks.${event}" must be a non-empty array of .mjs file names or Claude hook groups`);
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
+ );
151
+ console.error(PKG_INTEGRITY_HINT);
107
152
  process.exit(1);
108
153
  }
109
154
  }
110
- 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
+ ) {
111
159
  console.error('Error: hooks/hooks.json "shared" must be an array of .mjs file names');
160
+ console.error(PKG_INTEGRITY_HINT);
112
161
  process.exit(1);
113
162
  }
114
163
 
115
- 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
+ );
116
167
  const SHARED_FILES = _hookConfig.shared ?? [];
117
168
 
118
169
  // ── checks ───────────────────────────────────────────────────────────────────
119
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
+
120
203
  function checkHypoRoot(hypoDir) {
121
204
  if (!existsSync(hypoDir)) {
122
205
  fail('Wiki root exists', hypoDir);
@@ -133,7 +216,17 @@ function checkHypoRoot(hypoDir) {
133
216
  }
134
217
 
135
218
  function checkDirectories(hypoDir) {
136
- 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
+ ];
137
230
  for (const d of required) {
138
231
  if (existsSync(join(hypoDir, d))) {
139
232
  pass(`Directory: ${d}/`);
@@ -196,8 +289,8 @@ function checkSettingsJson() {
196
289
  total++;
197
290
  const cmd = `node ${hooksDir.replace(HOME, '$HOME')}/${file}`;
198
291
  const found = (Array.isArray(settings.hooks?.[event]) ? settings.hooks[event] : [])
199
- .flatMap(g => g.hooks || [])
200
- .some(h => h.command === cmd);
292
+ .flatMap((g) => g.hooks || [])
293
+ .some((h) => h.command === cmd);
201
294
  if (found) registered++;
202
295
  }
203
296
  }
@@ -205,25 +298,111 @@ function checkSettingsJson() {
205
298
  if (registered === total) {
206
299
  pass('settings.json hook registrations', `${registered}/${total} registered`);
207
300
  } else if (registered > 0) {
208
- 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
+ );
209
305
  } else {
210
306
  fail('settings.json hook registrations', `0/${total} registered — run /hypo:init`);
211
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
+ }
212
368
  }
213
369
 
214
370
  function checkGit(hypoDir) {
215
371
  if (!existsSync(join(hypoDir, '.git'))) {
216
- 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
+ );
217
376
  return;
218
377
  }
219
378
  pass('Git repository');
220
379
 
221
- 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
+ });
222
383
  if (remote.status === 0 && remote.stdout.trim()) {
223
384
  pass('Git remote origin', remote.stdout.trim());
224
385
  } else {
225
386
  warn('Git remote origin', 'No remote configured — wiki will not sync/backup automatically');
226
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
+ }
227
406
  }
228
407
 
229
408
  function checkBrokenLinks(hypoDir, ignorePatterns = []) {
@@ -234,7 +413,9 @@ function checkBrokenLinks(hypoDir, ignorePatterns = []) {
234
413
  for (const file of mdFiles) {
235
414
  const raw = readFileSync(file, 'utf-8');
236
415
  const content = raw.replace(/<!--[\s\S]*?-->/g, '').replace(/`[^`\n]+`/g, '');
237
- 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
+ );
238
419
  for (const link of links) {
239
420
  // skip object-path references (e.g. [[hooks.SessionStart]])
240
421
  if (link.includes('.') && !link.endsWith('.md')) continue;
@@ -250,8 +431,11 @@ function checkBrokenLinks(hypoDir, ignorePatterns = []) {
250
431
  if (broken.length === 0) {
251
432
  pass('Broken wiki links', `Scanned ${mdFiles.length} files`);
252
433
  } else {
253
- const sample = broken.slice(0, 5).map(b => `${b.file} → [[${b.link}]]`).join(', ');
254
- const extra = broken.length > 5 ? ` (+${broken.length - 5} more)` : '';
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)` : '';
255
439
  warn('Broken wiki links', `${broken.length} broken: ${sample}${extra}`);
256
440
  }
257
441
  }
@@ -262,7 +446,7 @@ function collectMdFiles(dir, acc = [], hypoDir = '', ignorePatterns = []) {
262
446
  if (entry.startsWith('.')) continue;
263
447
  const full = join(dir, entry);
264
448
  if (hypoDir && isIgnored(full, hypoDir, ignorePatterns)) continue;
265
- const st = statSync(full);
449
+ const st = statSync(full);
266
450
  if (st.isDirectory()) collectMdFiles(full, acc, hypoDir, ignorePatterns);
267
451
  else if (extname(entry) === '.md') acc.push(full);
268
452
  }
@@ -285,14 +469,14 @@ function buildSlugSet(files, hypoDir) {
285
469
  }
286
470
 
287
471
  function checkVerifyBy(hypoDir, ignorePatterns = []) {
288
- const today = new Date().toISOString().slice(0, 10);
289
- const mdFiles = collectMdFiles(hypoDir, [], hypoDir, ignorePatterns);
290
- const overdue = [];
291
- const missing = [];
472
+ const today = new Date().toISOString().slice(0, 10);
473
+ const mdFiles = collectMdFiles(hypoDir, [], hypoDir, ignorePatterns);
474
+ const overdue = [];
475
+ const missing = [];
292
476
 
293
477
  for (const file of mdFiles) {
294
478
  const content = readFileSync(file, 'utf-8');
295
- const fm = parseFrontmatter(content);
479
+ const fm = parseFrontmatter(content);
296
480
  if (!fm) continue;
297
481
 
298
482
  const type = fm.type || '';
@@ -302,30 +486,533 @@ function checkVerifyBy(hypoDir, ignorePatterns = []) {
302
486
  if (!fm.verify_by) {
303
487
  missing.push(relative(hypoDir, file));
304
488
  }
305
- 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
+ ) {
306
494
  overdue.push({ file: relative(hypoDir, file), due: fm.verify_by_date });
307
495
  }
308
496
  }
309
497
 
310
498
  if (overdue.length > 0) {
311
- const sample = overdue.slice(0, 3).map(o => `${o.file} (due ${o.due})`).join(', ');
312
- const extra = overdue.length > 3 ? ` (+${overdue.length - 3} more)` : '';
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)` : '';
313
504
  warn('verify_by_date overdue', `${overdue.length} overdue: ${sample}${extra}`);
314
505
  } else {
315
506
  pass('verify_by_date overdue', 'No overdue pages');
316
507
  }
317
508
 
318
509
  if (missing.length > 0) {
319
- 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
+ );
320
514
  } else {
321
515
  pass('verify_by coverage', 'All tracked pages have verify_by question');
322
516
  }
323
517
  }
324
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
+
325
1011
  // ── main ─────────────────────────────────────────────────────────────────────
326
1012
 
327
1013
  const args = parseArgs(process.argv);
328
1014
 
1015
+ checkExternalDeps();
329
1016
  const ignorePatterns = loadHypoIgnore(args.hypoDir);
330
1017
  const rootOk = checkHypoRoot(args.hypoDir);
331
1018
  if (rootOk) {
@@ -336,6 +1023,12 @@ if (rootOk) {
336
1023
  }
337
1024
  checkHooks();
338
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);
339
1032
  checkGit(args.hypoDir);
340
1033
 
341
1034
  // ── report ───────────────────────────────────────────────────────────────────
@@ -349,13 +1042,13 @@ if (args.json) {
349
1042
  console.log(`${icons[c.status]} ${c.label}${detail}`);
350
1043
  }
351
1044
 
352
- const fails = checks.filter(c => c.status === 'fail').length;
353
- const warns = checks.filter(c => c.status === 'warn').length;
354
- const passes = checks.filter(c => c.status === 'pass').length;
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;
355
1048
 
356
1049
  console.log('');
357
1050
  console.log(`Result: ${passes} passed, ${warns} warnings, ${fails} failed`);
358
1051
  if (fails > 0) console.log('Run /hypo:init to repair installation issues.');
359
1052
  }
360
1053
 
361
- if (checks.some(c => c.status === 'fail')) process.exit(1);
1054
+ if (checks.some((c) => c.status === 'fail')) process.exit(1);