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
@@ -12,37 +12,78 @@
12
12
  * Options:
13
13
  * --hypo-dir=<path> Hypomnema root directory (default: resolved via HYPO_DIR / hypo-config.md scan / ~/hypomnema)
14
14
  * --apply Apply hook file updates and settings.json merges
15
+ * --force-commands Overwrite user-modified slash command files (creates .bak)
16
+ * --force-extensions Overwrite user-modified / conflicting extension copies (creates .bak)
17
+ * --codex Mirror to ~/.codex/{hooks,commands,settings.json} — core
18
+ * hook drift/apply, settings.json registration, the
19
+ * wiki-*.mjs → hypo-*.mjs rename migration (fix #48), and
20
+ * the user-extensions companion sync (E4 / fix #32).
15
21
  * --json Output results as JSON
16
22
  */
17
23
 
18
- import { existsSync, readFileSync, writeFileSync, copyFileSync, mkdirSync, readdirSync, renameSync, unlinkSync } from 'fs';
24
+ import {
25
+ existsSync,
26
+ readFileSync,
27
+ writeFileSync,
28
+ copyFileSync,
29
+ mkdirSync,
30
+ readdirSync,
31
+ renameSync,
32
+ unlinkSync,
33
+ } from 'fs';
19
34
  import { join } from 'path';
20
35
  import { homedir } from 'os';
21
36
  import { fileURLToPath } from 'url';
22
37
  import { createHash } from 'crypto';
23
38
  import { resolveHypoRoot, expandHome } from './lib/hypo-root.mjs';
24
39
  import { parseFrontmatter } from './lib/frontmatter.mjs';
25
- import { readPkgJson as readPkgJsonSafe, writePkgJsonAtomic, sha256 as sha256Buf, isRegularFile, readFileIfRegular } from './lib/pkg-json.mjs';
26
-
27
- const HOME = homedir();
28
- const SCRIPT_DIR = fileURLToPath(new URL('.', import.meta.url));
29
- const PKG_ROOT = join(SCRIPT_DIR, '..');
30
- const HOOKS_SRC = join(PKG_ROOT, 'hooks');
40
+ import {
41
+ readPkgJson as readPkgJsonSafe,
42
+ writePkgJsonAtomic,
43
+ sha256 as sha256Buf,
44
+ isRegularFile,
45
+ readFileIfRegular,
46
+ } from './lib/pkg-json.mjs';
47
+ import { syncExtensions } from './lib/extensions.mjs';
48
+
49
+ const HOME = homedir();
50
+ const SCRIPT_DIR = fileURLToPath(new URL('.', import.meta.url));
51
+ const PKG_ROOT = join(SCRIPT_DIR, '..');
52
+
53
+ // Shown after every fatal package-integrity error. These conditions mean the
54
+ // shipped hooks/hooks.json is missing or malformed — never a user mistake —
55
+ // so the only useful next step is a re-install of the package.
56
+ const PKG_INTEGRITY_HINT =
57
+ '→ This indicates a corrupt or incomplete install. Re-install with `npm install -g hypomnema` (or re-install the Claude Code plugin).';
58
+ const HOOKS_SRC = join(PKG_ROOT, 'hooks');
31
59
  const COMMANDS_SRC = join(PKG_ROOT, 'commands');
32
- const TEMPLATES = join(PKG_ROOT, 'templates');
60
+ const TEMPLATES = join(PKG_ROOT, 'templates');
33
61
 
34
- function sha256(buf) { return sha256Buf(buf); }
35
- function pkgJsonPath() { return join(HOME, '.claude', 'hypo-pkg.json'); }
62
+ function sha256(buf) {
63
+ return sha256Buf(buf);
64
+ }
65
+ function pkgJsonPath() {
66
+ return join(HOME, '.claude', 'hypo-pkg.json');
67
+ }
36
68
 
37
69
  // ── arg parsing ──────────────────────────────────────────────────────────────
38
70
 
39
71
  function parseArgs(argv) {
40
- const args = { hypoDir: null, apply: false, json: false, forceCommands: false };
72
+ const args = {
73
+ hypoDir: null,
74
+ apply: false,
75
+ json: false,
76
+ forceCommands: false,
77
+ forceExtensions: false,
78
+ codex: false,
79
+ };
41
80
  for (const arg of argv.slice(2)) {
42
81
  if (arg.startsWith('--hypo-dir=')) args.hypoDir = expandHome(arg.slice(11));
43
- else if (arg === '--apply') args.apply = true;
44
- else if (arg === '--force-commands') args.forceCommands = true;
45
- else if (arg === '--json') args.json = true;
82
+ else if (arg === '--apply') args.apply = true;
83
+ else if (arg === '--force-commands') args.forceCommands = true;
84
+ else if (arg === '--force-extensions') args.forceExtensions = true;
85
+ else if (arg === '--codex') args.codex = true;
86
+ else if (arg === '--json') args.json = true;
46
87
  }
47
88
  if (!args.hypoDir) args.hypoDir = resolveHypoRoot();
48
89
  return args;
@@ -75,14 +116,21 @@ try {
75
116
  _hookConfig = JSON.parse(readFileSync(join(PKG_ROOT, 'hooks', 'hooks.json'), 'utf-8'));
76
117
  } catch {
77
118
  console.error(`Error: cannot read hooks/hooks.json from package root: ${PKG_ROOT}`);
119
+ console.error(PKG_INTEGRITY_HINT);
78
120
  process.exit(1);
79
121
  }
80
122
  if (!_hookConfig || typeof _hookConfig !== 'object' || Array.isArray(_hookConfig)) {
81
123
  console.error('Error: hooks/hooks.json must be a JSON object');
124
+ console.error(PKG_INTEGRITY_HINT);
82
125
  process.exit(1);
83
126
  }
84
- if (!_hookConfig.hooks || typeof _hookConfig.hooks !== 'object' || Array.isArray(_hookConfig.hooks)) {
127
+ if (
128
+ !_hookConfig.hooks ||
129
+ typeof _hookConfig.hooks !== 'object' ||
130
+ Array.isArray(_hookConfig.hooks)
131
+ ) {
85
132
  console.error('Error: hooks/hooks.json must contain a "hooks" object');
133
+ console.error(PKG_INTEGRITY_HINT);
86
134
  process.exit(1);
87
135
  }
88
136
  function _extractCommandFileName(command) {
@@ -98,53 +146,66 @@ function _isHookFileName(file) {
98
146
  }
99
147
 
100
148
  function _isHookGroup(group) {
101
- return group &&
149
+ return (
150
+ group &&
102
151
  typeof group === 'object' &&
103
152
  !Array.isArray(group) &&
104
153
  Array.isArray(group.hooks) &&
105
154
  group.hooks.length > 0 &&
106
- group.hooks.every(hook =>
107
- hook &&
108
- typeof hook === 'object' &&
109
- !Array.isArray(hook) &&
110
- hook.type === 'command' &&
111
- _extractCommandFileName(hook.command)
112
- );
155
+ group.hooks.every(
156
+ (hook) =>
157
+ hook &&
158
+ typeof hook === 'object' &&
159
+ !Array.isArray(hook) &&
160
+ hook.type === 'command' &&
161
+ _extractCommandFileName(hook.command),
162
+ )
163
+ );
113
164
  }
114
165
 
115
166
  // Extract .mjs file names from both old format (string[]) and new format (hook-group object[])
116
167
  function _extractFileNames(groups) {
117
- return groups.flatMap(group => {
168
+ return groups.flatMap((group) => {
118
169
  if (typeof group === 'string') return [group.trim()];
119
- return group.hooks.map(hook => _extractCommandFileName(hook.command));
170
+ return group.hooks.map((hook) => _extractCommandFileName(hook.command));
120
171
  });
121
172
  }
122
173
 
123
174
  for (const [event, groups] of Object.entries(_hookConfig.hooks)) {
124
- const valid = Array.isArray(groups) &&
175
+ const valid =
176
+ Array.isArray(groups) &&
125
177
  groups.length > 0 &&
126
- groups.every(group => _isHookFileName(group) || _isHookGroup(group)) &&
178
+ groups.every((group) => _isHookFileName(group) || _isHookGroup(group)) &&
127
179
  _extractFileNames(groups).length > 0;
128
180
  if (!valid) {
129
- console.error(`Error: hooks/hooks.json "hooks.${event}" must be a non-empty array of .mjs file names or Claude hook groups`);
181
+ console.error(
182
+ `Error: hooks/hooks.json "hooks.${event}" must be a non-empty array of .mjs file names or Claude hook groups`,
183
+ );
184
+ console.error(PKG_INTEGRITY_HINT);
130
185
  process.exit(1);
131
186
  }
132
187
  }
133
- if (_hookConfig.shared !== undefined && (!Array.isArray(_hookConfig.shared) || !_hookConfig.shared.every(f => _isHookFileName(f)))) {
188
+ if (
189
+ _hookConfig.shared !== undefined &&
190
+ (!Array.isArray(_hookConfig.shared) || !_hookConfig.shared.every((f) => _isHookFileName(f)))
191
+ ) {
134
192
  console.error('Error: hooks/hooks.json "shared" must be an array of .mjs file names');
193
+ console.error(PKG_INTEGRITY_HINT);
135
194
  process.exit(1);
136
195
  }
137
196
 
138
- const HOOK_MAP = Object.fromEntries(Object.entries(_hookConfig.hooks).map(([e, gs]) => [e, _extractFileNames(gs)]));
197
+ const HOOK_MAP = Object.fromEntries(
198
+ Object.entries(_hookConfig.hooks).map(([e, gs]) => [e, _extractFileNames(gs)]),
199
+ );
139
200
  const SHARED_FILES = _hookConfig.shared ?? [];
140
201
 
141
202
  // ── checks ───────────────────────────────────────────────────────────────────
142
203
 
143
204
  function checkSchemaVersion(hypoDir) {
144
- const pkgPath = join(TEMPLATES, 'SCHEMA.md');
205
+ const pkgPath = join(TEMPLATES, 'SCHEMA.md');
145
206
  const hypoPath = join(hypoDir, 'SCHEMA.md');
146
207
 
147
- const pkgVersion = existsSync(pkgPath)
208
+ const pkgVersion = existsSync(pkgPath)
148
209
  ? parseVersion((parseFrontmatter(readFileSync(pkgPath, 'utf-8')) ?? {}).version)
149
210
  : null;
150
211
  const hypoVersion = existsSync(hypoPath)
@@ -153,21 +214,23 @@ function checkSchemaVersion(hypoDir) {
153
214
 
154
215
  return {
155
216
  installed: hypoVersion?.raw ?? null,
156
- current: pkgVersion?.raw ?? null,
157
- bump: bumpType(hypoVersion, pkgVersion),
217
+ current: pkgVersion?.raw ?? null,
218
+ bump: bumpType(hypoVersion, pkgVersion),
158
219
  hypoPath,
159
220
  pkgPath,
160
221
  };
161
222
  }
162
223
 
163
- function checkHookFiles() {
164
- const claudeHooks = join(HOME, '.claude', 'hooks');
224
+ // Target-aware: the same core-hook integrity check runs against ~/.claude/hooks/
225
+ // for the default claude target, and against ~/.codex/hooks/ when --codex is set
226
+ // (ADR 0024). The function reads only — apply happens in applyHookFiles.
227
+ function checkHookFiles(hooksDir) {
165
228
  const results = [];
166
229
 
167
230
  const allFiles = [...Object.values(HOOK_MAP).flat(), ...SHARED_FILES];
168
231
  for (const file of allFiles) {
169
- const installedPath = join(claudeHooks, file);
170
- const srcPath = join(HOOKS_SRC, file);
232
+ const installedPath = join(hooksDir, file);
233
+ const srcPath = join(HOOKS_SRC, file);
171
234
 
172
235
  if (!existsSync(installedPath)) {
173
236
  results.push({ file, status: 'missing', installedPath, srcPath });
@@ -175,7 +238,7 @@ function checkHookFiles() {
175
238
  results.push({ file, status: 'src-missing', installedPath, srcPath });
176
239
  } else {
177
240
  const installedContent = readFileSync(installedPath, 'utf-8');
178
- const srcContent = readFileSync(srcPath, 'utf-8');
241
+ const srcContent = readFileSync(srcPath, 'utf-8');
179
242
  results.push({
180
243
  file,
181
244
  status: installedContent === srcContent ? 'up-to-date' : 'stale',
@@ -188,24 +251,27 @@ function checkHookFiles() {
188
251
  return results;
189
252
  }
190
253
 
191
- function checkSettingsJson() {
192
- const settingsPath = join(HOME, '.claude', 'settings.json');
193
- const hooksDir = join(HOME, '.claude', 'hooks');
194
- const results = [];
254
+ // Same target-aware shape as checkHookFiles — the registered command string is
255
+ // reconstructed from `hooksDir`, so a codex check verifies that ~/.codex/settings.json
256
+ // references ~/.codex/hooks/<file>.mjs (not the claude path).
257
+ function checkSettingsJson(settingsPath, hooksDir) {
258
+ const results = [];
195
259
 
196
260
  let settings = {};
197
261
  if (existsSync(settingsPath)) {
198
- try { settings = JSON.parse(readFileSync(settingsPath, 'utf-8')); } catch {
262
+ try {
263
+ settings = JSON.parse(readFileSync(settingsPath, 'utf-8'));
264
+ } catch {
199
265
  return [{ event: '*', file: '*', status: 'invalid-json', cmd: '' }];
200
266
  }
201
267
  }
202
268
 
203
269
  for (const [event, files] of Object.entries(HOOK_MAP)) {
204
270
  for (const file of files) {
205
- const cmd = `node ${hooksDir.replace(HOME, '$HOME')}/${file}`;
271
+ const cmd = `node ${hooksDir.replace(HOME, '$HOME')}/${file}`;
206
272
  const found = (Array.isArray(settings.hooks?.[event]) ? settings.hooks[event] : [])
207
- .flatMap(g => g.hooks || [])
208
- .some(h => h.command === cmd);
273
+ .flatMap((g) => g.hooks || [])
274
+ .some((h) => h.command === cmd);
209
275
  results.push({ event, file, status: found ? 'registered' : 'missing', cmd });
210
276
  }
211
277
  }
@@ -215,9 +281,11 @@ function checkSettingsJson() {
215
281
 
216
282
  // ── apply actions ────────────────────────────────────────────────────────────
217
283
 
218
- function applyHookFiles(hookResults) {
219
- const claudeHooks = join(HOME, '.claude', 'hooks');
220
- mkdirSync(claudeHooks, { recursive: true });
284
+ // `hooksDir` is the target the stale/missing paths in `hookResults` already point
285
+ // at (the check above embeds the absolute installedPath). The directory is created
286
+ // up front so first-time codex installs (~/.codex/hooks/ absent) succeed.
287
+ function applyHookFiles(hookResults, hooksDir) {
288
+ mkdirSync(hooksDir, { recursive: true });
221
289
 
222
290
  const applied = [];
223
291
  for (const h of hookResults) {
@@ -229,11 +297,14 @@ function applyHookFiles(hookResults) {
229
297
  return applied;
230
298
  }
231
299
 
232
- function applySettingsJson(settingsResults) {
233
- const settingsPath = join(HOME, '.claude', 'settings.json');
300
+ function applySettingsJson(settingsResults, settingsPath) {
234
301
  let settings = {};
235
302
  if (existsSync(settingsPath)) {
236
- try { settings = JSON.parse(readFileSync(settingsPath, 'utf-8')); } catch { return []; }
303
+ try {
304
+ settings = JSON.parse(readFileSync(settingsPath, 'utf-8'));
305
+ } catch {
306
+ return [];
307
+ }
237
308
  }
238
309
  if (!settings.hooks) settings.hooks = {};
239
310
 
@@ -241,6 +312,17 @@ function applySettingsJson(settingsResults) {
241
312
  for (const s of settingsResults) {
242
313
  if (s.status !== 'missing') continue;
243
314
  if (!Array.isArray(settings.hooks[s.event])) settings.hooks[s.event] = [];
315
+ // fix #48 BLOCKER: re-check the current parsed settings before appending.
316
+ // applyHookNameMigration may have rewritten a legacy wiki-*.mjs command to
317
+ // exactly `s.cmd` between checkSettingsJson and now — appending without
318
+ // this guard creates a duplicate registration (codex 2-worker review
319
+ // reproduced 11 duplicate hypo-*.mjs entries on a wiki-only legacy settings
320
+ // file). The precheck list is allowed to be stale; the apply path must
321
+ // self-heal against the on-disk truth.
322
+ const alreadyPresent = settings.hooks[s.event]
323
+ .flatMap((g) => g.hooks || [])
324
+ .some((h) => h.command === s.cmd);
325
+ if (alreadyPresent) continue;
244
326
  settings.hooks[s.event].push({ hooks: [{ type: 'command', command: s.cmd }] });
245
327
  applied.push(`${s.event}: ${s.file}`);
246
328
  }
@@ -254,29 +336,34 @@ function applySettingsJson(settingsResults) {
254
336
 
255
337
  // Rename map: old wiki-*.mjs → new hypo-*.mjs
256
338
  const HOOK_RENAMES = {
257
- 'wiki-session-start.mjs': 'hypo-session-start.mjs',
258
- 'wiki-first-prompt.mjs': 'hypo-first-prompt.mjs',
259
- 'wiki-lookup.mjs': 'hypo-lookup.mjs',
260
- 'wiki-compact-guard.mjs': 'hypo-compact-guard.mjs',
261
- 'wiki-auto-stage.mjs': 'hypo-auto-stage.mjs',
262
- 'wiki-hot-rebuild.mjs': 'hypo-hot-rebuild.mjs',
263
- 'wiki-auto-commit.mjs': 'hypo-auto-commit.mjs',
264
- 'wiki-cwd-change.mjs': 'hypo-cwd-change.mjs',
265
- 'wiki-file-watch.mjs': 'hypo-file-watch.mjs',
266
- 'wiki-shared.mjs': 'hypo-shared.mjs',
339
+ 'wiki-session-start.mjs': 'hypo-session-start.mjs',
340
+ 'wiki-first-prompt.mjs': 'hypo-first-prompt.mjs',
341
+ 'wiki-lookup.mjs': 'hypo-lookup.mjs',
342
+ 'wiki-compact-guard.mjs': 'hypo-compact-guard.mjs',
343
+ 'wiki-auto-stage.mjs': 'hypo-auto-stage.mjs',
344
+ 'wiki-hot-rebuild.mjs': 'hypo-hot-rebuild.mjs',
345
+ 'wiki-auto-commit.mjs': 'hypo-auto-commit.mjs',
346
+ 'wiki-cwd-change.mjs': 'hypo-cwd-change.mjs',
347
+ 'wiki-file-watch.mjs': 'hypo-file-watch.mjs',
348
+ 'wiki-shared.mjs': 'hypo-shared.mjs',
267
349
  'personal-wiki-check.mjs': 'hypo-personal-check.mjs',
268
350
  };
269
351
 
270
- function checkOldHookNames() {
271
- const settingsPath = join(HOME, '.claude', 'settings.json');
352
+ // Same target-aware shape: scans either ~/.claude/settings.json or ~/.codex/settings.json
353
+ // for v1.0/v1.1 `wiki-*.mjs` references that need renaming to `hypo-*.mjs`.
354
+ function checkOldHookNames(settingsPath) {
272
355
  if (!existsSync(settingsPath)) return [];
273
356
  let settings;
274
- try { settings = JSON.parse(readFileSync(settingsPath, 'utf-8')); } catch { return []; }
357
+ try {
358
+ settings = JSON.parse(readFileSync(settingsPath, 'utf-8'));
359
+ } catch {
360
+ return [];
361
+ }
275
362
 
276
363
  const found = [];
277
364
  for (const [event, groups] of Object.entries(settings.hooks || {})) {
278
- for (const group of (Array.isArray(groups) ? groups : [])) {
279
- for (const hook of (group.hooks || [])) {
365
+ for (const group of Array.isArray(groups) ? groups : []) {
366
+ for (const hook of group.hooks || []) {
280
367
  const cmd = hook.command || '';
281
368
  for (const oldName of Object.keys(HOOK_RENAMES)) {
282
369
  if (cmd.includes(oldName)) found.push({ event, oldName, cmd });
@@ -287,18 +374,20 @@ function checkOldHookNames() {
287
374
  return found;
288
375
  }
289
376
 
290
- function applyHookNameMigration(oldRefs) {
291
- const settingsPath = join(HOME, '.claude', 'settings.json');
292
- const hooksDir = join(HOME, '.claude', 'hooks');
377
+ function applyHookNameMigration(oldRefs, settingsPath, hooksDir) {
293
378
  if (!existsSync(settingsPath)) return [];
294
379
 
295
380
  let settings;
296
- try { settings = JSON.parse(readFileSync(settingsPath, 'utf-8')); } catch { return []; }
381
+ try {
382
+ settings = JSON.parse(readFileSync(settingsPath, 'utf-8'));
383
+ } catch {
384
+ return [];
385
+ }
297
386
 
298
387
  const applied = [];
299
388
  for (const [event, groups] of Object.entries(settings.hooks || {})) {
300
- for (const group of (Array.isArray(groups) ? groups : [])) {
301
- for (const hook of (group.hooks || [])) {
389
+ for (const group of Array.isArray(groups) ? groups : []) {
390
+ for (const hook of group.hooks || []) {
302
391
  for (const [oldName, newName] of Object.entries(HOOK_RENAMES)) {
303
392
  if ((hook.command || '').includes(oldName)) {
304
393
  hook.command = hook.command.replace(oldName, newName);
@@ -311,7 +400,8 @@ function applyHookNameMigration(oldRefs) {
311
400
 
312
401
  if (applied.length > 0) {
313
402
  writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
314
- // Copy renamed hook files to ~/.claude/hooks/
403
+ // Copy renamed hook files to the target hooks dir (~/.claude/hooks or
404
+ // ~/.codex/hooks per the caller — fix #48 mirror).
315
405
  for (const [oldName, newName] of Object.entries(HOOK_RENAMES)) {
316
406
  const oldPath = join(hooksDir, oldName);
317
407
  const newPath = join(hooksDir, newName);
@@ -324,37 +414,152 @@ function applyHookNameMigration(oldRefs) {
324
414
  return applied;
325
415
  }
326
416
 
417
+ // ── .hypoignore migration — ensure required runtime patterns are present ─────
418
+ //
419
+ // Idempotent: only appends entries that are absent. Re-running --apply on an
420
+ // already-migrated .hypoignore is a no-op.
421
+
422
+ const HYPOIGNORE_REQUIRED_ENTRIES = [
423
+ {
424
+ pattern: '.cache/',
425
+ comment: '# Hypomnema runtime cache (session growth metrics, future index.jsonl, etc.)',
426
+ },
427
+ ];
428
+
429
+ function checkHypoignore(hypoDir) {
430
+ const path = join(hypoDir, '.hypoignore');
431
+ if (!existsSync(path)) return { status: 'no-file', missing: [], path };
432
+ const content = readFileSync(path, 'utf-8');
433
+ const entries = new Set(
434
+ content
435
+ .split('\n')
436
+ .map((l) => l.trim())
437
+ .filter((l) => l && !l.startsWith('#')),
438
+ );
439
+ const missing = HYPOIGNORE_REQUIRED_ENTRIES.filter((e) => !entries.has(e.pattern));
440
+ return { status: missing.length === 0 ? 'up-to-date' : 'needs-migration', missing, path };
441
+ }
442
+
443
+ function applyHypoignoreMigration(result) {
444
+ if (result.status !== 'needs-migration') return [];
445
+ let content = readFileSync(result.path, 'utf-8');
446
+ if (!content.endsWith('\n')) content += '\n';
447
+ const appended = [];
448
+ for (const entry of result.missing) {
449
+ content += `\n${entry.comment}\n${entry.pattern}\n`;
450
+ appended.push(entry.pattern);
451
+ }
452
+ writeFileSync(result.path, content);
453
+ return appended;
454
+ }
455
+
327
456
  function writeMigrationReport(hypoDir, fromVersion, toVersion) {
328
- const today = new Date().toISOString().slice(0, 10);
457
+ const today = new Date().toISOString().slice(0, 10);
329
458
  const filename = `MIGRATION-v${toVersion}.md`;
330
- const dest = join(hypoDir, filename);
459
+ const dest = join(hypoDir, filename);
331
460
 
332
461
  // Don't overwrite an existing report
333
462
  if (existsSync(dest)) return dest;
334
463
 
464
+ // v1 → v2 specific guidance for the ADR 0031 feedback classification bump.
465
+ // Other major jumps fall back to the generic body. ADR 0034 reserves the
466
+ // right to keep SCHEMA.md as user-owned (Option C); auto-stub of the 9 new
467
+ // fields is rejected because scope/tier/targets/sensitivity/reason/source
468
+ // are semantic decisions whose wrong defaults would project wrong behavior.
469
+ const isV1ToV2 = fromVersion === '1.0' && toVersion === '2.0';
470
+ const specificBody = isV1ToV2
471
+ ? `## What changed in SCHEMA 2.0
472
+
473
+ ADR 0031 (\`projects/hypomnema/decisions/0031-feedback-as-sot-external-memory-projection.md\`)
474
+ made \`feedback\` pages the single source of truth for behavior corrections and added
475
+ **9 hard-required frontmatter fields** for every \`feedback\` page:
476
+
477
+ - \`status\` (active | superseded | archived)
478
+ - \`scope\` (global | project:<project-id>)
479
+ - \`tier\` (L1 | L2)
480
+ - \`targets\` (project-memory and/or claude-learned)
481
+ - \`sensitivity\` (public | sanitized) — \`private\` is forbidden
482
+ - \`priority\` (1–5)
483
+ - \`memory_summary\` (one line for MEMORY.md index)
484
+ - \`reason\` (why the rule exists)
485
+ - \`source\` (session:YYYY-MM-DD | commit:<hash> | pr:<n> | URL)
486
+
487
+ **Conditional (when \`targets\` includes \`claude-learned\`):** \`global_summary\`
488
+ and \`promote_to_global\` become required (set \`promote_to_global: true\`), and
489
+ the page MUST also be \`scope: global\` + \`tier: L1\`. A claude-learned page
490
+ that does not satisfy all four is rejected by both \`hypomnema lint\` and
491
+ \`hypomnema feedback\` (see \`scripts/lint.mjs\` and \`scripts/feedback.mjs\`
492
+ enforcement).
493
+
494
+ ADR 0034 records this schema bump and the reasoning (semver-major because
495
+ existing feedback pages without these fields now fail \`hypomnema lint\`).
496
+
497
+ ## Action items — existing wiki
498
+
499
+ Run \`hypomnema lint\` first to see exactly which feedback pages are missing
500
+ fields. Then **manually** backfill each \`pages/feedback/*.md\` — the upgrade
501
+ deliberately does NOT auto-stub the 9 fields because wrong defaults for
502
+ \`scope\` / \`tier\` / \`targets\` / \`sensitivity\` / \`reason\` / \`source\` would
503
+ silently project wrong behavior into MEMORY.md or CLAUDE.md.
504
+
505
+ \`SCHEMA.md\` is intentionally **not** overwritten by upgrade (Option C); the
506
+ checklist below is manual:
507
+
508
+ - [ ] Run \`hypomnema lint\` and read the per-file errors for type \`feedback\`
509
+ - [ ] Backfill the 9 fields in each \`pages/feedback/*.md\` (use an existing
510
+ valid page as a template; see \`templates/pages/_index.md\` if present)
511
+ - [ ] **Fix incomplete feedback pages BEFORE running \`/hypo:feedback\` append**
512
+ — append mode preserves existing frontmatter, so a partial page stays
513
+ partial
514
+ - [ ] Compare your \`SCHEMA.md\` (v${fromVersion}) with the package template
515
+ (v${toVersion}) and merge changes manually — only the \`version:\` line
516
+ and the \`feedback\` type section under §3 are load-bearing for the
517
+ hard-required fields above
518
+ - [ ] Run \`hypomnema feedback-sync --check\` to verify the MEMORY/CLAUDE
519
+ projection still resolves cleanly
520
+ - [ ] Run \`hypomnema doctor\` to verify installation health
521
+ - [ ] **Re-run \`hypomnema lint\` after backfilling — confirm 0 feedback errors
522
+ remain (including the conditional \`claude-learned\` fields above)**
523
+
524
+ ## Caveat — \`scope: project:<project-id>\` and slug regex
525
+
526
+ The lint regex \`^project:[a-z0-9][a-z0-9-]*$\` accepts only short slugs, but
527
+ \`feedback-sync\`'s default resolved project-id is cwd-derived (e.g.
528
+ \`-Users-you-Workspace-Project\`), which the regex rejects. To use a
529
+ \`scope: project:*\` page in v1.2.0 you must override with
530
+ \`--project-id=<slug>\` so the resolved id is slug-safe. The full resolved-id
531
+ ↔ wiki-slug reconciliation is deferred to v1.3.0; \`commands/feedback.md\`
532
+ documents the interim pattern.
533
+ `
534
+ : `## What changed
535
+
536
+ This is a **major version bump** (v${fromVersion} → v${toVersion}).
537
+ Review the SCHEMA diff and update your wiki pages accordingly.
538
+
539
+ ## Action items
540
+
541
+ This report was generated during \`/hypo:upgrade --apply\`. Hook files and settings.json
542
+ entries were applied by that run (or skipped with a warning if the target was malformed —
543
+ see the upgrade output). \`SCHEMA.md\` is intentionally **not** overwritten by upgrade — the
544
+ remaining steps are manual:
545
+
546
+ - [ ] Compare your \`SCHEMA.md\` (v${fromVersion}) with the package template (v${toVersion}) and merge changes manually
547
+ - [ ] Re-check all \`adr\` and \`learning\` pages for new required frontmatter fields once SCHEMA is updated
548
+ - [ ] Run \`/hypo:doctor\` to verify installation health
549
+ `;
550
+
335
551
  const content = `---
336
552
  title: Migration Report — v${fromVersion} → v${toVersion}
337
553
  type: reference
338
554
  updated: ${today}
339
- tags: [hypomnema, migration, schema]
555
+ tags: [schema]
340
556
  ---
341
557
 
342
558
  # Migration Report: v${fromVersion} → v${toVersion}
343
559
 
344
560
  Generated by \`/hypo:upgrade\` on ${today}.
345
561
 
346
- ## What changed
347
-
348
- This is a **major version bump** (v${fromVersion} → v${toVersion}).
349
- Review the SCHEMA diff and update your wiki pages accordingly.
350
-
351
- ## Action items
352
-
353
- - [ ] Compare your \`SCHEMA.md\` (v${fromVersion}) with the package template (v${toVersion}) and update manually
354
- - [ ] Run \`/hypo:upgrade --apply\` to install updated hook files and settings.json entries
355
- - [ ] Check all \`adr\` and \`learning\` pages for new required frontmatter fields
356
- - [ ] Run \`/hypo:doctor\` after applying updates to verify installation health
357
-
562
+ ${specificBody}
358
563
  ## Notes
359
564
 
360
565
  Add migration-specific notes here after reviewing the SCHEMA diff.
@@ -381,7 +586,7 @@ function checkPkgJson() {
381
586
 
382
587
  function readPkgRecordedCommands() {
383
588
  const pkg = readPkgJsonSafe(pkgJsonPath());
384
- return (pkg && pkg.commands && typeof pkg.commands === 'object') ? pkg.commands : {};
589
+ return pkg && pkg.commands && typeof pkg.commands === 'object' ? pkg.commands : {};
385
590
  }
386
591
 
387
592
  function checkCommands() {
@@ -396,8 +601,8 @@ function checkCommands() {
396
601
  if (!file.endsWith('.md')) continue;
397
602
  packagedFiles.add(file);
398
603
  const srcPath = join(COMMANDS_SRC, file);
399
- const dest = join(targetDir, file);
400
- const srcSHA = sha256(readFileSync(srcPath));
604
+ const dest = join(targetDir, file);
605
+ const srcSHA = sha256(readFileSync(srcPath));
401
606
 
402
607
  if (!existsSync(dest)) {
403
608
  results.push({ file, status: 'missing', srcPath, dest, srcSHA });
@@ -437,8 +642,14 @@ function checkCommands() {
437
642
  function writeFreshAtomic(dest, content) {
438
643
  const tmp = `${dest}.tmp.${process.pid}.${Date.now()}`;
439
644
  writeFileSync(tmp, content);
440
- try { renameSync(tmp, dest); }
441
- catch (err) { try { unlinkSync(tmp); } catch {} throw err; }
645
+ try {
646
+ renameSync(tmp, dest);
647
+ } catch (err) {
648
+ try {
649
+ unlinkSync(tmp);
650
+ } catch {}
651
+ throw err;
652
+ }
442
653
  }
443
654
 
444
655
  function applyCommands(commandResults, force) {
@@ -519,13 +730,15 @@ function applyCommands(commandResults, force) {
519
730
  const path = pkgJsonPath();
520
731
  const existing = readPkgJsonSafe(path);
521
732
  let pkgVersion = null;
522
- try { pkgVersion = JSON.parse(readFileSync(join(PKG_ROOT, 'package.json'), 'utf-8')).version; } catch {}
733
+ try {
734
+ pkgVersion = JSON.parse(readFileSync(join(PKG_ROOT, 'package.json'), 'utf-8')).version;
735
+ } catch {}
523
736
  writePkgJsonAtomic(path, {
524
737
  ...existing,
525
- pkgRoot: PKG_ROOT,
738
+ pkgRoot: PKG_ROOT,
526
739
  pkgVersion,
527
- schemaVersion: '1.0',
528
- commands: newSHAs,
740
+ schemaVersion: '2.0',
741
+ commands: newSHAs,
529
742
  });
530
743
  return applied;
531
744
  }
@@ -534,60 +747,224 @@ function applyCommands(commandResults, force) {
534
747
 
535
748
  const args = parseArgs(process.argv);
536
749
 
537
- const schema = checkSchemaVersion(args.hypoDir);
538
- const hooks = checkHookFiles();
539
- const settings = checkSettingsJson();
540
- const pkgJson = checkPkgJson();
750
+ // Target paths. Claude is always checked; codex is checked only when --codex is set
751
+ // (ADR 0024) so users without codex installed see no false drift.
752
+ const claudeHooksDir = join(HOME, '.claude', 'hooks');
753
+ const claudeSettingsPath = join(HOME, '.claude', 'settings.json');
754
+ const codexHooksDir = join(HOME, '.codex', 'hooks');
755
+ const codexSettingsPath = join(HOME, '.codex', 'settings.json');
756
+
757
+ const schema = checkSchemaVersion(args.hypoDir);
758
+ const hooks = checkHookFiles(claudeHooksDir);
759
+ const settings = checkSettingsJson(claudeSettingsPath, claudeHooksDir);
760
+ const pkgJson = checkPkgJson();
541
761
  const commands = checkCommands();
542
- const oldHookRefs = checkOldHookNames();
543
-
544
- const staleHooks = hooks.filter(h => h.status === 'stale' || h.status === 'missing' || h.status === 'src-missing');
545
- const missingSettings = settings.filter(s => s.status === 'missing');
546
- const invalidSettings = settings.some(s => s.status === 'invalid-json');
547
- const schemaDrift = schema.bump !== 'none' && schema.bump !== 'unknown' && schema.bump !== 'ahead';
548
- const pkgJsonDrift = pkgJson.status !== 'up-to-date';
549
- const staleCommands = commands.filter(c => c.status === 'stale' || c.status === 'missing');
550
- const userModifiedCommands = commands.filter(c => c.status === 'user-modified');
551
- const orphanedCommands = commands.filter(c => c.status === 'orphaned');
552
- const nonRegularCommands = commands.filter(c => c.status === 'non-regular' || c.status === 'unreadable');
553
-
554
- let migrationPath = null;
555
- let appliedHooks = [];
556
- let appliedSettings = [];
557
- let appliedPkgJson = false;
762
+ const oldHookRefs = checkOldHookNames(claudeSettingsPath);
763
+ const hypoignore = checkHypoignore(args.hypoDir);
764
+
765
+ // fix #48: when --codex is set, mirror the same core-hook checks against ~/.codex/
766
+ // so `hypomnema upgrade --codex` reports drift symmetrically and `--apply --codex`
767
+ // updates both targets in one pass (matching init.mjs behaviour).
768
+ const hooksCodex = args.codex ? checkHookFiles(codexHooksDir) : null;
769
+ const settingsCodex = args.codex ? checkSettingsJson(codexSettingsPath, codexHooksDir) : null;
770
+ const oldHookRefsCodex = args.codex ? checkOldHookNames(codexSettingsPath) : null;
771
+
772
+ // Extensions companion (ADR 0024). Read-only check; the apply
773
+ // happens below, AFTER applyCommands, so the per-target SHA map merges into the
774
+ // hypo-pkg.json that applyCommands writes (rather than being clobbered by it).
775
+ const extSettingsPath = claudeSettingsPath;
776
+ const extDir = join(args.hypoDir, 'extensions');
777
+ const extCheck = syncExtensions({
778
+ extDir,
779
+ hypoDir: args.hypoDir,
780
+ target: 'claude',
781
+ settingsPath: extSettingsPath,
782
+ pkgPath: pkgJsonPath(),
783
+ apply: false,
784
+ force: args.forceExtensions,
785
+ });
786
+
787
+ // E4 (#32): --codex mirrors the extensions sync into ~/.codex (hooks + commands
788
+ // only; skills/agents skipped with a notice). The per-target SHA map lives in the
789
+ // same ~/.claude/hypo-pkg.json under extensions.codex, so pkgPath is unchanged.
790
+ const extCodexSettingsPath = codexSettingsPath;
791
+ const extCheckCodex = args.codex
792
+ ? syncExtensions({
793
+ extDir,
794
+ hypoDir: args.hypoDir,
795
+ target: 'codex',
796
+ settingsPath: extCodexSettingsPath,
797
+ pkgPath: pkgJsonPath(),
798
+ apply: false,
799
+ force: args.forceExtensions,
800
+ })
801
+ : null;
802
+
803
+ const staleHooks = hooks.filter(
804
+ (h) => h.status === 'stale' || h.status === 'missing' || h.status === 'src-missing',
805
+ );
806
+ const missingSettings = settings.filter((s) => s.status === 'missing');
807
+ const invalidSettings = settings.some((s) => s.status === 'invalid-json');
808
+ const staleHooksCodex = hooksCodex
809
+ ? hooksCodex.filter(
810
+ (h) => h.status === 'stale' || h.status === 'missing' || h.status === 'src-missing',
811
+ )
812
+ : [];
813
+ const missingSettingsCodex = settingsCodex
814
+ ? settingsCodex.filter((s) => s.status === 'missing')
815
+ : [];
816
+ const invalidSettingsCodex = settingsCodex
817
+ ? settingsCodex.some((s) => s.status === 'invalid-json')
818
+ : false;
819
+ const schemaDrift = schema.bump !== 'none' && schema.bump !== 'unknown' && schema.bump !== 'ahead';
820
+ const pkgJsonDrift = pkgJson.status !== 'up-to-date';
821
+ const staleCommands = commands.filter((c) => c.status === 'stale' || c.status === 'missing');
822
+ const userModifiedCommands = commands.filter((c) => c.status === 'user-modified');
823
+ const orphanedCommands = commands.filter((c) => c.status === 'orphaned');
824
+ const nonRegularCommands = commands.filter(
825
+ (c) => c.status === 'non-regular' || c.status === 'unreadable',
826
+ );
827
+
828
+ let migrationPath = null;
829
+ let appliedHooks = [];
830
+ let appliedSettings = [];
831
+ let appliedPkgJson = false;
558
832
  let appliedHookNameRenames = [];
559
- let appliedCommands = [];
833
+ let appliedHooksCodex = [];
834
+ let appliedSettingsCodex = [];
835
+ let appliedHookNameRenamesCodex = [];
836
+ let appliedCommands = [];
837
+ let appliedHypoignore = [];
838
+ let appliedExtensions = null;
839
+ let appliedExtensionsCodex = null;
560
840
 
561
841
  if (args.apply) {
562
842
  if (oldHookRefs.length > 0) {
563
- appliedHookNameRenames = applyHookNameMigration(oldHookRefs);
843
+ appliedHookNameRenames = applyHookNameMigration(
844
+ oldHookRefs,
845
+ claudeSettingsPath,
846
+ claudeHooksDir,
847
+ );
564
848
  }
565
849
  if (schema.bump === 'major' && schema.installed && schema.current && existsSync(args.hypoDir)) {
566
850
  migrationPath = writeMigrationReport(args.hypoDir, schema.installed, schema.current);
567
851
  }
568
- appliedHooks = applyHookFiles(hooks);
569
- appliedSettings = applySettingsJson(settings);
852
+ appliedHooks = applyHookFiles(hooks, claudeHooksDir);
853
+ appliedSettings = applySettingsJson(settings, claudeSettingsPath);
570
854
  // applyCommands handles the single atomic hypo-pkg.json write (pkgRoot, version, schema, commands map)
571
855
  appliedCommands = applyCommands(commands, args.forceCommands);
572
856
  appliedPkgJson = true;
857
+ appliedHypoignore = applyHypoignoreMigration(hypoignore);
858
+ // fix #48: codex core hooks + settings + wiki-*→hypo-* rename mirror. Same order
859
+ // as the claude side (rename first so subsequent hook copy can find renamed targets).
860
+ if (args.codex) {
861
+ if (oldHookRefsCodex.length > 0) {
862
+ appliedHookNameRenamesCodex = applyHookNameMigration(
863
+ oldHookRefsCodex,
864
+ codexSettingsPath,
865
+ codexHooksDir,
866
+ );
867
+ }
868
+ appliedHooksCodex = applyHookFiles(hooksCodex, codexHooksDir);
869
+ appliedSettingsCodex = applySettingsJson(settingsCodex, codexSettingsPath);
870
+ }
871
+ // After applyCommands wrote hypo-pkg.json — merges extensions.<target> alongside.
872
+ appliedExtensions = syncExtensions({
873
+ extDir,
874
+ hypoDir: args.hypoDir,
875
+ target: 'claude',
876
+ settingsPath: extSettingsPath,
877
+ pkgPath: pkgJsonPath(),
878
+ apply: true,
879
+ force: args.forceExtensions,
880
+ });
881
+ // E4 (#32): codex apply runs AFTER the claude apply so it reads the freshly
882
+ // written hypo-pkg.json and merges extensions.codex alongside extensions.claude
883
+ // (the per-target spread in syncExtensions preserves the other target's map).
884
+ if (args.codex) {
885
+ appliedExtensionsCodex = syncExtensions({
886
+ extDir,
887
+ hypoDir: args.hypoDir,
888
+ target: 'codex',
889
+ settingsPath: extCodexSettingsPath,
890
+ pkgPath: pkgJsonPath(),
891
+ apply: true,
892
+ force: args.forceExtensions,
893
+ });
894
+ }
573
895
  }
574
896
 
575
897
  // ── output ───────────────────────────────────────────────────────────────────
576
898
 
577
- const hasDrift = staleHooks.length > 0 || missingSettings.length > 0 || schemaDrift || invalidSettings || pkgJsonDrift || oldHookRefs.length > 0 || staleCommands.length > 0 || userModifiedCommands.length > 0 || orphanedCommands.length > 0 || nonRegularCommands.length > 0;
899
+ const extDrift = extCheck.needsWork || (extCheckCodex?.needsWork ?? false);
900
+
901
+ // fix #48: codex drift only counts when --codex is set — without the flag the codex
902
+ // target is intentionally unobserved (parity with the existing extensions pattern).
903
+ const codexCoreDrift =
904
+ args.codex &&
905
+ (staleHooksCodex.length > 0 ||
906
+ missingSettingsCodex.length > 0 ||
907
+ invalidSettingsCodex ||
908
+ (oldHookRefsCodex?.length ?? 0) > 0);
909
+
910
+ const hasDrift =
911
+ staleHooks.length > 0 ||
912
+ missingSettings.length > 0 ||
913
+ schemaDrift ||
914
+ invalidSettings ||
915
+ pkgJsonDrift ||
916
+ oldHookRefs.length > 0 ||
917
+ staleCommands.length > 0 ||
918
+ userModifiedCommands.length > 0 ||
919
+ orphanedCommands.length > 0 ||
920
+ nonRegularCommands.length > 0 ||
921
+ hypoignore.status === 'needs-migration' ||
922
+ extDrift ||
923
+ codexCoreDrift;
578
924
 
579
925
  if (args.json) {
580
- console.log(JSON.stringify({
581
- schema,
582
- hooks,
583
- settings,
584
- pkgJson,
585
- commands,
586
- oldHookRefs,
587
- applied: { hooks: appliedHooks, settings: appliedSettings, pkgJson: appliedPkgJson, hookNameRenames: appliedHookNameRenames, commands: appliedCommands },
588
- migrationReport: migrationPath,
589
- }, null, 2));
590
- process.exit(hasDrift && !args.apply ? 1 : 0);
926
+ console.log(
927
+ JSON.stringify(
928
+ {
929
+ schema,
930
+ hooks,
931
+ settings,
932
+ pkgJson,
933
+ commands,
934
+ oldHookRefs,
935
+ hypoignore,
936
+ extensions: extCheck,
937
+ extensionsCodex: extCheckCodex,
938
+ // fix #48: codex core mirror (null when --codex absent).
939
+ hooksCodex,
940
+ settingsCodex,
941
+ oldHookRefsCodex,
942
+ applied: {
943
+ hooks: appliedHooks,
944
+ settings: appliedSettings,
945
+ pkgJson: appliedPkgJson,
946
+ hookNameRenames: appliedHookNameRenames,
947
+ commands: appliedCommands,
948
+ hypoignore: appliedHypoignore,
949
+ extensions: appliedExtensions,
950
+ extensionsCodex: appliedExtensionsCodex,
951
+ hooksCodex: appliedHooksCodex,
952
+ settingsCodex: appliedSettingsCodex,
953
+ hookNameRenamesCodex: appliedHookNameRenamesCodex,
954
+ },
955
+ migrationReport: migrationPath,
956
+ },
957
+ null,
958
+ 2,
959
+ ),
960
+ );
961
+ process.exit(
962
+ (hasDrift && !args.apply) ||
963
+ extCheck.conflicts.length > 0 ||
964
+ (extCheckCodex?.conflicts.length ?? 0) > 0
965
+ ? 1
966
+ : 0,
967
+ );
591
968
  }
592
969
 
593
970
  // Human-readable report
@@ -597,94 +974,189 @@ const lines = [];
597
974
  if (schema.bump === 'none') {
598
975
  lines.push(`✓ SCHEMA version ${schema.installed} (up to date)`);
599
976
  } else if (schema.bump === 'unknown') {
600
- lines.push(`⚠ SCHEMA version installed=${schema.installed ?? 'not found'}, package=${schema.current ?? 'not found'} (cannot compare)`);
977
+ lines.push(
978
+ `⚠ SCHEMA version installed=${schema.installed ?? 'not found'}, package=${schema.current ?? 'not found'} (cannot compare)`,
979
+ );
601
980
  } else if (schema.bump === 'ahead') {
602
- lines.push(`⚠ SCHEMA version ${schema.installed} (installed is ahead of package ${schema.current})`);
981
+ lines.push(
982
+ `⚠ SCHEMA version ${schema.installed} (installed is ahead of package ${schema.current})`,
983
+ );
603
984
  } else if (schema.bump === 'major') {
604
- lines.push(`✗ SCHEMA version ${schema.installed} → ${schema.current} [MAJOR — review MIGRATION report, update manually]`);
985
+ lines.push(
986
+ `✗ SCHEMA version ${schema.installed} → ${schema.current} [MAJOR — --apply writes MIGRATION report; SCHEMA must be merged manually]`,
987
+ );
605
988
  } else {
606
- lines.push(`⚠ SCHEMA version ${schema.installed} → ${schema.current} [minor update — review and update SCHEMA.md manually]`);
989
+ lines.push(
990
+ `⚠ SCHEMA version ${schema.installed} → ${schema.current} [minor update — review and update SCHEMA.md manually]`,
991
+ );
607
992
  }
608
993
 
609
- // Hook files
610
- const upToDate = hooks.filter(h => h.status === 'up-to-date').length;
611
- const staleCount = hooks.filter(h => h.status === 'stale').length;
612
- const missCount = hooks.filter(h => h.status === 'missing').length;
613
- const srcMiss = hooks.filter(h => h.status === 'src-missing').length;
614
-
615
- if (staleCount === 0 && missCount === 0 && srcMiss === 0) {
616
- lines.push(`✓ Hook files ${upToDate}/${hooks.length} up to date`);
617
- } else {
618
- lines.push(`⚠ Hook files ${upToDate} up to date, ${staleCount} stale, ${missCount} missing, ${srcMiss} src-missing:`);
619
- for (const h of hooks) {
620
- if (h.status === 'up-to-date') {
621
- lines.push(` ✓ ${h.file}`);
622
- } else if (h.status === 'stale') {
623
- lines.push(` ⚠ ${h.file} [stale package has newer version]`);
624
- } else if (h.status === 'missing') {
625
- lines.push(` ✗ ${h.file} [not found in ~/.claude/hooks/]`);
626
- } else if (h.status === 'src-missing') {
627
- lines.push(` ⚠ ${h.file} [installed but missing from package — may be orphaned]`);
994
+ // Hook files (target-aware so --codex can mirror the same block; fix #48).
995
+ function pushHookSummary(hookList, label, targetPath) {
996
+ const colHook = `Hook files${label}`.padEnd(20);
997
+ const up = hookList.filter((h) => h.status === 'up-to-date').length;
998
+ const st = hookList.filter((h) => h.status === 'stale').length;
999
+ const mi = hookList.filter((h) => h.status === 'missing').length;
1000
+ const sm = hookList.filter((h) => h.status === 'src-missing').length;
1001
+ if (st === 0 && mi === 0 && sm === 0) {
1002
+ lines.push(`✓ ${colHook}${up}/${hookList.length} up to date`);
1003
+ } else {
1004
+ lines.push(`⚠ ${colHook}${up} up to date, ${st} stale, ${mi} missing, ${sm} src-missing:`);
1005
+ for (const h of hookList) {
1006
+ if (h.status === 'up-to-date') {
1007
+ lines.push(` ${h.file}`);
1008
+ } else if (h.status === 'stale') {
1009
+ lines.push(` ⚠ ${h.file} [stale package has newer version]`);
1010
+ } else if (h.status === 'missing') {
1011
+ lines.push(` ${h.file} [not found in ${targetPath}]`);
1012
+ } else if (h.status === 'src-missing') {
1013
+ lines.push(` ⚠ ${h.file} [installed but missing from package — may be orphaned]`);
1014
+ }
628
1015
  }
629
1016
  }
630
1017
  }
631
-
632
- // settings.json
633
- const regCount = settings.filter(s => s.status === 'registered').length;
634
- const missReg = settings.filter(s => s.status === 'missing').length;
635
-
636
- if (invalidSettings) {
637
- lines.push(`✗ settings.json invalid JSON fix or back it up before re-running`);
638
- } else if (missReg === 0) {
639
- lines.push(`✓ settings.json ${regCount}/${settings.length} hook registrations present`);
640
- } else {
641
- lines.push(`⚠ settings.json ${regCount}/${settings.length} registrations present ${missReg} missing:`);
642
- for (const s of settings) {
643
- if (s.status === 'missing') lines.push(` + ${s.event}: ${s.file}`);
1018
+ pushHookSummary(hooks, '', '~/.claude/hooks/');
1019
+ if (hooksCodex) pushHookSummary(hooksCodex, ' (codex)', '~/.codex/hooks/');
1020
+
1021
+ // settings.json registrations (target-aware mirror; fix #48).
1022
+ function pushSettingsSummary(sList, label, invalidFlag) {
1023
+ const colS = `settings.json${label}`.padEnd(20);
1024
+ const reg = sList.filter((s) => s.status === 'registered').length;
1025
+ const mr = sList.filter((s) => s.status === 'missing').length;
1026
+ if (invalidFlag) {
1027
+ lines.push(`✗ ${colS}invalid JSON — fix or back it up before re-running`);
1028
+ } else if (mr === 0) {
1029
+ lines.push(`✓ ${colS}${reg}/${sList.length} hook registrations present`);
1030
+ } else {
1031
+ lines.push(`⚠ ${colS}${reg}/${sList.length} registrations present — ${mr} missing:`);
1032
+ for (const s of sList) {
1033
+ if (s.status === 'missing') lines.push(` + ${s.event}: ${s.file}`);
1034
+ }
644
1035
  }
645
1036
  }
1037
+ pushSettingsSummary(settings, '', invalidSettings);
1038
+ if (settingsCodex) pushSettingsSummary(settingsCodex, ' (codex)', invalidSettingsCodex);
646
1039
 
647
1040
  // Package metadata
648
1041
  if (pkgJson.status === 'up-to-date') {
649
1042
  lines.push(`✓ Package metadata hypo-pkg.json up to date`);
650
1043
  } else if (pkgJson.status === 'stale') {
651
- lines.push(`⚠ Package metadata hypo-pkg.json stale (${pkgJson.installed} → ${pkgJson.current}) — run --apply to update`);
1044
+ lines.push(
1045
+ `⚠ Package metadata hypo-pkg.json stale (${pkgJson.installed} → ${pkgJson.current}) — run --apply to update`,
1046
+ );
652
1047
  } else {
653
1048
  lines.push(`✗ Package metadata hypo-pkg.json missing — run --apply to install`);
654
1049
  }
655
1050
 
656
1051
  // Slash commands
657
- const cmdUpToDate = commands.filter(c => c.status === 'up-to-date').length;
658
- const cmdStaleCount = commands.filter(c => c.status === 'stale').length;
659
- const cmdMissCount = commands.filter(c => c.status === 'missing').length;
660
- const cmdUserCount = userModifiedCommands.length;
1052
+ const cmdUpToDate = commands.filter((c) => c.status === 'up-to-date').length;
1053
+ const cmdStaleCount = commands.filter((c) => c.status === 'stale').length;
1054
+ const cmdMissCount = commands.filter((c) => c.status === 'missing').length;
1055
+ const cmdUserCount = userModifiedCommands.length;
661
1056
  const cmdOrphanCount = orphanedCommands.length;
662
1057
  const cmdNonRegCount = nonRegularCommands.length;
663
1058
  if (commands.length === 0) {
664
1059
  lines.push(`⚠ Slash commands package commands/ is empty`);
665
- } else if (cmdStaleCount === 0 && cmdMissCount === 0 && cmdUserCount === 0 && cmdOrphanCount === 0 && cmdNonRegCount === 0) {
666
- lines.push(`✓ Slash commands ${cmdUpToDate}/${commands.length} up to date in ~/.claude/commands/hypo/`);
1060
+ } else if (
1061
+ cmdStaleCount === 0 &&
1062
+ cmdMissCount === 0 &&
1063
+ cmdUserCount === 0 &&
1064
+ cmdOrphanCount === 0 &&
1065
+ cmdNonRegCount === 0
1066
+ ) {
1067
+ lines.push(
1068
+ `✓ Slash commands ${cmdUpToDate}/${commands.length} up to date in ~/.claude/commands/hypo/`,
1069
+ );
667
1070
  } else {
668
- lines.push(`⚠ Slash commands ${cmdUpToDate} up to date, ${cmdStaleCount} stale, ${cmdMissCount} missing, ${cmdUserCount} user-modified, ${cmdOrphanCount} orphaned, ${cmdNonRegCount} non-regular:`);
1071
+ lines.push(
1072
+ `⚠ Slash commands ${cmdUpToDate} up to date, ${cmdStaleCount} stale, ${cmdMissCount} missing, ${cmdUserCount} user-modified, ${cmdOrphanCount} orphaned, ${cmdNonRegCount} non-regular:`,
1073
+ );
669
1074
  for (const c of commands) {
670
- if (c.status === 'up-to-date') lines.push(` ✓ ${c.file}`);
671
- else if (c.status === 'stale') lines.push(` ⚠ ${c.file} [stale — package has newer version]`);
672
- else if (c.status === 'missing') lines.push(` ${c.file} [not installed]`);
673
- else if (c.status === 'user-modified') lines.push(` ${c.file} [user-modified — use --apply --force-commands to overwrite]`);
674
- else if (c.status === 'orphaned') lines.push(` ⊘ ${c.file} [orphaned — no longer shipped by package; --apply will reconcile]`);
675
- else if (c.status === 'non-regular') lines.push(` ${c.file} [not a regular file (symlink?) — refusing to touch]`);
676
- else if (c.status === 'unreadable') lines.push(` ✗ ${c.file} [unreadable — refusing to touch]`);
1075
+ if (c.status === 'up-to-date') lines.push(` ✓ ${c.file}`);
1076
+ else if (c.status === 'stale')
1077
+ lines.push(` ${c.file} [stale — package has newer version]`);
1078
+ else if (c.status === 'missing') lines.push(` ${c.file} [not installed]`);
1079
+ else if (c.status === 'user-modified')
1080
+ lines.push(` ${c.file} [user-modified use --apply --force-commands to overwrite]`);
1081
+ else if (c.status === 'orphaned')
1082
+ lines.push(
1083
+ ` ⊘ ${c.file} [orphaned — no longer shipped by package; --apply will reconcile]`,
1084
+ );
1085
+ else if (c.status === 'non-regular')
1086
+ lines.push(` ✗ ${c.file} [not a regular file (symlink?) — refusing to touch]`);
1087
+ else if (c.status === 'unreadable')
1088
+ lines.push(` ✗ ${c.file} [unreadable — refusing to touch]`);
677
1089
  }
678
1090
  }
679
1091
 
680
- // Old hook names (wiki-*.mjs → hypo-*.mjs rename migration)
681
- if (oldHookRefs.length > 0) {
682
- lines.push(`⚠ Hook name migration ${oldHookRefs.length} old wiki-*.mjs reference(s) in settings.json — run --apply to rename:`);
683
- for (const r of oldHookRefs) lines.push(` ${r.event}: ${r.oldName} → ${HOOK_RENAMES[r.oldName]}`);
1092
+ // Old hook names (wiki-*.mjs → hypo-*.mjs rename migration). Target-aware so
1093
+ // fix #48 surfaces codex settings.json that still references the v1.0/v1.1 names.
1094
+ function pushHookNameSummary(refs, label) {
1095
+ if (refs.length > 0) {
1096
+ lines.push(
1097
+ `⚠ Hook name migration${label} ${refs.length} old wiki-*.mjs reference(s) — run --apply to rename:`,
1098
+ );
1099
+ for (const r of refs) lines.push(` ${r.event}: ${r.oldName} → ${HOOK_RENAMES[r.oldName]}`);
1100
+ } else {
1101
+ const colN = `Hook names${label}`.padEnd(20);
1102
+ lines.push(`✓ ${colN}All hook references use current hypo-*.mjs names`);
1103
+ }
1104
+ }
1105
+ pushHookNameSummary(oldHookRefs, '');
1106
+ if (oldHookRefsCodex) pushHookNameSummary(oldHookRefsCodex, ' (codex)');
1107
+
1108
+ // .hypoignore migration (ensure required runtime patterns are present)
1109
+ if (hypoignore.status === 'no-file') {
1110
+ lines.push(`⚠ .hypoignore not found at ${hypoignore.path} (init.mjs scaffolds this)`);
1111
+ } else if (hypoignore.status === 'up-to-date') {
1112
+ lines.push(`✓ .hypoignore required entries present`);
684
1113
  } else {
685
- lines.push(`✓ Hook names All hook references use current hypo-*.mjs names`);
1114
+ lines.push(
1115
+ `⚠ .hypoignore ${hypoignore.missing.length} missing entry(s) — run --apply to append:`,
1116
+ );
1117
+ for (const e of hypoignore.missing) lines.push(` + ${e.pattern}`);
686
1118
  }
687
1119
 
1120
+ // Extensions companion (ADR 0024; conflict/drift gating E3, #31). Shared by the
1121
+ // claude target and, under --codex, the codex target (E4, #32) — the label keeps
1122
+ // the two blocks distinguishable in the report.
1123
+ function pushExtSummary(check, label) {
1124
+ // Pad to fit the longest label ("Extensions (codex)" = 18) plus a separating
1125
+ // space so the count never glues onto the label.
1126
+ const col = `Extensions${label}`.padEnd(20);
1127
+ const pending = check.actions.filter(
1128
+ (a) => a.action === 'create' || a.action === 'update' || a.action === 'force-update',
1129
+ );
1130
+ const nConflicts = check.conflicts.length;
1131
+ const nDrifts = check.drifts.length;
1132
+ if (check.actions.length === 0 && check.warnings.length === 0) {
1133
+ lines.push(`✓ ${col}none found in ${extDir.replace(HOME, '~')}`);
1134
+ } else if (pending.length === 0 && nConflicts === 0 && nDrifts === 0) {
1135
+ const reg = check.registered.length;
1136
+ lines.push(`✓ ${col}${check.actions.length} synced${reg ? `, ${reg} hook(s) registered` : ''}`);
1137
+ } else {
1138
+ lines.push(
1139
+ `⚠ ${col}${pending.length} to sync, ${nConflicts} conflict(s), ${nDrifts} drift(s):`,
1140
+ );
1141
+ for (const a of pending) lines.push(` + ${a.file} [${a.action}]`);
1142
+ for (const c of check.conflicts) lines.push(` ✗ ${c.file} [${c.action} — left untouched]`);
1143
+ for (const d of check.drifts) lines.push(` ⚠ ${d.file} [drift — left untouched]`);
1144
+ }
1145
+ // E3 (#31): a hard conflict blocks install (exit 1, even under --apply); drift is
1146
+ // resolvable advisory. Emit the spec'd WIKI messages so the user knows the recovery.
1147
+ if (nConflicts > 0) {
1148
+ lines.push(' [WIKI: existing file conflicts. Backup and retry, or use --force-extensions]');
1149
+ }
1150
+ for (const d of check.drifts) {
1151
+ lines.push(
1152
+ ` [WIKI: extension ${d.name} drift detected. Use --force-extensions to overwrite.]`,
1153
+ );
1154
+ }
1155
+ for (const w of check.warnings) lines.push(` ⚠ ${w}`);
1156
+ }
1157
+ pushExtSummary(extCheck, '');
1158
+ if (extCheckCodex) pushExtSummary(extCheckCodex, ' (codex)');
1159
+
688
1160
  // Migration report notice
689
1161
  if (migrationPath) {
690
1162
  lines.push('');
@@ -693,7 +1165,17 @@ if (migrationPath) {
693
1165
  }
694
1166
 
695
1167
  // Applied actions
696
- if (appliedHooks.length > 0 || appliedSettings.length > 0 || appliedPkgJson || appliedHookNameRenames.length > 0 || appliedCommands.length > 0) {
1168
+ if (
1169
+ appliedHooks.length > 0 ||
1170
+ appliedSettings.length > 0 ||
1171
+ appliedPkgJson ||
1172
+ appliedHookNameRenames.length > 0 ||
1173
+ appliedCommands.length > 0 ||
1174
+ appliedHypoignore.length > 0 ||
1175
+ appliedHooksCodex.length > 0 ||
1176
+ appliedSettingsCodex.length > 0 ||
1177
+ appliedHookNameRenamesCodex.length > 0
1178
+ ) {
697
1179
  lines.push('');
698
1180
  if (appliedHookNameRenames.length > 0) {
699
1181
  lines.push(`✓ Renamed legacy hook references (${appliedHookNameRenames.length}):`);
@@ -714,15 +1196,100 @@ if (appliedHooks.length > 0 || appliedSettings.length > 0 || appliedPkgJson || a
714
1196
  if (appliedPkgJson) {
715
1197
  lines.push(`✓ Written package metadata: ~/.claude/hypo-pkg.json`);
716
1198
  }
1199
+ if (appliedHypoignore.length > 0) {
1200
+ lines.push(`✓ Appended .hypoignore entries (${appliedHypoignore.length}):`);
1201
+ for (const e of appliedHypoignore) lines.push(` → ${e}`);
1202
+ }
1203
+ // fix #48: codex-target applied actions (mirrors claude blocks above).
1204
+ if (appliedHookNameRenamesCodex.length > 0) {
1205
+ lines.push(`✓ Renamed legacy hook references (codex) (${appliedHookNameRenamesCodex.length}):`);
1206
+ for (const r of appliedHookNameRenamesCodex) lines.push(` → ${r}`);
1207
+ }
1208
+ if (appliedHooksCodex.length > 0) {
1209
+ lines.push(`✓ Updated hook files (codex) (${appliedHooksCodex.length}):`);
1210
+ for (const f of appliedHooksCodex) lines.push(` → ${f}`);
1211
+ }
1212
+ if (appliedSettingsCodex.length > 0) {
1213
+ lines.push(`✓ Merged settings.json entries (codex) (${appliedSettingsCodex.length}):`);
1214
+ for (const e of appliedSettingsCodex) lines.push(` → ${e}`);
1215
+ }
1216
+ }
1217
+
1218
+ function pushAppliedExt(applied, label) {
1219
+ if (!applied) return;
1220
+ const synced = applied.actions.filter(
1221
+ (a) => a.action === 'create' || a.action === 'update' || a.action === 'force-update',
1222
+ );
1223
+ if (synced.length > 0 || applied.settingsChanged) {
1224
+ lines.push('');
1225
+ if (synced.length > 0) {
1226
+ lines.push(`✓ Synced extensions${label} (${synced.length}):`);
1227
+ for (const a of synced) lines.push(` → ${a.file} (${a.action})`);
1228
+ }
1229
+ if (applied.settingsChanged) {
1230
+ lines.push(`✓ Registered extension hooks${label} (${applied.registered.length}):`);
1231
+ for (const r of applied.registered) lines.push(` → ${r}`);
1232
+ }
1233
+ }
717
1234
  }
1235
+ pushAppliedExt(appliedExtensions, '');
1236
+ pushAppliedExt(appliedExtensionsCodex, ' (codex)');
718
1237
 
719
1238
  // Summary
720
1239
  lines.push('');
721
- const totalDrift = staleHooks.length + missingSettings.length + (schemaDrift ? 1 : 0) + (invalidSettings ? 1 : 0) + (pkgJsonDrift ? 1 : 0) + oldHookRefs.length + staleCommands.length + userModifiedCommands.length + orphanedCommands.length + nonRegularCommands.length;
1240
+ const totalDrift =
1241
+ staleHooks.length +
1242
+ missingSettings.length +
1243
+ (schemaDrift ? 1 : 0) +
1244
+ (invalidSettings ? 1 : 0) +
1245
+ (pkgJsonDrift ? 1 : 0) +
1246
+ oldHookRefs.length +
1247
+ staleCommands.length +
1248
+ userModifiedCommands.length +
1249
+ orphanedCommands.length +
1250
+ nonRegularCommands.length +
1251
+ (hypoignore.status === 'needs-migration' ? hypoignore.missing.length : 0) +
1252
+ extCheck.actions.filter(
1253
+ (a) => a.action === 'create' || a.action === 'update' || a.action === 'force-update',
1254
+ ).length +
1255
+ // E3 (#31): unresolved drift/conflict is pending work too — without these the
1256
+ // summary printed "up to date" while the exit code was 1 (codex review).
1257
+ extCheck.conflicts.length +
1258
+ extCheck.drifts.length +
1259
+ // E4 (#32): codex-target pending work counts identically (same message/exit
1260
+ // consistency the E3 review caught — a codex conflict must not read "up to date").
1261
+ (extCheckCodex
1262
+ ? extCheckCodex.actions.filter(
1263
+ (a) => a.action === 'create' || a.action === 'update' || a.action === 'force-update',
1264
+ ).length +
1265
+ extCheckCodex.conflicts.length +
1266
+ extCheckCodex.drifts.length
1267
+ : 0) +
1268
+ // fix #48: codex core mirror counts the same way as the claude side.
1269
+ staleHooksCodex.length +
1270
+ missingSettingsCodex.length +
1271
+ (invalidSettingsCodex ? 1 : 0) +
1272
+ (oldHookRefsCodex?.length ?? 0);
722
1273
  if (totalDrift === 0) {
723
1274
  lines.push('Result: Hypomnema is up to date');
724
1275
  } else if (args.apply) {
725
- const total = appliedHooks.length + appliedSettings.length + (appliedPkgJson ? 1 : 0) + appliedHookNameRenames.length;
1276
+ const countApplied = (r) =>
1277
+ r
1278
+ ? r.actions.filter(
1279
+ (a) => a.action === 'create' || a.action === 'update' || a.action === 'force-update',
1280
+ ).length + (r.settingsChanged ? 1 : 0)
1281
+ : 0;
1282
+ const appliedExtCount = countApplied(appliedExtensions) + countApplied(appliedExtensionsCodex);
1283
+ const total =
1284
+ appliedHooks.length +
1285
+ appliedSettings.length +
1286
+ (appliedPkgJson ? 1 : 0) +
1287
+ appliedHookNameRenames.length +
1288
+ appliedHypoignore.length +
1289
+ appliedExtCount +
1290
+ appliedHooksCodex.length +
1291
+ appliedSettingsCodex.length +
1292
+ appliedHookNameRenamesCodex.length;
726
1293
  lines.push(`Result: ${total} update(s) applied. Run /hypo:doctor to verify.`);
727
1294
  } else {
728
1295
  lines.push(`Result: ${totalDrift} item(s) need updating — run with --apply to install`);
@@ -730,4 +1297,8 @@ if (totalDrift === 0) {
730
1297
 
731
1298
  console.log(lines.join('\n'));
732
1299
 
733
- process.exit(hasDrift && !args.apply ? 1 : 0);
1300
+ // E3 (#31): a hard extension conflict blocks even under --apply (unlike ordinary
1301
+ // drift, which only fails check mode). --force-extensions clears the resolvable
1302
+ // cases; an unfollowable symlink/non-regular dest still counts and stays exit 1.
1303
+ const extBlocked = extCheck.conflicts.length > 0 || (extCheckCodex?.conflicts.length ?? 0) > 0;
1304
+ process.exit((hasDrift && !args.apply) || extBlocked ? 1 : 0);