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