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
@@ -10,24 +10,51 @@
10
10
  *
11
11
  * Options:
12
12
  * --apply Actually remove files / edit settings.json (default: dry-run)
13
- * --codex Also remove Codex hooks (~/.codex/hooks/)
13
+ * --codex Also remove Codex hooks/commands + ext hard-copies (~/.codex/)
14
+ * --force-commands Remove user-modified slash commands instead of preserving them
15
+ * --force-extensions Remove user-modified extension files (hypo-ext-*) instead of preserving them
14
16
  * --hooks-dir=<path> Override Claude hooks directory (default: ~/.claude/hooks)
17
+ *
18
+ * Extensions (ADR 0024 fix #34): hypo-ext-* hard-copies under
19
+ * ~/.claude/{hooks,commands,skills,agents}/ and ~/.codex/{hooks,commands}/ (with
20
+ * --codex) are removed when their on-disk SHA matches the recorded one in
21
+ * ~/.claude/hypo-pkg.json#extensions.<target>. User-modified copies are preserved
22
+ * unless --force-extensions; symlinks/non-regular files are NEVER removed (force
23
+ * does not follow them). The wiki source (~/hypomnema/extensions/) is preserved.
15
24
  */
16
25
 
17
26
  import { existsSync, readFileSync, writeFileSync, rmSync, rmdirSync, readdirSync } from 'fs';
18
27
  import { join } from 'path';
19
28
  import { homedir } from 'os';
20
29
  import { fileURLToPath } from 'url';
21
- import { readPkgJson as readPkgJsonSafe, sha256, isRegularFile, readFileIfRegular } from './lib/pkg-json.mjs';
22
-
23
- const HOME = homedir();
30
+ import {
31
+ readPkgJson as readPkgJsonSafe,
32
+ sha256,
33
+ isRegularFile,
34
+ readFileIfRegular,
35
+ } from './lib/pkg-json.mjs';
36
+ import {
37
+ EXT_PREFIX,
38
+ EXT_TYPES,
39
+ CODEX_TYPES,
40
+ readExtensionPkgStateNoMutate,
41
+ } from './lib/extensions.mjs';
42
+
43
+ const HOME = homedir();
24
44
  const SCRIPT_DIR = fileURLToPath(new URL('.', import.meta.url));
25
- const PKG_ROOT = join(SCRIPT_DIR, '..');
45
+ const PKG_ROOT = join(SCRIPT_DIR, '..');
46
+
47
+ // Shown after every fatal package-integrity error. These conditions mean the
48
+ // shipped hooks/hooks.json is missing or malformed — never a user mistake —
49
+ // so the only useful next step is a re-install of the package.
50
+ const PKG_INTEGRITY_HINT =
51
+ '→ This indicates a corrupt or incomplete install. Re-install with `npm install -g hypomnema` (or re-install the Claude Code plugin).';
26
52
 
27
53
  function removeCommands(apply, force) {
28
54
  const targetDir = join(HOME, '.claude', 'commands', 'hypo');
29
- const pkgPath = join(HOME, '.claude', 'hypo-pkg.json');
30
- if (!existsSync(targetDir)) return { removed: [], skippedUserModified: [], skippedNonRegular: [] };
55
+ const pkgPath = join(HOME, '.claude', 'hypo-pkg.json');
56
+ if (!existsSync(targetDir))
57
+ return { removed: [], skippedUserModified: [], skippedNonRegular: [] };
31
58
 
32
59
  const recorded = readPkgJsonSafe(pkgPath).commands || {};
33
60
  const removed = [];
@@ -67,14 +94,201 @@ function removeCommands(apply, force) {
67
94
  return { removed, skippedUserModified, skippedNonRegular };
68
95
  }
69
96
 
97
+ // ── extensions removal (ADR 0024) ───────────────────────────────────
98
+
99
+ // Strip per-target extension SHA records from ~/.claude/hypo-pkg.json. Surgical:
100
+ // we only touch the entries for keys we actually removed, so a `--force-extensions`
101
+ // run that leaves user-modified files behind keeps their recorded SHAs intact
102
+ // (doctor still has something to compare against next time). When the per-target
103
+ // map empties out, drop the target key; when the whole `extensions` object empties,
104
+ // drop it too. Other targets' records (e.g. codex map during a Claude-only uninstall)
105
+ // are never touched.
106
+ function stripExtensionsFromPkg(pkgPath, target, removedKeys, apply) {
107
+ if (!existsSync(pkgPath) || removedKeys.length === 0) return false;
108
+ let pkg;
109
+ try {
110
+ pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
111
+ } catch {
112
+ return false;
113
+ }
114
+ if (!pkg || typeof pkg !== 'object' || Array.isArray(pkg)) return false;
115
+ const extensions = pkg.extensions;
116
+ if (!extensions || typeof extensions !== 'object' || Array.isArray(extensions)) return false;
117
+ const perTarget = extensions[target];
118
+ if (!perTarget || typeof perTarget !== 'object' || Array.isArray(perTarget)) return false;
119
+
120
+ let changed = false;
121
+ for (const key of removedKeys) {
122
+ if (key in perTarget) {
123
+ delete perTarget[key];
124
+ changed = true;
125
+ }
126
+ }
127
+ if (Object.keys(perTarget).length === 0) {
128
+ delete extensions[target];
129
+ changed = true;
130
+ }
131
+ if (Object.keys(extensions).length === 0) {
132
+ delete pkg.extensions;
133
+ changed = true;
134
+ }
135
+ if (changed && apply) {
136
+ writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
137
+ }
138
+ return changed;
139
+ }
140
+
141
+ // Remove hypo-ext-* hard-copies for one target (claude | codex). Mirrors
142
+ // removeCommands' 3-way model: filesystem scan + per-target recorded SHA from
143
+ // hypo-pkg.json#extensions[target] decides ownership. A file we never installed
144
+ // (no recorded SHA) is left alone — that may be a foreign hypo-ext-* file the
145
+ // user created by hand, never ours to delete. A user-modified file is preserved
146
+ // unless --force-extensions; a symlink/non-regular target is always preserved
147
+ // (force does not follow them, matching install/upgrade E3's guard).
148
+ function removeExtensions(target, apply, force) {
149
+ const targetRoot = target === 'codex' ? join(HOME, '.codex') : join(HOME, '.claude');
150
+ const types = target === 'codex' ? CODEX_TYPES : EXT_TYPES;
151
+ // Per-target SHAs live in ~/.claude/hypo-pkg.json regardless of target (the
152
+ // file is a single source of truth, with extensions: { claude: {}, codex: {} }).
153
+ const pkgPath = join(HOME, '.claude', 'hypo-pkg.json');
154
+ const recorded = readExtensionPkgStateNoMutate(pkgPath, target);
155
+
156
+ const removed = [];
157
+ const removedKeys = [];
158
+ const skippedUserModified = [];
159
+ const skippedNonRegular = [];
160
+
161
+ for (const type of types) {
162
+ const typeDir = join(targetRoot, type);
163
+ if (!existsSync(typeDir)) continue;
164
+ let entries;
165
+ try {
166
+ entries = readdirSync(typeDir);
167
+ } catch {
168
+ continue;
169
+ }
170
+ for (const fname of entries) {
171
+ if (!fname.startsWith(EXT_PREFIX)) continue;
172
+ const key = `${type}/${fname}`;
173
+ const recordedSHA = recorded[key];
174
+ if (!recordedSHA) continue; // not tracked by us → leave alone
175
+ const fullPath = join(typeDir, fname);
176
+
177
+ if (!isRegularFile(fullPath)) {
178
+ // Refuse to follow symlinks/sockets even under --force-extensions.
179
+ skippedNonRegular.push(fullPath);
180
+ continue;
181
+ }
182
+ const buf = readFileIfRegular(fullPath);
183
+ const sha = buf ? sha256(buf) : null;
184
+
185
+ if (sha === recordedSHA || force) {
186
+ if (apply) rmSync(fullPath);
187
+ removed.push(fullPath);
188
+ removedKeys.push(key);
189
+ } else {
190
+ skippedUserModified.push(fullPath);
191
+ }
192
+ }
193
+ }
194
+ return { target, removed, removedKeys, skippedUserModified, skippedNonRegular };
195
+ }
196
+
197
+ // True iff `pkg.json` still holds extensions state for a target the current
198
+ // uninstall did NOT process. Without this guard, a Claude-only uninstall would
199
+ // wholesale-rm pkg.json even when ~/.codex/hooks/hypo-ext-* hard-copies (and
200
+ // their `extensions.codex` ownership baseline) are still live — silently
201
+ // orphaning Codex's per-target SHA contract (plan D2b/E6).
202
+ //
203
+ // Scope is narrowed to unprocessed targets so the legacy clean-uninstall path
204
+ // stands: when commands are removed in full (commandResult has no skipped
205
+ // entries) the stale `pkg.commands` map still gets wholesale-removed, matching
206
+ // pre-E6 behavior. A processed target whose own state is non-empty (e.g.
207
+ // user-modified ext file held back) is already guarded by its skippedUserModified
208
+ // / skippedNonRegular tally — so we don't double-count it here.
209
+ function unprocessedExtensionTargetRemains(pkgPath, processedTargets) {
210
+ if (!existsSync(pkgPath)) return false;
211
+ let pkg;
212
+ try {
213
+ pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
214
+ } catch {
215
+ // Cannot reliably inspect — refuse to delete (fail-safe: keep the file).
216
+ return true;
217
+ }
218
+ const exts = pkg && typeof pkg === 'object' && !Array.isArray(pkg) ? pkg.extensions : null;
219
+ if (!exts || typeof exts !== 'object' || Array.isArray(exts)) return false;
220
+ for (const [target, m] of Object.entries(exts)) {
221
+ if (processedTargets.has(target)) continue;
222
+ if (m && typeof m === 'object' && !Array.isArray(m) && Object.keys(m).length > 0) {
223
+ return true;
224
+ }
225
+ }
226
+ return false;
227
+ }
228
+
229
+ // settings.json: strip groups whose command points to a hypo-ext-* path under
230
+ // the target's hooks dir. Identity is path-based (plan §0 D1) — no hookMap needed
231
+ // because ext hooks are never enumerated there. Mixed groups (foreign hook +
232
+ // ours) keep the foreign hook; ours-only groups are dropped entirely. Mirrors
233
+ // stripSettingsJson's flatMap pattern so other-plugin invariants (§7.3) hold.
234
+ function stripExtensionSettings(settingsPath, hooksDir, apply) {
235
+ if (!existsSync(settingsPath)) return { stripped: [] };
236
+ let settings;
237
+ try {
238
+ settings = JSON.parse(readFileSync(settingsPath, 'utf-8'));
239
+ } catch {
240
+ return { stripped: [], error: `${settingsPath} is not valid JSON — skipping` };
241
+ }
242
+ if (!settings.hooks || typeof settings.hooks !== 'object') return { stripped: [] };
243
+
244
+ const cmdPrefix = `node ${hooksDir.replace(HOME, '$HOME')}/${EXT_PREFIX}`;
245
+ const isExtHook = (h) =>
246
+ h && h.type === 'command' && typeof h.command === 'string' && h.command.startsWith(cmdPrefix);
247
+
248
+ const stripped = [];
249
+ let changed = false;
250
+
251
+ for (const [event, groups] of Object.entries(settings.hooks)) {
252
+ if (!Array.isArray(groups)) continue;
253
+
254
+ const filtered = groups.flatMap((group) => {
255
+ if (!Array.isArray(group.hooks)) return [group];
256
+
257
+ const extHooks = group.hooks.filter(isExtHook);
258
+ const others = group.hooks.filter((h) => !isExtHook(h));
259
+
260
+ for (const h of extHooks) stripped.push(`${event}: ${h.command}`);
261
+
262
+ if (extHooks.length === 0) return [group];
263
+ changed = true;
264
+ if (others.length === 0) return [];
265
+ return [{ ...group, hooks: others }];
266
+ });
267
+
268
+ settings.hooks[event] = filtered;
269
+ }
270
+
271
+ if (changed && apply) {
272
+ writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
273
+ }
274
+ return { stripped };
275
+ }
276
+
70
277
  // ── arg parsing ──────────────────────────────────────────────────────────────
71
278
 
72
279
  function parseArgs(argv) {
73
- const args = { apply: false, codex: false, hooksDir: null, forceCommands: false };
280
+ const args = {
281
+ apply: false,
282
+ codex: false,
283
+ hooksDir: null,
284
+ forceCommands: false,
285
+ forceExtensions: false,
286
+ };
74
287
  for (const arg of argv.slice(2)) {
75
- if (arg === '--apply') args.apply = true;
76
- else if (arg === '--codex') args.codex = true;
77
- else if (arg === '--force-commands') args.forceCommands = true;
288
+ if (arg === '--apply') args.apply = true;
289
+ else if (arg === '--codex') args.codex = true;
290
+ else if (arg === '--force-commands') args.forceCommands = true;
291
+ else if (arg === '--force-extensions') args.forceExtensions = true;
78
292
  else if (arg.startsWith('--hooks-dir=')) args.hooksDir = arg.slice(12);
79
293
  }
80
294
  return args;
@@ -88,10 +302,12 @@ function loadHookFiles() {
88
302
  cfg = JSON.parse(readFileSync(join(PKG_ROOT, 'hooks', 'hooks.json'), 'utf-8'));
89
303
  } catch {
90
304
  console.error('Error: cannot read hooks/hooks.json');
305
+ console.error(PKG_INTEGRITY_HINT);
91
306
  process.exit(1);
92
307
  }
93
308
  if (!cfg?.hooks || typeof cfg.hooks !== 'object' || Array.isArray(cfg.hooks)) {
94
309
  console.error('Error: hooks/hooks.json must contain a "hooks" object');
310
+ console.error(PKG_INTEGRITY_HINT);
95
311
  process.exit(1);
96
312
  }
97
313
 
@@ -110,7 +326,10 @@ function loadHookFiles() {
110
326
  for (const h of entry.hooks) {
111
327
  if (h.type === 'command' && typeof h.command === 'string') {
112
328
  const m = h.command.match(/\/hooks\/([^/\s]+\.mjs)$/);
113
- if (m) { hookFiles.add(m[1]); filenames.push(m[1]); }
329
+ if (m) {
330
+ hookFiles.add(m[1]);
331
+ filenames.push(m[1]);
332
+ }
114
333
  }
115
334
  }
116
335
  }
@@ -127,7 +346,8 @@ function loadHookFiles() {
127
346
  // ── hook file removal ────────────────────────────────────────────────────────
128
347
 
129
348
  function removeHookFiles(hooksDir, hookFiles, apply) {
130
- const removed = [], missing = [];
349
+ const removed = [],
350
+ missing = [];
131
351
  for (const file of hookFiles) {
132
352
  const p = join(hooksDir, file);
133
353
  if (existsSync(p)) {
@@ -160,24 +380,24 @@ function stripSettingsJson(settingsPath, hooksDir, hookMap, apply) {
160
380
  for (const [event, groups] of Object.entries(settings.hooks)) {
161
381
  if (!Array.isArray(groups)) continue;
162
382
 
163
- const managed = (hookMap[event] ?? []);
164
- const isHypoHook = h =>
383
+ const managed = hookMap[event] ?? [];
384
+ const isHypoHook = (h) =>
165
385
  h.type === 'command' &&
166
386
  typeof h.command === 'string' &&
167
- managed.some(file => h.command === `node ${hooksDir.replace(HOME, '$HOME')}/${file}`);
387
+ managed.some((file) => h.command === `node ${hooksDir.replace(HOME, '$HOME')}/${file}`);
168
388
 
169
- const filtered = groups.flatMap(group => {
389
+ const filtered = groups.flatMap((group) => {
170
390
  if (!Array.isArray(group.hooks)) return [group];
171
391
 
172
- const hypoHooks = group.hooks.filter(h => isHypoHook(h));
173
- const userHooks = group.hooks.filter(h => !isHypoHook(h));
392
+ const hypoHooks = group.hooks.filter((h) => isHypoHook(h));
393
+ const userHooks = group.hooks.filter((h) => !isHypoHook(h));
174
394
 
175
395
  for (const h of hypoHooks) stripped.push(`${event}: ${h.command}`);
176
396
 
177
- if (hypoHooks.length === 0) return [group]; // no Hypomnema hooks → keep as-is
397
+ if (hypoHooks.length === 0) return [group]; // no Hypomnema hooks → keep as-is
178
398
  changed = true;
179
- if (userHooks.length === 0) return []; // all Hypomnema → remove group
180
- return [{ ...group, hooks: userHooks }]; // mixed → keep only user hooks
399
+ if (userHooks.length === 0) return []; // all Hypomnema → remove group
400
+ return [{ ...group, hooks: userHooks }]; // mixed → keep only user hooks
181
401
  });
182
402
 
183
403
  settings.hooks[event] = filtered;
@@ -192,35 +412,75 @@ function stripSettingsJson(settingsPath, hooksDir, hookMap, apply) {
192
412
 
193
413
  // ── main ─────────────────────────────────────────────────────────────────────
194
414
 
195
- const args = parseArgs(process.argv);
415
+ const args = parseArgs(process.argv);
196
416
  const dryRun = !args.apply;
197
417
 
198
418
  const { hookMap, hookFiles } = loadHookFiles();
199
419
 
200
- const claudeHooksDir = args.hooksDir ?? join(HOME, '.claude', 'hooks');
201
- const claudeSettings = join(HOME, '.claude', 'settings.json');
420
+ const claudeHooksDir = args.hooksDir ?? join(HOME, '.claude', 'hooks');
421
+ const claudeSettings = join(HOME, '.claude', 'settings.json');
202
422
 
203
- const hookResult = removeHookFiles(claudeHooksDir, hookFiles, args.apply);
423
+ const hookResult = removeHookFiles(claudeHooksDir, hookFiles, args.apply);
204
424
  const settingsResult = stripSettingsJson(claudeSettings, claudeHooksDir, hookMap, args.apply);
205
- const commandResult = removeCommands(args.apply, args.forceCommands);
206
-
207
- // pkg.json metadata file removal only when no user-modified commands remain on disk.
425
+ const commandResult = removeCommands(args.apply, args.forceCommands);
426
+
427
+ // Extensions (ADR 0024). Order matters: remove files first, then strip
428
+ // settings, then surgically clear the per-target SHA map. The SHA strip uses
429
+ // removedKeys so a user-modified file we left in place keeps its recorded SHA
430
+ // (doctor still has a baseline next run). Settings are path-based and run even
431
+ // if no files were removed — a manifest could have registered entries that the
432
+ // hook copy was deleted by hand (force-commands legacy state).
208
433
  const pkgJsonPath = join(HOME, '.claude', 'hypo-pkg.json');
209
- let pkgJsonRemoved = null;
210
- const keepPkgJson = commandResult.skippedUserModified.length > 0 || commandResult.skippedNonRegular.length > 0;
211
- if (existsSync(pkgJsonPath) && !keepPkgJson) {
212
- if (args.apply) rmSync(pkgJsonPath);
213
- pkgJsonRemoved = pkgJsonPath;
214
- }
434
+ const claudeExtResult = removeExtensions('claude', args.apply, args.forceExtensions);
435
+ const claudeExtSettings = stripExtensionSettings(claudeSettings, claudeHooksDir, args.apply);
215
436
 
216
- let codexHookResult = { removed: [], missing: [] };
437
+ let codexHookResult = { removed: [], missing: [] };
217
438
  let codexSettingsResult = { stripped: [] };
439
+ let codexCommandResult = null;
440
+ let codexExtResult = {
441
+ target: 'codex',
442
+ removed: [],
443
+ removedKeys: [],
444
+ skippedUserModified: [],
445
+ skippedNonRegular: [],
446
+ };
447
+ let codexExtSettings = { stripped: [] };
218
448
 
219
449
  if (args.codex) {
220
450
  const codexHooksDir = join(HOME, '.codex', 'hooks');
221
451
  const codexSettings = join(HOME, '.codex', 'settings.json');
222
- codexHookResult = removeHookFiles(codexHooksDir, hookFiles, args.apply);
452
+ codexHookResult = removeHookFiles(codexHooksDir, hookFiles, args.apply);
223
453
  codexSettingsResult = stripSettingsJson(codexSettings, codexHooksDir, hookMap, args.apply);
454
+ codexExtResult = removeExtensions('codex', args.apply, args.forceExtensions);
455
+ codexExtSettings = stripExtensionSettings(codexSettings, codexHooksDir, args.apply);
456
+ }
457
+
458
+ // Surgical per-target ext SHA strip — only for files we actually removed.
459
+ stripExtensionsFromPkg(pkgJsonPath, 'claude', claudeExtResult.removedKeys, args.apply);
460
+ if (args.codex) {
461
+ stripExtensionsFromPkg(pkgJsonPath, 'codex', codexExtResult.removedKeys, args.apply);
462
+ }
463
+
464
+ // pkg.json metadata file removal — only when no user-tracked state remains.
465
+ // Both command and extension preservation cases hold the file: doctor still
466
+ // needs the recorded SHAs of files we left behind so the next sync compares
467
+ // against truth, not nothing.
468
+ let pkgJsonRemoved = null;
469
+ const processedExtTargets = new Set(['claude']);
470
+ if (args.codex) processedExtTargets.add('codex');
471
+ const keepPkgJson =
472
+ commandResult.skippedUserModified.length > 0 ||
473
+ commandResult.skippedNonRegular.length > 0 ||
474
+ claudeExtResult.skippedUserModified.length > 0 ||
475
+ claudeExtResult.skippedNonRegular.length > 0 ||
476
+ codexExtResult.skippedUserModified.length > 0 ||
477
+ codexExtResult.skippedNonRegular.length > 0 ||
478
+ // Claude-only uninstall must not wholesale-rm pkg.json if extensions.codex
479
+ // (or any other unprocessed target) still tracks live Codex hard-copies.
480
+ unprocessedExtensionTargetRemains(pkgJsonPath, processedExtTargets);
481
+ if (existsSync(pkgJsonPath) && !keepPkgJson) {
482
+ if (args.apply) rmSync(pkgJsonPath);
483
+ pkgJsonRemoved = pkgJsonPath;
224
484
  }
225
485
 
226
486
  // ── report ───────────────────────────────────────────────────────────────────
@@ -230,18 +490,79 @@ if (dryRun) lines.push('[DRY RUN — pass --apply to make changes]');
230
490
 
231
491
  const allRemoved = [...hookResult.removed, ...codexHookResult.removed];
232
492
  const allStripped = [...settingsResult.stripped, ...codexSettingsResult.stripped];
233
-
234
- if (allRemoved.length) lines.push(`✓ Hook files ${dryRun ? 'to remove' : 'removed'} (${allRemoved.length}):\n${allRemoved.map(p => ` ${p}`).join('\n')}`);
235
- if (commandResult.removed.length) lines.push(`✓ Slash commands ${dryRun ? 'to remove' : 'removed'} (${commandResult.removed.length}):\n${commandResult.removed.map(p => ` ${p}`).join('\n')}`);
236
- if (commandResult.skippedUserModified.length) lines.push(`⊘ Slash commands preserved (user-modified, ${commandResult.skippedUserModified.length}) — pass --force-commands to remove anyway:\n${commandResult.skippedUserModified.map(p => ` ${p}`).join('\n')}`);
237
- if (commandResult.skippedNonRegular.length) lines.push(`⊘ Slash commands skipped (non-regular file, ${commandResult.skippedNonRegular.length}) — refusing to follow symlinks:\n${commandResult.skippedNonRegular.map(p => ` ${p}`).join('\n')}`);
238
- if (allStripped.length) lines.push(`✓ settings.json entries ${dryRun ? 'to remove' : 'removed'} (${allStripped.length}):\n${allStripped.map(p => ` ${p}`).join('\n')}`);
239
- if (pkgJsonRemoved) lines.push(`✓ Package metadata ${dryRun ? 'to remove' : 'removed'}: ${pkgJsonRemoved}`);
240
- if (keepPkgJson && !pkgJsonRemoved && existsSync(pkgJsonPath)) lines.push(`⊘ Package metadata preserved (${pkgJsonPath}) — user-modified or non-regular commands still tracked`);
241
- if (hookResult.missing.length) lines.push(`⊘ Already absent (${hookResult.missing.length}):\n${hookResult.missing.map(p => ` ${p}`).join('\n')}`);
493
+ const extRemoved = [...claudeExtResult.removed, ...codexExtResult.removed];
494
+ const extSkippedUserModified = [
495
+ ...claudeExtResult.skippedUserModified,
496
+ ...codexExtResult.skippedUserModified,
497
+ ];
498
+ const extSkippedNonRegular = [
499
+ ...claudeExtResult.skippedNonRegular,
500
+ ...codexExtResult.skippedNonRegular,
501
+ ];
502
+ const extStripped = [...claudeExtSettings.stripped, ...codexExtSettings.stripped];
503
+
504
+ if (allRemoved.length)
505
+ lines.push(
506
+ `✓ Hook files ${dryRun ? 'to remove' : 'removed'} (${allRemoved.length}):\n${allRemoved.map((p) => ` ${p}`).join('\n')}`,
507
+ );
508
+ if (commandResult.removed.length)
509
+ lines.push(
510
+ `✓ Slash commands ${dryRun ? 'to remove' : 'removed'} (${commandResult.removed.length}):\n${commandResult.removed.map((p) => ` ${p}`).join('\n')}`,
511
+ );
512
+ if (commandResult.skippedUserModified.length)
513
+ lines.push(
514
+ `⊘ Slash commands preserved (user-modified, ${commandResult.skippedUserModified.length}) — pass --force-commands to remove anyway:\n${commandResult.skippedUserModified.map((p) => ` ${p}`).join('\n')}`,
515
+ );
516
+ if (commandResult.skippedNonRegular.length)
517
+ lines.push(
518
+ `⊘ Slash commands skipped (non-regular file, ${commandResult.skippedNonRegular.length}) — refusing to follow symlinks:\n${commandResult.skippedNonRegular.map((p) => ` ${p}`).join('\n')}`,
519
+ );
520
+ if (extRemoved.length)
521
+ lines.push(
522
+ `✓ Extension files ${dryRun ? 'to remove' : 'removed'} (${extRemoved.length}):\n${extRemoved.map((p) => ` ${p}`).join('\n')}`,
523
+ );
524
+ if (extSkippedUserModified.length)
525
+ lines.push(
526
+ `⊘ Extension files preserved (user-modified, ${extSkippedUserModified.length}) — pass --force-extensions to remove anyway:\n${extSkippedUserModified.map((p) => ` ${p}`).join('\n')}`,
527
+ );
528
+ if (extSkippedNonRegular.length)
529
+ lines.push(
530
+ `⊘ Extension files skipped (non-regular file, ${extSkippedNonRegular.length}) — refusing to follow symlinks:\n${extSkippedNonRegular.map((p) => ` ${p}`).join('\n')}`,
531
+ );
532
+ if (extStripped.length)
533
+ lines.push(
534
+ `✓ settings.json extension entries ${dryRun ? 'to remove' : 'removed'} (${extStripped.length}):\n${extStripped.map((p) => ` ${p}`).join('\n')}`,
535
+ );
536
+ if (allStripped.length)
537
+ lines.push(
538
+ `✓ settings.json entries ${dryRun ? 'to remove' : 'removed'} (${allStripped.length}):\n${allStripped.map((p) => ` ${p}`).join('\n')}`,
539
+ );
540
+ if (pkgJsonRemoved)
541
+ lines.push(`✓ Package metadata ${dryRun ? 'to remove' : 'removed'}: ${pkgJsonRemoved}`);
542
+ if (keepPkgJson && !pkgJsonRemoved && existsSync(pkgJsonPath))
543
+ lines.push(
544
+ `⊘ Package metadata preserved (${pkgJsonPath}) — user-modified or non-regular files still tracked`,
545
+ );
546
+ if (hookResult.missing.length)
547
+ lines.push(
548
+ `⊘ Already absent (${hookResult.missing.length}):\n${hookResult.missing.map((p) => ` ${p}`).join('\n')}`,
549
+ );
242
550
  if (settingsResult.error) lines.push(`⚠ ${settingsResult.error}`);
243
-
244
- if (!allRemoved.length && !allStripped.length && !hookResult.missing.length && !commandResult.removed.length && !pkgJsonRemoved && !commandResult.skippedUserModified.length) {
551
+ if (claudeExtSettings.error) lines.push(`⚠ ${claudeExtSettings.error}`);
552
+ if (codexExtSettings.error) lines.push(`⚠ ${codexExtSettings.error}`);
553
+
554
+ if (
555
+ !allRemoved.length &&
556
+ !allStripped.length &&
557
+ !extRemoved.length &&
558
+ !extStripped.length &&
559
+ !hookResult.missing.length &&
560
+ !commandResult.removed.length &&
561
+ !pkgJsonRemoved &&
562
+ !commandResult.skippedUserModified.length &&
563
+ !extSkippedUserModified.length &&
564
+ !extSkippedNonRegular.length
565
+ ) {
245
566
  lines.push('Nothing to uninstall — Hypomnema does not appear to be installed.');
246
567
  }
247
568