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
package/scripts/init.mjs CHANGED
@@ -13,7 +13,7 @@
13
13
  * --no-hooks Skip hook installation
14
14
  * --no-commands Skip slash command installation to ~/.claude/commands/hypo/
15
15
  * --force-commands Overwrite user-modified slash command files (creates .bak)
16
- * --codex Also install Codex hooks (~/.codex/hooks/)
16
+ * --codex Also install Codex hooks + extensions (~/.codex/{hooks,commands}/)
17
17
  * --git-remote=<url> Git remote URL
18
18
  * --no-git-init Skip git initialization
19
19
  * --from-remote=<url> Clone existing Hypomnema wiki from remote and install hooks
@@ -21,78 +21,145 @@
21
21
  * --help, -h Show this help message
22
22
  */
23
23
 
24
- import { existsSync, mkdirSync, readFileSync, writeFileSync, copyFileSync, readdirSync, renameSync, unlinkSync } from 'fs';
24
+ import {
25
+ existsSync,
26
+ mkdirSync,
27
+ readFileSync,
28
+ writeFileSync,
29
+ chmodSync,
30
+ copyFileSync,
31
+ readdirSync,
32
+ renameSync,
33
+ unlinkSync,
34
+ } from 'fs';
25
35
  import { join, basename } from 'path';
26
36
  import { homedir } from 'os';
27
37
  import { execSync, spawnSync } from 'child_process';
28
38
  import { fileURLToPath } from 'url';
29
39
  import { createHash } from 'crypto';
30
40
  import { expandHome, resolveHypoRoot } from './lib/hypo-root.mjs';
31
- import { readPkgJson as readPkgJsonSafe, writePkgJsonAtomic, sha256 as sha256Buf, isRegularFile, readFileIfRegular } from './lib/pkg-json.mjs';
32
-
33
- const HOME = homedir();
41
+ import {
42
+ readPkgJson as readPkgJsonSafe,
43
+ writePkgJsonAtomic,
44
+ sha256 as sha256Buf,
45
+ isRegularFile,
46
+ readFileIfRegular,
47
+ } from './lib/pkg-json.mjs';
48
+ import { syncExtensions } from './lib/extensions.mjs';
49
+
50
+ const HOME = homedir();
34
51
  const SCRIPT_DIR = fileURLToPath(new URL('.', import.meta.url));
35
- const PKG_ROOT = join(SCRIPT_DIR, '..');
36
- const HOOKS_SRC = join(PKG_ROOT, 'hooks');
52
+ const PKG_ROOT = join(SCRIPT_DIR, '..');
53
+
54
+ // Shown after every fatal package-integrity error. These conditions mean the
55
+ // shipped hooks/hooks.json is missing or malformed — never a user mistake —
56
+ // so the only useful next step is a re-install of the package.
57
+ const PKG_INTEGRITY_HINT =
58
+ '→ This indicates a corrupt or incomplete install. Re-install with `npm install -g hypomnema` (or re-install the Claude Code plugin).';
59
+ const HOOKS_SRC = join(PKG_ROOT, 'hooks');
37
60
  const COMMANDS_SRC = join(PKG_ROOT, 'commands');
38
- const TEMPLATES = join(PKG_ROOT, 'templates');
39
- const PKG_VERSION = (() => {
40
- try { return JSON.parse(readFileSync(join(PKG_ROOT, 'package.json'), 'utf-8')).version; }
41
- catch { return null; }
61
+ const TEMPLATES = join(PKG_ROOT, 'templates');
62
+ const PKG_VERSION = (() => {
63
+ try {
64
+ return JSON.parse(readFileSync(join(PKG_ROOT, 'package.json'), 'utf-8')).version;
65
+ } catch {
66
+ return null;
67
+ }
42
68
  })();
43
69
 
44
- function sha256(buf) { return sha256Buf(buf); }
70
+ function sha256(buf) {
71
+ return sha256Buf(buf);
72
+ }
73
+
74
+ // ── subcommand dispatch ──────────────────────────────────────────────────────
75
+ // Route `hypomnema <verb> [flags]` to the matching script. README, CHANGELOG,
76
+ // and the upgrade-flow docs all document `hypomnema upgrade --apply`,
77
+ // `hypomnema upgrade --check`, `hypomnema doctor`, and `hypomnema uninstall`,
78
+ // but without this dispatcher the positional verb was silently dropped and
79
+ // init.mjs ran instead — so users got an init-shaped output and assumed the
80
+ // command "worked" while the documented behavior never happened.
81
+ //
82
+ // `hypomnema` with no verb (or with only flags like `--hypo-dir=…` / `--help`)
83
+ // keeps running init for backwards compatibility — that's the documented
84
+ // Path-B onboarding command. An explicit `hypomnema init` is accepted too,
85
+ // and is stripped before flag parsing so the rest of this file is unchanged.
86
+ const KNOWN_SUBCOMMANDS = new Set(['init', 'upgrade', 'doctor', 'uninstall', 'feedback-sync']);
87
+ const _verb = process.argv[2];
88
+ if (_verb && KNOWN_SUBCOMMANDS.has(_verb) && _verb !== 'init') {
89
+ const _target = join(SCRIPT_DIR, `${_verb}.mjs`);
90
+ const _r = spawnSync(process.execPath, [_target, ...process.argv.slice(3)], { stdio: 'inherit' });
91
+ process.exit(_r.status ?? 1);
92
+ }
93
+ if (_verb === 'init') process.argv.splice(2, 1);
45
94
 
46
95
  // ── arg parsing ──────────────────────────────────────────────────────────────
47
96
 
48
97
  function parseArgs(argv) {
49
98
  const args = {
50
- hypoDir: resolveHypoRoot(),
51
- hooks: true,
52
- commands: true,
99
+ hypoDir: resolveHypoRoot(),
100
+ hooks: true,
101
+ commands: true,
53
102
  forceCommands: false,
54
- codex: false,
55
- gitRemote: null,
56
- gitInit: true,
57
- dryRun: false,
58
- fromRemote: null,
59
- shellSetup: true,
103
+ forceExtensions: false,
104
+ codex: false,
105
+ gitRemote: null,
106
+ gitInit: true,
107
+ dryRun: false,
108
+ fromRemote: null,
109
+ shellSetup: true,
60
110
  shellConfig: null,
61
111
  };
62
112
  for (const arg of argv.slice(2)) {
63
113
  if (arg === '--help' || arg === '-h') {
64
- console.log(`Usage: node scripts/init.mjs [options]
114
+ console.log(`Usage: hypomnema [<command>] [options]
115
+
116
+ Commands:
117
+ init (default) Scaffold a wiki, install hooks, merge settings.json
118
+ upgrade Reconcile hooks / settings.json / slash commands against the
119
+ installed package (use --check for dry-run, --apply to commit)
120
+ doctor Health check: directories, files, hooks, settings.json, git
121
+ uninstall Remove hooks and registrations (dry-run by default; pass --apply)
122
+ feedback-sync Project feedback (SoT) → MEMORY.md / CLAUDE.md learned-behaviors
123
+ projection (--check default, --write to apply; ADR 0031)
65
124
 
66
- Options:
125
+ Running \`hypomnema\` with no command is equivalent to \`hypomnema init\`.
126
+
127
+ Init options:
67
128
  --hypo-dir=<path> Hypomnema root directory (default: resolves via HYPO_DIR env / hypo-config.md scan / ~/hypomnema)
68
129
  --no-hooks Skip hook installation
69
130
  --no-commands Skip slash command installation to ~/.claude/commands/hypo/
70
131
  --force-commands Overwrite user-modified slash command files (creates .bak)
71
- --codex Also install Codex hooks (~/.codex/hooks/)
132
+ --force-extensions Overwrite user-modified / conflicting extension copies (creates .bak)
133
+ --codex Also install Codex hooks + extensions (~/.codex/{hooks,commands}/)
72
134
  --git-remote=<url> Git remote URL
73
135
  --no-git-init Skip git initialization
74
136
  --from-remote=<url> Clone existing Hypomnema wiki from remote and install hooks
75
137
  --no-shell Skip shell function setup (~/.zshrc / ~/.bashrc)
76
138
  --shell-config=<path> Shell config file path (default: auto-detect)
77
139
  --dry-run Show what would be done without making changes
78
- --help, -h Show this help message`);
140
+ --help, -h Show this help message
141
+
142
+ Subcommand-specific flags (upgrade/doctor/uninstall) live in the
143
+ docstring at the top of scripts/<command>.mjs.`);
79
144
  process.exit(0);
80
- }
81
- else if (arg.startsWith('--hypo-dir=')) args.hypoDir = expandHome(arg.slice(11));
82
- else if (arg === '--no-hooks') args.hooks = false;
83
- else if (arg === '--no-commands') args.commands = false;
84
- else if (arg === '--force-commands') args.forceCommands = true;
85
- else if (arg === '--codex') args.codex = true;
86
- else if (arg.startsWith('--git-remote=')) args.gitRemote = arg.slice(13);
87
- else if (arg === '--no-git-init') args.gitInit = false;
145
+ } else if (arg.startsWith('--hypo-dir=')) args.hypoDir = expandHome(arg.slice(11));
146
+ else if (arg === '--no-hooks') args.hooks = false;
147
+ else if (arg === '--no-commands') args.commands = false;
148
+ else if (arg === '--force-commands') args.forceCommands = true;
149
+ else if (arg === '--force-extensions') args.forceExtensions = true;
150
+ else if (arg === '--codex') args.codex = true;
151
+ else if (arg.startsWith('--git-remote=')) args.gitRemote = arg.slice(13);
152
+ else if (arg === '--no-git-init') args.gitInit = false;
88
153
  else if (arg.startsWith('--from-remote=')) {
89
154
  const url = arg.slice(14).trim();
90
- if (!url) { console.error('Error: --from-remote requires a non-empty URL'); process.exit(1); }
155
+ if (!url) {
156
+ console.error('Error: --from-remote requires a non-empty URL');
157
+ process.exit(1);
158
+ }
91
159
  args.fromRemote = url;
92
- }
93
- else if (arg === '--dry-run') args.dryRun = true;
94
- else if (arg === '--no-shell') args.shellSetup = false;
95
- else if (arg.startsWith('--shell-config=')) args.shellConfig = expandHome(arg.slice(15));
160
+ } else if (arg === '--dry-run') args.dryRun = true;
161
+ else if (arg === '--no-shell') args.shellSetup = false;
162
+ else if (arg.startsWith('--shell-config=')) args.shellConfig = expandHome(arg.slice(15));
96
163
  }
97
164
  return args;
98
165
  }
@@ -101,11 +168,27 @@ Options:
101
168
 
102
169
  const results = { created: [], skipped: [], merged: [], errors: [] };
103
170
 
104
- function log(action, path) { results[action].push(path); }
171
+ function log(action, path) {
172
+ results[action].push(path);
173
+ }
105
174
 
106
175
  // ── directory structure ──────────────────────────────────────────────────────
107
176
 
108
- const HYPO_DIRS = ['pages', 'projects', 'sources', 'journal/daily', 'journal/weekly', 'journal/monthly'];
177
+ const HYPO_DIRS = [
178
+ 'pages',
179
+ 'projects',
180
+ 'sources',
181
+ 'journal/daily',
182
+ 'journal/weekly',
183
+ 'journal/monthly',
184
+ 'pages/observability',
185
+ // User extensions companion (ADR 0024). init creates the baseline
186
+ // dirs; #29 (E2) adds the hard-copy / manifest / settings sync into them.
187
+ 'extensions/hooks',
188
+ 'extensions/commands',
189
+ 'extensions/skills',
190
+ 'extensions/agents',
191
+ ];
109
192
 
110
193
  function ensureDir(dir, dryRun) {
111
194
  if (existsSync(dir)) return;
@@ -117,8 +200,14 @@ function ensureDir(dir, dryRun) {
117
200
 
118
201
  function copyTemplate(srcName, destPath, dryRun, transform) {
119
202
  const src = join(TEMPLATES, srcName);
120
- if (!existsSync(src)) { log('errors', `template missing: ${srcName}`); return; }
121
- if (existsSync(destPath)) { log('skipped', destPath); return; }
203
+ if (!existsSync(src)) {
204
+ log('errors', `template missing: ${srcName}`);
205
+ return;
206
+ }
207
+ if (existsSync(destPath)) {
208
+ log('skipped', destPath);
209
+ return;
210
+ }
122
211
  if (!dryRun) {
123
212
  let content = readFileSync(src, 'utf-8');
124
213
  content = content.replace(/YYYY-MM-DD/g, new Date().toISOString().slice(0, 10));
@@ -132,10 +221,13 @@ function copyTemplate(srcName, destPath, dryRun, transform) {
132
221
 
133
222
  function writeHypoConfig(hypoDir, dryRun) {
134
223
  const dest = join(hypoDir, 'hypo-config.md');
135
- if (existsSync(dest)) { log('skipped', dest); return; }
224
+ if (existsSync(dest)) {
225
+ log('skipped', dest);
226
+ return;
227
+ }
136
228
  const today = new Date().toISOString().slice(0, 10);
137
- const src = join(TEMPLATES, 'hypo-config.md');
138
- const base = existsSync(src) ? readFileSync(src, 'utf-8') : '';
229
+ const src = join(TEMPLATES, 'hypo-config.md');
230
+ const base = existsSync(src) ? readFileSync(src, 'utf-8') : '';
139
231
  const content = base.replace(/YYYY-MM-DD/g, today);
140
232
  if (!dryRun) writeFileSync(dest, content);
141
233
  log('created', dest);
@@ -145,13 +237,31 @@ function writeHypoConfig(hypoDir, dryRun) {
145
237
 
146
238
  function writeWikiignore(hypoDir, dryRun) {
147
239
  const dest = join(hypoDir, '.hypoignore');
148
- if (existsSync(dest)) { log('skipped', dest); return; }
149
- const src = join(TEMPLATES, '.hypoignore');
240
+ if (existsSync(dest)) {
241
+ log('skipped', dest);
242
+ return;
243
+ }
244
+ const src = join(TEMPLATES, '.hypoignore');
150
245
  const content = existsSync(src) ? readFileSync(src, 'utf-8') : '';
151
246
  if (!dryRun) writeFileSync(dest, content);
152
247
  log('created', dest);
153
248
  }
154
249
 
250
+ // ── .gitignore ───────────────────────────────────────────────────────────────
251
+
252
+ function writeGitignore(hypoDir, dryRun) {
253
+ const dest = join(hypoDir, '.gitignore');
254
+ if (existsSync(dest)) {
255
+ log('skipped', dest);
256
+ return;
257
+ }
258
+ // Template is named without leading dot to survive npm pack (which strips .gitignore)
259
+ const src = join(TEMPLATES, 'gitignore');
260
+ const content = existsSync(src) ? readFileSync(src, 'utf-8') : '.cache/\n';
261
+ if (!dryRun) writeFileSync(dest, content);
262
+ log('created', dest);
263
+ }
264
+
155
265
  // ── hook installation ────────────────────────────────────────────────────────
156
266
 
157
267
  function loadHookMap() {
@@ -160,14 +270,17 @@ function loadHookMap() {
160
270
  cfg = JSON.parse(readFileSync(join(PKG_ROOT, 'hooks', 'hooks.json'), 'utf-8'));
161
271
  } catch {
162
272
  console.error(`Error: cannot read hooks/hooks.json from package root: ${PKG_ROOT}`);
273
+ console.error(PKG_INTEGRITY_HINT);
163
274
  process.exit(1);
164
275
  }
165
276
  if (!cfg || typeof cfg !== 'object' || Array.isArray(cfg)) {
166
277
  console.error('Error: hooks/hooks.json must be a JSON object');
278
+ console.error(PKG_INTEGRITY_HINT);
167
279
  process.exit(1);
168
280
  }
169
281
  if (!cfg.hooks || typeof cfg.hooks !== 'object' || Array.isArray(cfg.hooks)) {
170
282
  console.error('Error: hooks/hooks.json must contain a "hooks" object');
283
+ console.error(PKG_INTEGRITY_HINT);
171
284
  process.exit(1);
172
285
  }
173
286
  function _extractCommandFileName(command) {
@@ -183,52 +296,71 @@ function loadHookMap() {
183
296
  }
184
297
 
185
298
  function _isHookGroup(group) {
186
- return group &&
299
+ return (
300
+ group &&
187
301
  typeof group === 'object' &&
188
302
  !Array.isArray(group) &&
189
303
  Array.isArray(group.hooks) &&
190
304
  group.hooks.length > 0 &&
191
- group.hooks.every(hook =>
192
- hook &&
193
- typeof hook === 'object' &&
194
- !Array.isArray(hook) &&
195
- hook.type === 'command' &&
196
- _extractCommandFileName(hook.command)
197
- );
305
+ group.hooks.every(
306
+ (hook) =>
307
+ hook &&
308
+ typeof hook === 'object' &&
309
+ !Array.isArray(hook) &&
310
+ hook.type === 'command' &&
311
+ _extractCommandFileName(hook.command),
312
+ )
313
+ );
198
314
  }
199
315
 
200
316
  // Extract .mjs file names from both old format (string[]) and new format (hook-group object[])
201
317
  function _extractFileNames(groups) {
202
- return groups.flatMap(group => {
318
+ return groups.flatMap((group) => {
203
319
  if (typeof group === 'string') return [group.trim()];
204
- return group.hooks.map(hook => _extractCommandFileName(hook.command));
320
+ return group.hooks.map((hook) => _extractCommandFileName(hook.command));
205
321
  });
206
322
  }
207
323
 
208
324
  for (const [event, groups] of Object.entries(cfg.hooks)) {
209
- const valid = Array.isArray(groups) &&
325
+ const valid =
326
+ Array.isArray(groups) &&
210
327
  groups.length > 0 &&
211
- groups.every(group => _isHookFileName(group) || _isHookGroup(group)) &&
328
+ groups.every((group) => _isHookFileName(group) || _isHookGroup(group)) &&
212
329
  _extractFileNames(groups).length > 0;
213
330
  if (!valid) {
214
- console.error(`Error: hooks/hooks.json "hooks.${event}" must be a non-empty array of .mjs file names or Claude hook groups`);
331
+ console.error(
332
+ `Error: hooks/hooks.json "hooks.${event}" must be a non-empty array of .mjs file names or Claude hook groups`,
333
+ );
334
+ console.error(PKG_INTEGRITY_HINT);
215
335
  process.exit(1);
216
336
  }
217
337
  }
218
- if (cfg.shared !== undefined && (!Array.isArray(cfg.shared) || !cfg.shared.every(f => _isHookFileName(f)))) {
338
+ if (
339
+ cfg.shared !== undefined &&
340
+ (!Array.isArray(cfg.shared) || !cfg.shared.every((f) => _isHookFileName(f)))
341
+ ) {
219
342
  console.error('Error: hooks/hooks.json "shared" must be an array of .mjs file names');
343
+ console.error(PKG_INTEGRITY_HINT);
220
344
  process.exit(1);
221
345
  }
222
- return Object.fromEntries(Object.entries(cfg.hooks).map(([event, groups]) => [event, _extractFileNames(groups)]));
346
+ return Object.fromEntries(
347
+ Object.entries(cfg.hooks).map(([event, groups]) => [event, _extractFileNames(groups)]),
348
+ );
223
349
  }
224
350
 
225
351
  function installHooks(targetDir, dryRun) {
226
- if (!existsSync(HOOKS_SRC)) { log('errors', `hooks source missing: ${HOOKS_SRC}`); return; }
352
+ if (!existsSync(HOOKS_SRC)) {
353
+ log('errors', `hooks source missing: ${HOOKS_SRC}`);
354
+ return;
355
+ }
227
356
  if (!dryRun) mkdirSync(targetDir, { recursive: true });
228
357
  for (const file of readdirSync(HOOKS_SRC)) {
229
358
  if (!file.endsWith('.mjs')) continue;
230
359
  const dest = join(targetDir, file);
231
- if (existsSync(dest)) { log('skipped', dest); continue; }
360
+ if (existsSync(dest)) {
361
+ log('skipped', dest);
362
+ continue;
363
+ }
232
364
  if (!dryRun) copyFileSync(join(HOOKS_SRC, file), dest);
233
365
  log('created', dest);
234
366
  }
@@ -237,8 +369,13 @@ function installHooks(targetDir, dryRun) {
237
369
  function mergeSettingsJson(settingsPath, hooksDir, dryRun, hookMap) {
238
370
  let settings = {};
239
371
  if (existsSync(settingsPath)) {
240
- try { settings = JSON.parse(readFileSync(settingsPath, 'utf-8')); } catch {
241
- log('errors', `settings.json is not valid JSON — fix or back it up before re-running: ${settingsPath}`);
372
+ try {
373
+ settings = JSON.parse(readFileSync(settingsPath, 'utf-8'));
374
+ } catch {
375
+ log(
376
+ 'errors',
377
+ `settings.json is not valid JSON — fix or back it up before re-running: ${settingsPath}`,
378
+ );
242
379
  return;
243
380
  }
244
381
  }
@@ -250,8 +387,8 @@ function mergeSettingsJson(settingsPath, hooksDir, dryRun, hookMap) {
250
387
  for (const file of files) {
251
388
  const cmd = `node ${hooksDir.replace(HOME, '$HOME')}/${file}`;
252
389
  const already = settings.hooks[event]
253
- .flatMap(g => g.hooks || [])
254
- .some(h => h.command === cmd);
390
+ .flatMap((g) => g.hooks || [])
391
+ .some((h) => h.command === cmd);
255
392
  if (already) continue;
256
393
  settings.hooks[event].push({ hooks: [{ type: 'command', command: cmd }] });
257
394
  log('merged', `${event}: ${file}`);
@@ -278,16 +415,18 @@ echo "[post-commit] hooks/ changed — syncing to ~/.claude/hooks/ ..."
278
415
  node "$REPO_ROOT/scripts/upgrade.mjs" --apply
279
416
  `;
280
417
 
281
- function pkgJsonPath() { return join(HOME, '.claude', 'hypo-pkg.json'); }
418
+ function pkgJsonPath() {
419
+ return join(HOME, '.claude', 'hypo-pkg.json');
420
+ }
282
421
 
283
422
  function writePkgJson(dryRun, extraFields = {}) {
284
- const dest = pkgJsonPath();
423
+ const dest = pkgJsonPath();
285
424
  const existing = readPkgJsonSafe(dest);
286
- const merged = {
425
+ const merged = {
287
426
  ...existing,
288
- pkgRoot: PKG_ROOT,
289
- pkgVersion: PKG_VERSION,
290
- schemaVersion: '1.0',
427
+ pkgRoot: PKG_ROOT,
428
+ pkgVersion: PKG_VERSION,
429
+ schemaVersion: '2.0',
291
430
  ...extraFields,
292
431
  };
293
432
  if (!dryRun) {
@@ -309,27 +448,36 @@ function writePkgJson(dryRun, extraFields = {}) {
309
448
  // Recorded SHAs are kept in ~/.claude/hypo-pkg.json under `commands: { "<name>.md": "<sha>" }`.
310
449
 
311
450
  function installCommands(targetDir, dryRun, force) {
312
- if (!existsSync(COMMANDS_SRC)) { log('errors', `commands source missing: ${COMMANDS_SRC}`); return null; }
451
+ if (!existsSync(COMMANDS_SRC)) {
452
+ log('errors', `commands source missing: ${COMMANDS_SRC}`);
453
+ return null;
454
+ }
313
455
  if (!dryRun) mkdirSync(targetDir, { recursive: true });
314
456
 
315
- const prevPkg = readPkgJsonSafe(pkgJsonPath());
316
- const prevSHAs = (prevPkg.commands && typeof prevPkg.commands === 'object') ? prevPkg.commands : {};
317
- const newSHAs = {};
457
+ const prevPkg = readPkgJsonSafe(pkgJsonPath());
458
+ const prevSHAs = prevPkg.commands && typeof prevPkg.commands === 'object' ? prevPkg.commands : {};
459
+ const newSHAs = {};
318
460
 
319
461
  function writeFresh(dest, srcContent) {
320
462
  if (dryRun) return;
321
463
  const tmp = `${dest}.tmp.${process.pid}.${Date.now()}`;
322
464
  writeFileSync(tmp, srcContent);
323
- try { renameSync(tmp, dest); }
324
- catch (err) { try { unlinkSync(tmp); } catch {} throw err; }
465
+ try {
466
+ renameSync(tmp, dest);
467
+ } catch (err) {
468
+ try {
469
+ unlinkSync(tmp);
470
+ } catch {}
471
+ throw err;
472
+ }
325
473
  }
326
474
 
327
475
  for (const file of readdirSync(COMMANDS_SRC)) {
328
476
  if (!file.endsWith('.md')) continue;
329
- const srcPath = join(COMMANDS_SRC, file);
330
- const dest = join(targetDir, file);
477
+ const srcPath = join(COMMANDS_SRC, file);
478
+ const dest = join(targetDir, file);
331
479
  const srcContent = readFileSync(srcPath);
332
- const srcSHA = sha256(srcContent);
480
+ const srcSHA = sha256(srcContent);
333
481
 
334
482
  // Fresh install
335
483
  if (!existsSync(dest)) {
@@ -396,7 +544,10 @@ function installCommands(targetDir, dryRun, force) {
396
544
  newSHAs[file] = recordedSHA;
397
545
  log('skipped', `${dest} (user-modified — re-run with --force-commands to overwrite)`);
398
546
  } else {
399
- log('skipped', `${dest} (file exists but Hypomnema does not own it — re-run with --force-commands to take ownership)`);
547
+ log(
548
+ 'skipped',
549
+ `${dest} (file exists but Hypomnema does not own it — re-run with --force-commands to take ownership)`,
550
+ );
400
551
  }
401
552
  }
402
553
 
@@ -408,7 +559,10 @@ function installPkgGitHook(dryRun) {
408
559
  if (!existsSync(gitDir)) return;
409
560
  const hooksDir = join(gitDir, 'hooks');
410
561
  const hookPath = join(hooksDir, 'post-commit');
411
- if (existsSync(hookPath)) { log('skipped', hookPath); return; }
562
+ if (existsSync(hookPath)) {
563
+ log('skipped', hookPath);
564
+ return;
565
+ }
412
566
  if (!dryRun) {
413
567
  mkdirSync(hooksDir, { recursive: true });
414
568
  writeFileSync(hookPath, PKG_GIT_HOOK_CONTENT, { mode: 0o755 });
@@ -416,10 +570,63 @@ function installPkgGitHook(dryRun) {
416
570
  log('created', hookPath);
417
571
  }
418
572
 
573
+ // ── wiki pre-commit hook ─────────────────────────────────────────────────────
574
+
575
+ const WIKI_PRE_COMMIT_MARKER_START = '# hypo-managed:pre-commit:start';
576
+ const WIKI_PRE_COMMIT_MARKER_END = '# hypo-managed:pre-commit:end';
577
+
578
+ function wikiPreCommitContent() {
579
+ const worker = join(PKG_ROOT, 'hooks', 'hypo-pre-commit.mjs');
580
+ // Single-quote escaping prevents shell expansion of special chars (e.g. $HOME, backticks) in path
581
+ const escaped = worker.replace(/'/g, "'\\''");
582
+ return `#!/bin/sh\n${WIKI_PRE_COMMIT_MARKER_START}\nnode '${escaped}'\nexit $?\n${WIKI_PRE_COMMIT_MARKER_END}\n`;
583
+ }
584
+
585
+ function installWikiPreCommitHook(hypoDir, dryRun, force) {
586
+ const gitDir = join(hypoDir, '.git');
587
+ if (!existsSync(gitDir)) return; // no git repo — silently skip
588
+ const hooksDir = join(gitDir, 'hooks');
589
+ const hookPath = join(hooksDir, 'pre-commit');
590
+ const newContent = wikiPreCommitContent();
591
+
592
+ if (existsSync(hookPath)) {
593
+ const existing = readFileSync(hookPath, 'utf-8');
594
+ if (existing.includes(WIKI_PRE_COMMIT_MARKER_START)) {
595
+ if (existing === newContent) {
596
+ log('skipped', `${hookPath} (pre-commit up to date)`);
597
+ return;
598
+ }
599
+ if (!dryRun) {
600
+ writeFileSync(hookPath, newContent);
601
+ chmodSync(hookPath, 0o755);
602
+ }
603
+ log('merged', `${hookPath} (pre-commit updated)`);
604
+ } else if (force) {
605
+ if (!dryRun) {
606
+ writeFileSync(hookPath + '.bak', existing);
607
+ writeFileSync(hookPath, newContent);
608
+ chmodSync(hookPath, 0o755);
609
+ }
610
+ log('merged', `${hookPath} (force-overwritten, backup at pre-commit.bak)`);
611
+ } else {
612
+ log(
613
+ 'skipped',
614
+ `${hookPath} (user pre-commit exists — re-run with --force-commands to install)`,
615
+ );
616
+ }
617
+ return;
618
+ }
619
+ if (!dryRun) {
620
+ mkdirSync(hooksDir, { recursive: true });
621
+ writeFileSync(hookPath, newContent, { mode: 0o755 });
622
+ }
623
+ log('created', hookPath);
624
+ }
625
+
419
626
  // ── shell function setup ─────────────────────────────────────────────────────
420
627
 
421
628
  const SHELL_MARKER_START = '# hypo-managed:shell-setup:start';
422
- const SHELL_MARKER_END = '# hypo-managed:shell-setup:end';
629
+ const SHELL_MARKER_END = '# hypo-managed:shell-setup:end';
423
630
 
424
631
  function shellFunctionBlock() {
425
632
  return `${SHELL_MARKER_START}
@@ -433,7 +640,7 @@ ${SHELL_MARKER_END}`;
433
640
  function detectShellConfig(customPath) {
434
641
  if (customPath) return customPath;
435
642
  const shell = process.env.SHELL || '';
436
- if (shell.includes('zsh')) return join(HOME, '.zshrc');
643
+ if (shell.includes('zsh')) return join(HOME, '.zshrc');
437
644
  if (shell.includes('bash')) return join(HOME, '.bashrc');
438
645
  // fallback: prefer .zshrc if it exists, else .bashrc
439
646
  const zshrc = join(HOME, '.zshrc');
@@ -451,14 +658,18 @@ function installShellFunction(shellConfigPath, dryRun) {
451
658
 
452
659
  const content = readFileSync(shellConfigPath, 'utf-8');
453
660
  const startIdx = content.indexOf(SHELL_MARKER_START);
454
- const endIdx = content.indexOf(SHELL_MARKER_END);
661
+ const endIdx = content.indexOf(SHELL_MARKER_END);
455
662
 
456
663
  if (startIdx !== -1 && endIdx !== -1) {
457
664
  // Block exists — check if already up to date
458
665
  const existing = content.slice(startIdx, endIdx + SHELL_MARKER_END.length);
459
- if (existing === block) { log('skipped', `${shellConfigPath} (shell function up to date)`); return; }
666
+ if (existing === block) {
667
+ log('skipped', `${shellConfigPath} (shell function up to date)`);
668
+ return;
669
+ }
460
670
  // Replace stale block
461
- const updated = content.slice(0, startIdx) + block + content.slice(endIdx + SHELL_MARKER_END.length);
671
+ const updated =
672
+ content.slice(0, startIdx) + block + content.slice(endIdx + SHELL_MARKER_END.length);
462
673
  if (!dryRun) writeFileSync(shellConfigPath, updated);
463
674
  log('merged', `${shellConfigPath} (shell function updated)`);
464
675
  return;
@@ -476,7 +687,10 @@ function installShellFunction(shellConfigPath, dryRun) {
476
687
 
477
688
  function cloneFromRemote(url, hypoDir, dryRun) {
478
689
  if (existsSync(hypoDir)) {
479
- log('errors', `--from-remote: target directory already exists: ${hypoDir}. Remove it or choose a different --hypo-dir.`);
690
+ log(
691
+ 'errors',
692
+ `--from-remote: target directory already exists: ${hypoDir}. Remove it or choose a different --hypo-dir.`,
693
+ );
480
694
  return false;
481
695
  }
482
696
  console.log(`Cloning ${url} → ${hypoDir} ...`);
@@ -488,7 +702,10 @@ function cloneFromRemote(url, hypoDir, dryRun) {
488
702
  }
489
703
  if (!existsSync(join(hypoDir, 'hypo-config.md'))) {
490
704
  spawnSync('rm', ['-rf', hypoDir]);
491
- log('errors', `--from-remote: cloned repo is not a Hypomnema wiki (hypo-config.md missing). Removed ${hypoDir}.`);
705
+ log(
706
+ 'errors',
707
+ `--from-remote: cloned repo is not a Hypomnema wiki (hypo-config.md missing). Removed ${hypoDir}.`,
708
+ );
492
709
  return false;
493
710
  }
494
711
  }
@@ -538,7 +755,10 @@ function firstCommit(hypoDir, remote, dryRun) {
538
755
  if (!dryRun) {
539
756
  git(hypoDir, ['add', '-A'], { stdio: 'ignore' });
540
757
  const commitR = git(hypoDir, ['commit', '-m', `chore: init hypomnema (${today})`]);
541
- if (commitR.status !== 0) { log('errors', 'first commit failed'); return; }
758
+ if (commitR.status !== 0) {
759
+ log('errors', 'first commit failed');
760
+ return;
761
+ }
542
762
  if (remote) {
543
763
  const actualOrigin = git(hypoDir, ['remote', 'get-url', 'origin']);
544
764
  const pushTarget = actualOrigin.status === 0 ? actualOrigin.stdout.trim() : remote;
@@ -555,7 +775,7 @@ function firstCommit(hypoDir, remote, dryRun) {
555
775
  const args = parseArgs(process.argv);
556
776
 
557
777
  // Validate hooks.json before any file writes so a bad package leaves no partial state
558
- const HOOK_MAP = (args.hooks || args.codex) ? loadHookMap() : null;
778
+ const HOOK_MAP = args.hooks || args.codex ? loadHookMap() : null;
559
779
 
560
780
  if (args.fromRemote) {
561
781
  // ── from-remote path: clone → read config → install hooks ──────────────────
@@ -570,31 +790,63 @@ if (args.fromRemote) {
570
790
  ensureDir(args.hypoDir, args.dryRun);
571
791
  for (const d of HYPO_DIRS) ensureDir(join(args.hypoDir, d), args.dryRun);
572
792
 
793
+ // 1b. extensions baseline: drop a .gitkeep into each so the empty dirs are
794
+ // git-trackable in the user's wiki repo (ADR 0024).
795
+ for (const t of ['hooks', 'commands', 'skills', 'agents']) {
796
+ copyTemplate(
797
+ join('extensions', t, '.gitkeep'),
798
+ join(args.hypoDir, 'extensions', t, '.gitkeep'),
799
+ args.dryRun,
800
+ );
801
+ }
802
+
573
803
  // 2. template files
574
- copyTemplate('index.md', join(args.hypoDir, 'index.md'), args.dryRun);
575
- copyTemplate('hot.md', join(args.hypoDir, 'hot.md'), args.dryRun);
576
- copyTemplate('log.md', join(args.hypoDir, 'log.md'), args.dryRun);
577
- copyTemplate('SCHEMA.md', join(args.hypoDir, 'SCHEMA.md'), args.dryRun);
578
- copyTemplate('hypo-guide.md', join(args.hypoDir, 'hypo-guide.md'), args.dryRun);
579
- copyTemplate('Home.md', join(args.hypoDir, 'Home.md'), args.dryRun);
580
- copyTemplate('Overview.md', join(args.hypoDir, 'Overview.md'), args.dryRun);
581
- copyTemplate('hypo-help.md', join(args.hypoDir, 'hypo-help.md'), args.dryRun);
582
- copyTemplate('hypo-automation.md',join(args.hypoDir, 'hypo-automation.md'),args.dryRun);
583
- copyTemplate('session-state.md', join(args.hypoDir, 'session-state.md'), args.dryRun);
804
+ copyTemplate('index.md', join(args.hypoDir, 'index.md'), args.dryRun);
805
+ copyTemplate('hot.md', join(args.hypoDir, 'hot.md'), args.dryRun);
806
+ copyTemplate('log.md', join(args.hypoDir, 'log.md'), args.dryRun);
807
+ copyTemplate('SCHEMA.md', join(args.hypoDir, 'SCHEMA.md'), args.dryRun);
808
+ copyTemplate('hypo-guide.md', join(args.hypoDir, 'hypo-guide.md'), args.dryRun);
809
+ copyTemplate('Home.md', join(args.hypoDir, 'Home.md'), args.dryRun);
810
+ copyTemplate('Overview.md', join(args.hypoDir, 'Overview.md'), args.dryRun);
811
+ copyTemplate('hypo-help.md', join(args.hypoDir, 'hypo-help.md'), args.dryRun);
812
+ copyTemplate('hypo-automation.md', join(args.hypoDir, 'hypo-automation.md'), args.dryRun);
813
+ copyTemplate('session-state.md', join(args.hypoDir, 'session-state.md'), args.dryRun);
584
814
  copyTemplate(join('pages', '_index.md'), join(args.hypoDir, 'pages', '_index.md'), args.dryRun);
815
+ copyTemplate(
816
+ join('pages', 'observability', '_index.md'),
817
+ join(args.hypoDir, 'pages', 'observability', '_index.md'),
818
+ args.dryRun,
819
+ );
585
820
 
586
821
  // projects/_template structure
587
822
  ensureDir(join(args.hypoDir, 'projects', '_template'), args.dryRun);
588
823
  ensureDir(join(args.hypoDir, 'projects', '_template', 'decisions'), args.dryRun);
589
824
  ensureDir(join(args.hypoDir, 'projects', '_template', 'session-log'), args.dryRun);
590
- copyTemplate(join('projects', '_template', 'hot.md'), join(args.hypoDir, 'projects', '_template', 'hot.md'), args.dryRun);
591
- copyTemplate(join('projects', '_template', 'index.md'), join(args.hypoDir, 'projects', '_template', 'index.md'), args.dryRun);
592
- copyTemplate(join('projects', '_template', 'prd.md'), join(args.hypoDir, 'projects', '_template', 'prd.md'), args.dryRun);
593
- copyTemplate(join('projects', '_template', 'session-state.md'),join(args.hypoDir, 'projects', '_template', 'session-state.md'),args.dryRun);
594
-
595
- // 3. hypo-config.md + .hypoignore
825
+ copyTemplate(
826
+ join('projects', '_template', 'hot.md'),
827
+ join(args.hypoDir, 'projects', '_template', 'hot.md'),
828
+ args.dryRun,
829
+ );
830
+ copyTemplate(
831
+ join('projects', '_template', 'index.md'),
832
+ join(args.hypoDir, 'projects', '_template', 'index.md'),
833
+ args.dryRun,
834
+ );
835
+ copyTemplate(
836
+ join('projects', '_template', 'prd.md'),
837
+ join(args.hypoDir, 'projects', '_template', 'prd.md'),
838
+ args.dryRun,
839
+ );
840
+ copyTemplate(
841
+ join('projects', '_template', 'session-state.md'),
842
+ join(args.hypoDir, 'projects', '_template', 'session-state.md'),
843
+ args.dryRun,
844
+ );
845
+
846
+ // 3. hypo-config.md + .hypoignore + .gitignore
596
847
  writeHypoConfig(args.hypoDir, args.dryRun);
597
848
  writeWikiignore(args.hypoDir, args.dryRun);
849
+ writeGitignore(args.hypoDir, args.dryRun);
598
850
  }
599
851
 
600
852
  // 4. hooks
@@ -615,6 +867,40 @@ if (args.hooks || args.commands) {
615
867
  writePkgJson(args.dryRun, commandSHAs ? { commands: commandSHAs } : {});
616
868
  }
617
869
 
870
+ // 4b. user extensions companion sync (ADR 0024). Runs after
871
+ // writePkgJson so the per-target SHA map is merged into the same hypo-pkg.json
872
+ // (preserving the commands map) rather than racing it.
873
+ if (args.hooks) {
874
+ const extResult = syncExtensions({
875
+ extDir: join(args.hypoDir, 'extensions'),
876
+ hypoDir: args.hypoDir,
877
+ target: 'claude',
878
+ settingsPath: join(HOME, '.claude', 'settings.json'),
879
+ pkgPath: pkgJsonPath(),
880
+ apply: !args.dryRun,
881
+ force: args.forceExtensions,
882
+ });
883
+ for (const a of extResult.actions) {
884
+ if (a.action === 'create' || a.action === 'update' || a.action === 'force-update') {
885
+ log('created', `extension ${a.file} (${a.action})`);
886
+ }
887
+ }
888
+ for (const r of extResult.registered) log('merged', `extension ${r}`);
889
+ for (const w of extResult.warnings) log('skipped', `extension: ${w}`);
890
+ // E3 (#31): a hard conflict (unowned/symlinked target) blocks install — surface
891
+ // the recovery and force a non-zero exit. Drift is advisory (resolvable, no block).
892
+ if (extResult.conflicts.length > 0) {
893
+ log('errors', '[WIKI: existing file conflicts. Backup and retry, or use --force-extensions]');
894
+ for (const c of extResult.conflicts) log('errors', `extension ${c.file} (${c.action})`);
895
+ }
896
+ for (const d of extResult.drifts) {
897
+ log(
898
+ 'skipped',
899
+ `[WIKI: extension ${d.name} drift detected. Use --force-extensions to overwrite.]`,
900
+ );
901
+ }
902
+ }
903
+
618
904
  // 5. shell function (claude wrapper)
619
905
  if (args.shellSetup) {
620
906
  const shellConfigPath = detectShellConfig(args.shellConfig);
@@ -626,6 +912,37 @@ if (args.codex) {
626
912
  const codexHooks = join(HOME, '.codex', 'hooks');
627
913
  installHooks(codexHooks, args.dryRun);
628
914
  mergeSettingsJson(join(HOME, '.codex', 'settings.json'), codexHooks, args.dryRun, HOOK_MAP);
915
+
916
+ // 6b. user extensions companion → codex (E4, #32). Mirrors the claude sync above
917
+ // for the codex target: hooks + commands only (skills/agents are skipped with a
918
+ // notice). The per-target SHA map merges into the same ~/.claude/hypo-pkg.json
919
+ // under extensions.codex, alongside extensions.claude written in step 4b.
920
+ const extCodex = syncExtensions({
921
+ extDir: join(args.hypoDir, 'extensions'),
922
+ hypoDir: args.hypoDir,
923
+ target: 'codex',
924
+ settingsPath: join(HOME, '.codex', 'settings.json'),
925
+ pkgPath: pkgJsonPath(),
926
+ apply: !args.dryRun,
927
+ force: args.forceExtensions,
928
+ });
929
+ for (const a of extCodex.actions) {
930
+ if (a.action === 'create' || a.action === 'update' || a.action === 'force-update') {
931
+ log('created', `codex extension ${a.file} (${a.action})`);
932
+ }
933
+ }
934
+ for (const r of extCodex.registered) log('merged', `codex extension ${r}`);
935
+ for (const w of extCodex.warnings) log('skipped', `codex extension: ${w}`);
936
+ if (extCodex.conflicts.length > 0) {
937
+ log('errors', '[WIKI: existing file conflicts. Backup and retry, or use --force-extensions]');
938
+ for (const c of extCodex.conflicts) log('errors', `codex extension ${c.file} (${c.action})`);
939
+ }
940
+ for (const d of extCodex.drifts) {
941
+ log(
942
+ 'skipped',
943
+ `[WIKI: extension ${d.name} drift detected. Use --force-extensions to overwrite.]`,
944
+ );
945
+ }
629
946
  }
630
947
 
631
948
  // 7. git setup (skip when cloned from remote — already has .git + remote)
@@ -638,6 +955,9 @@ if (args.hooks) {
638
955
  installPkgGitHook(args.dryRun);
639
956
  }
640
957
 
958
+ // 8b. wiki pre-commit hook (.hypoignore last-line-of-defence guard — §6.8)
959
+ installWikiPreCommitHook(args.hypoDir, args.dryRun, args.forceCommands);
960
+
641
961
  // 9. first commit + push (skip when cloned from remote — already has commits)
642
962
  if (args.gitInit && !args.fromRemote) {
643
963
  firstCommit(args.hypoDir, args.gitRemote, args.dryRun);
@@ -646,10 +966,18 @@ if (args.gitInit && !args.fromRemote) {
646
966
  // ── report ───────────────────────────────────────────────────────────────────
647
967
 
648
968
  const lines = [];
649
- if (results.created.length) lines.push(`✓ Created (${results.created.length}):\n${results.created.map(p => ` ${p}`).join('\n')}`);
650
- if (results.skipped.length) lines.push(`⊘ Skipped / already exists (${results.skipped.length}):\n${results.skipped.map(p => ` ${p}`).join('\n')}`);
651
- if (results.merged.length) lines.push(`↪ Merged into settings.json:\n${results.merged.map(p => ` ${p}`).join('\n')}`);
652
- if (results.errors.length) lines.push(`✗ Errors:\n${results.errors.map(p => ` ${p}`).join('\n')}`);
969
+ if (results.created.length)
970
+ lines.push(
971
+ `✓ Created (${results.created.length}):\n${results.created.map((p) => ` ${p}`).join('\n')}`,
972
+ );
973
+ if (results.skipped.length)
974
+ lines.push(
975
+ `⊘ Skipped / already exists (${results.skipped.length}):\n${results.skipped.map((p) => ` ${p}`).join('\n')}`,
976
+ );
977
+ if (results.merged.length)
978
+ lines.push(`↪ Merged into settings.json:\n${results.merged.map((p) => ` ${p}`).join('\n')}`);
979
+ if (results.errors.length)
980
+ lines.push(`✗ Errors:\n${results.errors.map((p) => ` ${p}`).join('\n')}`);
653
981
 
654
982
  if (args.dryRun) lines.unshift('[DRY RUN — no changes made]');
655
983