hypomnema 1.8.2 → 1.9.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/CHANGELOG.md +141 -51
- package/README.ko.md +18 -17
- package/README.md +16 -15
- package/commands/crystallize.md +89 -24
- package/commands/doctor.md +3 -1
- package/docs/ARCHITECTURE.md +45 -14
- package/docs/CONTRIBUTING.md +26 -8
- package/hooks/atomic-write.mjs +44 -0
- package/hooks/base-store.mjs +409 -90
- package/hooks/close-gate-store.mjs +131 -16
- package/hooks/close-journal.mjs +250 -0
- package/hooks/close-receipt.mjs +559 -0
- package/hooks/hooks.json +3 -38
- package/hooks/hypo-auto-commit.mjs +80 -10
- package/hooks/hypo-auto-minimal-crystallize.mjs +129 -25
- package/hooks/hypo-auto-stage.mjs +13 -1
- package/hooks/hypo-close-guard.mjs +2 -1
- package/hooks/hypo-cwd-change.mjs +45 -20
- package/hooks/hypo-file-watch.mjs +44 -6
- package/hooks/hypo-first-prompt.mjs +15 -9
- package/hooks/hypo-hot-rebuild.mjs +169 -112
- package/hooks/hypo-personal-check.mjs +9 -1
- package/hooks/hypo-session-record.mjs +1 -1
- package/hooks/hypo-session-start.mjs +566 -20
- package/hooks/hypo-shared.mjs +2250 -206
- package/hooks/hypo-stop.mjs +207 -0
- package/hooks/proposal-store.mjs +201 -35
- package/hooks/shared.json +15 -0
- package/hooks/version-check.mjs +9 -7
- package/package.json +3 -1
- package/scripts/crystallize.mjs +3 -4
- package/scripts/doctor.mjs +549 -123
- package/scripts/init.mjs +86 -109
- package/scripts/lib/core-hooks.mjs +48 -22
- package/scripts/lib/crystallize-close-apply.mjs +2397 -188
- package/scripts/lib/crystallize-close-check.mjs +43 -23
- package/scripts/lib/design-history-stale.mjs +36 -9
- package/scripts/lib/frontmatter.mjs +39 -0
- package/scripts/lib/git-hooks-dir.mjs +427 -52
- package/scripts/lib/hook-inventory.mjs +180 -0
- package/scripts/lib/page-usage.mjs +1 -1
- package/scripts/lib/pkg-provenance.mjs +18 -1
- package/scripts/lib/plugin-detect.mjs +43 -9
- package/scripts/lib/project-create.mjs +40 -44
- package/scripts/lib/schema-vocab.mjs +22 -11
- package/scripts/lib/template-schema-version.mjs +48 -0
- package/scripts/lib/time.mjs +46 -0
- package/scripts/lint.mjs +66 -18
- package/scripts/proposal.mjs +423 -18
- package/scripts/resume.mjs +3 -2
- package/scripts/session-audit.mjs +2 -1
- package/scripts/uninstall.mjs +144 -66
- package/scripts/upgrade.mjs +523 -234
- package/scripts/verify.mjs +2 -1
- package/scripts/weekly-report.mjs +2 -1
- package/templates/SCHEMA.md +27 -7
- package/templates/gitignore +11 -0
- package/templates/hypo-automation.md +10 -13
- package/templates/hypo-config.md +1 -1
- package/templates/hypo-guide.md +53 -25
- package/templates/hot.md +0 -24
package/scripts/upgrade.mjs
CHANGED
|
@@ -42,8 +42,9 @@ import { homedir } from 'os';
|
|
|
42
42
|
import { fileURLToPath } from 'url';
|
|
43
43
|
import { createHash } from 'crypto';
|
|
44
44
|
import { resolveHypoRoot, expandHome } from './lib/hypo-root.mjs';
|
|
45
|
+
import { loadHookInventory } from './lib/hook-inventory.mjs';
|
|
45
46
|
import { parseFrontmatter } from './lib/frontmatter.mjs';
|
|
46
|
-
import { templateSchemaVersion } from './lib/template-schema-version.mjs';
|
|
47
|
+
import { templateSchemaVersion, schemaVersionDeltas } from './lib/template-schema-version.mjs';
|
|
47
48
|
import {
|
|
48
49
|
readPkgJson as readPkgJsonSafe,
|
|
49
50
|
writePkgJsonAtomic,
|
|
@@ -54,15 +55,18 @@ import {
|
|
|
54
55
|
writeDualSkipProvenance,
|
|
55
56
|
} from './lib/pkg-json.mjs';
|
|
56
57
|
import { syncExtensions } from './lib/extensions.mjs';
|
|
57
|
-
import { writeProvenanceSidecar } from './lib/pkg-provenance.mjs';
|
|
58
|
+
import { writeProvenanceSidecar, readProvenanceSidecar } from './lib/pkg-provenance.mjs';
|
|
58
59
|
import { resolvePluginChannel } from './lib/plugin-detect.mjs';
|
|
59
60
|
import {
|
|
60
61
|
resolveGitHooksDir,
|
|
61
62
|
unsafeHookTargetReason,
|
|
62
63
|
parseWikiPreCommitRoot,
|
|
63
64
|
wikiPreCommitContent,
|
|
65
|
+
oldFormPreCommitContent,
|
|
66
|
+
uniqueBakPath,
|
|
64
67
|
} from './lib/git-hooks-dir.mjs';
|
|
65
68
|
import { classifyInstall, downgradeGuardMessage } from '../hooks/version-check.mjs';
|
|
69
|
+
import { atomicWrite } from '../hooks/atomic-write.mjs';
|
|
66
70
|
|
|
67
71
|
const HOME = homedir();
|
|
68
72
|
const SCRIPT_DIR = fileURLToPath(new URL('.', import.meta.url));
|
|
@@ -73,6 +77,14 @@ const PKG_ROOT = join(SCRIPT_DIR, '..');
|
|
|
73
77
|
// so the only useful next step is a re-install of the package.
|
|
74
78
|
const PKG_INTEGRITY_HINT =
|
|
75
79
|
'→ This indicates a corrupt or incomplete install. Re-install with `npm install -g hypomnema` (or re-install the Claude Code plugin).';
|
|
80
|
+
// Where to send a person to verify after --apply. Named both ways, with no
|
|
81
|
+
// runtime guess: the `hypomnema` bin comes only with the npm install, so a
|
|
82
|
+
// plugin-only user has `/hypo:doctor` and nothing else, and a shell user with
|
|
83
|
+
// the npm CLI may have no slash-command surface at all. Same convention
|
|
84
|
+
// scripts/lib/crystallize-close-apply.mjs's own DOCTOR_HINT already uses for
|
|
85
|
+
// the same reason; kept as a separate local constant here rather than an
|
|
86
|
+
// import, since that file is not this task's to change.
|
|
87
|
+
const DOCTOR_HINT = '`/hypo:doctor` in Claude Code, or `hypomnema doctor` with the npm CLI';
|
|
76
88
|
const HOOKS_SRC = join(PKG_ROOT, 'hooks');
|
|
77
89
|
const COMMANDS_SRC = join(PKG_ROOT, 'commands');
|
|
78
90
|
const TEMPLATES = join(PKG_ROOT, 'templates');
|
|
@@ -132,94 +144,24 @@ function bumpType(installed, current) {
|
|
|
132
144
|
}
|
|
133
145
|
|
|
134
146
|
// ── hook map (loaded from hooks/hooks.json — single source of truth) ─────────
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
console.error(
|
|
147
|
-
process.exit(1);
|
|
148
|
-
}
|
|
149
|
-
if (
|
|
150
|
-
!_hookConfig.hooks ||
|
|
151
|
-
typeof _hookConfig.hooks !== 'object' ||
|
|
152
|
-
Array.isArray(_hookConfig.hooks)
|
|
153
|
-
) {
|
|
154
|
-
console.error('Error: hooks/hooks.json must contain a "hooks" object');
|
|
155
|
-
console.error(PKG_INTEGRITY_HINT);
|
|
156
|
-
process.exit(1);
|
|
157
|
-
}
|
|
158
|
-
function _extractCommandFileName(command) {
|
|
159
|
-
if (typeof command !== 'string') return null;
|
|
160
|
-
const matches = [...command.matchAll(/(?:^|[\/\\])([^\/\\\s"'`]+\.mjs)(?=$|[\s"'`])/g)];
|
|
161
|
-
if (matches.length > 0) return matches[matches.length - 1][1];
|
|
162
|
-
const bare = command.match(/(?:^|\s)([^\/\\\s"'`]+\.mjs)(?=$|[\s"'`])/);
|
|
163
|
-
return bare ? bare[1] : null;
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
function _isHookFileName(file) {
|
|
167
|
-
return typeof file === 'string' && /^[^/\\\s]+\.mjs$/.test(file.trim());
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
function _isHookGroup(group) {
|
|
171
|
-
return (
|
|
172
|
-
group &&
|
|
173
|
-
typeof group === 'object' &&
|
|
174
|
-
!Array.isArray(group) &&
|
|
175
|
-
Array.isArray(group.hooks) &&
|
|
176
|
-
group.hooks.length > 0 &&
|
|
177
|
-
group.hooks.every(
|
|
178
|
-
(hook) =>
|
|
179
|
-
hook &&
|
|
180
|
-
typeof hook === 'object' &&
|
|
181
|
-
!Array.isArray(hook) &&
|
|
182
|
-
hook.type === 'command' &&
|
|
183
|
-
_extractCommandFileName(hook.command),
|
|
184
|
-
)
|
|
185
|
-
);
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
// Extract .mjs file names from both old format (string[]) and new format (hook-group object[])
|
|
189
|
-
function _extractFileNames(groups) {
|
|
190
|
-
return groups.flatMap((group) => {
|
|
191
|
-
if (typeof group === 'string') return [group.trim()];
|
|
192
|
-
return group.hooks.map((hook) => _extractCommandFileName(hook.command));
|
|
193
|
-
});
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
for (const [event, groups] of Object.entries(_hookConfig.hooks)) {
|
|
197
|
-
const valid =
|
|
198
|
-
Array.isArray(groups) &&
|
|
199
|
-
groups.length > 0 &&
|
|
200
|
-
groups.every((group) => _isHookFileName(group) || _isHookGroup(group)) &&
|
|
201
|
-
_extractFileNames(groups).length > 0;
|
|
202
|
-
if (!valid) {
|
|
203
|
-
console.error(
|
|
204
|
-
`Error: hooks/hooks.json "hooks.${event}" must be a non-empty array of .mjs file names or Claude hook groups`,
|
|
205
|
-
);
|
|
206
|
-
console.error(PKG_INTEGRITY_HINT);
|
|
207
|
-
process.exit(1);
|
|
208
|
-
}
|
|
209
|
-
}
|
|
210
|
-
if (
|
|
211
|
-
_hookConfig.shared !== undefined &&
|
|
212
|
-
(!Array.isArray(_hookConfig.shared) || !_hookConfig.shared.every((f) => _isHookFileName(f)))
|
|
213
|
-
) {
|
|
214
|
-
console.error('Error: hooks/hooks.json "shared" must be an array of .mjs file names');
|
|
147
|
+
//
|
|
148
|
+
// loadHookInventory (scripts/lib/hook-inventory.mjs) is the one parser every
|
|
149
|
+
// hooks.json/shared.json consumer reads through — smoke-plugin, init, doctor,
|
|
150
|
+
// and uninstall all call the same function, so a command shape one of them
|
|
151
|
+
// would accept and another would silently misread cannot exist anymore. It also
|
|
152
|
+
// existence-checks every named file against THIS package's own hooks/ before
|
|
153
|
+
// returning ok:true, so a corrupt or incomplete package (a hook file dropped
|
|
154
|
+
// from `package.json`'s `files` allowlist) fails here, at startup, instead of
|
|
155
|
+
// upgrade reporting some files "src-missing" while still applying the rest.
|
|
156
|
+
const _hookInventory = loadHookInventory(PKG_ROOT);
|
|
157
|
+
if (!_hookInventory.ok) {
|
|
158
|
+
console.error(`Error: ${_hookInventory.error}`);
|
|
215
159
|
console.error(PKG_INTEGRITY_HINT);
|
|
216
160
|
process.exit(1);
|
|
217
161
|
}
|
|
218
|
-
|
|
219
|
-
const
|
|
220
|
-
|
|
221
|
-
);
|
|
222
|
-
const SHARED_FILES = _hookConfig.shared ?? [];
|
|
162
|
+
const HOOK_MAP = _hookInventory.hookMap;
|
|
163
|
+
const SHARED_FILES = _hookInventory.shared;
|
|
164
|
+
const INSTALL_ORDER = _hookInventory.ordered;
|
|
223
165
|
|
|
224
166
|
// ── checks ───────────────────────────────────────────────────────────────────
|
|
225
167
|
|
|
@@ -281,7 +223,16 @@ function checkGuideVersion(hypoDir) {
|
|
|
281
223
|
function checkHookFiles(hooksDir) {
|
|
282
224
|
const results = [];
|
|
283
225
|
|
|
284
|
-
|
|
226
|
+
// The canonical order, shared by init and (reversed) by uninstall. Shared
|
|
227
|
+
// modules come first because applyHookFiles copies one file at a time and an
|
|
228
|
+
// entry hook imports its shared modules the moment the next event fires it:
|
|
229
|
+
// copy the entry hook first, get interrupted, and the next SessionStart dies
|
|
230
|
+
// with ERR_MODULE_NOT_FOUND, which is also the session the repair would have
|
|
231
|
+
// been run from.
|
|
232
|
+
//
|
|
233
|
+
// This is ordering, not atomicity. A half-copied set is still a mix of
|
|
234
|
+
// versions; what it is not is a set that cannot load.
|
|
235
|
+
const allFiles = INSTALL_ORDER;
|
|
285
236
|
for (const file of allFiles) {
|
|
286
237
|
const installedPath = join(hooksDir, file);
|
|
287
238
|
const srcPath = join(HOOKS_SRC, file);
|
|
@@ -333,6 +284,51 @@ function checkSettingsJson(settingsPath, hooksDir) {
|
|
|
333
284
|
return results;
|
|
334
285
|
}
|
|
335
286
|
|
|
287
|
+
// Every .mjs basename this package still ships, entry hook or shared module.
|
|
288
|
+
// checkRetiredRegistrations uses it as the line between a registration we wrote
|
|
289
|
+
// and have since withdrawn, and a hook somebody else put in the same directory.
|
|
290
|
+
const SHIPPED_HOOK_FILES = new Set(INSTALL_ORDER);
|
|
291
|
+
|
|
292
|
+
// The reverse of checkSettingsJson. That walk asks whether every live
|
|
293
|
+
// HOOK_MAP entry is registered; it never asks whether a registration is still
|
|
294
|
+
// live, so a settings.json install that predates a hook moving out of an event
|
|
295
|
+
// keeps the old entry forever. The Stop orchestrator made that concrete: four
|
|
296
|
+
// Stop hooks became steps that hypo-stop.mjs spawns, and an install that kept
|
|
297
|
+
// their old Stop entries ran each of them twice per Stop.
|
|
298
|
+
//
|
|
299
|
+
// A registration is retired only when all three hold:
|
|
300
|
+
// - its command is exactly `node <hooksDir>/<file>` in the `$HOME` form
|
|
301
|
+
// init and checkSettingsJson write (no arguments, no subdirectory);
|
|
302
|
+
// - `<file>` is a basename this package still ships (SHIPPED_HOOK_FILES);
|
|
303
|
+
// - HOOK_MAP[event] no longer lists `<file>`.
|
|
304
|
+
// A hook this package never shipped, a hypo-ext-* extension, and anything with
|
|
305
|
+
// a different command shape fail the first two tests and are left alone.
|
|
306
|
+
function checkRetiredRegistrations(settingsPath, hooksDir) {
|
|
307
|
+
if (!existsSync(settingsPath)) return [];
|
|
308
|
+
let settings;
|
|
309
|
+
try {
|
|
310
|
+
settings = JSON.parse(readFileSync(settingsPath, 'utf-8'));
|
|
311
|
+
} catch {
|
|
312
|
+
return [];
|
|
313
|
+
}
|
|
314
|
+
const prefix = `node ${hooksDir.replace(HOME, '$HOME')}/`;
|
|
315
|
+
const found = [];
|
|
316
|
+
for (const [event, groups] of Object.entries(settings.hooks || {})) {
|
|
317
|
+
if (!Array.isArray(groups)) continue;
|
|
318
|
+
const live = new Set(HOOK_MAP[event] || []);
|
|
319
|
+
for (const group of groups) {
|
|
320
|
+
if (!group || typeof group !== 'object' || !Array.isArray(group.hooks)) continue;
|
|
321
|
+
for (const hook of group.hooks) {
|
|
322
|
+
const cmd = hook?.command;
|
|
323
|
+
if (typeof cmd !== 'string' || !cmd.startsWith(prefix)) continue;
|
|
324
|
+
const file = cmd.slice(prefix.length);
|
|
325
|
+
if (SHIPPED_HOOK_FILES.has(file) && !live.has(file)) found.push({ event, file, cmd });
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
return found;
|
|
330
|
+
}
|
|
331
|
+
|
|
336
332
|
// ── apply actions ────────────────────────────────────────────────────────────
|
|
337
333
|
|
|
338
334
|
// `hooksDir` is the target the stale/missing paths in `hookResults` already point
|
|
@@ -351,41 +347,162 @@ function applyHookFiles(hookResults, hooksDir) {
|
|
|
351
347
|
return applied;
|
|
352
348
|
}
|
|
353
349
|
|
|
354
|
-
|
|
350
|
+
// ── settings.json apply: rename + add + retire as one atomic write ──────────
|
|
351
|
+
//
|
|
352
|
+
// These three used to be three separate `writeFileSync` calls (rename below,
|
|
353
|
+
// then this file's former applySettingsJson, then its former
|
|
354
|
+
// applyRetiredRegistrations), run one after another against the same file. A
|
|
355
|
+
// crash mid-write could truncate the JSON; a crash BETWEEN any two of them
|
|
356
|
+
// left VALID JSON that was only half-migrated. One example: a legacy Stop
|
|
357
|
+
// entry renamed to its new basename with nothing left to retire it, so both
|
|
358
|
+
// hypo-stop.mjs (the orchestrator) and the step it replaced fired on every
|
|
359
|
+
// Stop. The retire step made that window concrete when it was added: it is
|
|
360
|
+
// the step most likely to be skipped by exactly this kind of interruption.
|
|
361
|
+
//
|
|
362
|
+
// The fix is not three atomic writes in a row (that still leaves the
|
|
363
|
+
// between-writes window) but one in-memory transform committed once. Each
|
|
364
|
+
// step below reads the value the step before it just produced, never a
|
|
365
|
+
// precomputed check-time snapshot. That is what keeps the self-heal
|
|
366
|
+
// applySettingsJson used to need a fresh disk re-read for: a legacy
|
|
367
|
+
// wiki-auto-commit.mjs entry the rename step just turned into
|
|
368
|
+
// hypo-auto-commit.mjs is visible to the retire step as exactly that name,
|
|
369
|
+
// in the same pass, because it is reading the same object the rename step
|
|
370
|
+
// wrote into.
|
|
371
|
+
|
|
372
|
+
function migrateHookNamesInSettings(settings) {
|
|
373
|
+
const applied = [];
|
|
374
|
+
for (const [event, groups] of Object.entries(settings.hooks || {})) {
|
|
375
|
+
for (const group of Array.isArray(groups) ? groups : []) {
|
|
376
|
+
for (const hook of group.hooks || []) {
|
|
377
|
+
for (const [oldName, newName] of Object.entries(HOOK_RENAMES)) {
|
|
378
|
+
if ((hook.command || '').includes(oldName)) {
|
|
379
|
+
hook.command = hook.command.replace(oldName, newName);
|
|
380
|
+
applied.push(`${event}: ${oldName} → ${newName}`);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
return applied;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Copies a renamed hook's bytes onto its new basename when the old file is
|
|
390
|
+
// still on disk and the new one is not yet there. A file-system side effect,
|
|
391
|
+
// not a settings.json write, so it stays separate from the atomic commit
|
|
392
|
+
// below; existsSync guards make repeat runs a no-op.
|
|
393
|
+
function copyRenamedHookFiles(hooksDir) {
|
|
394
|
+
for (const [oldName, newName] of Object.entries(HOOK_RENAMES)) {
|
|
395
|
+
const oldPath = join(hooksDir, oldName);
|
|
396
|
+
const newPath = join(hooksDir, newName);
|
|
397
|
+
const srcPath = join(HOOKS_SRC, newName);
|
|
398
|
+
if (existsSync(oldPath) && !existsSync(newPath) && existsSync(srcPath)) {
|
|
399
|
+
copyFileSync(srcPath, newPath);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// Adds every HOOK_MAP entry `settings` does not yet register, judged against
|
|
405
|
+
// `settings` itself (already carrying whatever the rename step above just
|
|
406
|
+
// rewrote) rather than a precomputed checkSettingsJson() list. A name the
|
|
407
|
+
// rename just produced reads as already-present here, in the same pass.
|
|
408
|
+
function addMissingRegistrations(settings, hooksDir) {
|
|
409
|
+
if (!settings.hooks) settings.hooks = {};
|
|
410
|
+
const applied = [];
|
|
411
|
+
for (const [event, files] of Object.entries(HOOK_MAP)) {
|
|
412
|
+
if (!Array.isArray(settings.hooks[event])) settings.hooks[event] = [];
|
|
413
|
+
for (const file of files) {
|
|
414
|
+
const cmd = `node ${hooksDir.replace(HOME, '$HOME')}/${file}`;
|
|
415
|
+
const alreadyPresent = settings.hooks[event]
|
|
416
|
+
.flatMap((g) => g.hooks || [])
|
|
417
|
+
.some((h) => h.command === cmd);
|
|
418
|
+
if (alreadyPresent) continue;
|
|
419
|
+
settings.hooks[event].push({ hooks: [{ type: 'command', command: cmd }] });
|
|
420
|
+
applied.push(`${event}: ${file}`);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
return applied;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// The reverse of the add step above, judged the same way: against `settings`
|
|
427
|
+
// as the add step just left it, never a precomputed checkRetiredRegistrations()
|
|
428
|
+
// list computed before the rename ran. A registration is retired only when
|
|
429
|
+
// all three hold, mirroring checkRetiredRegistrations's contract:
|
|
430
|
+
// - its command is exactly `node <hooksDir>/<file>` in the `$HOME` form
|
|
431
|
+
// init and checkSettingsJson write (no arguments, no subdirectory);
|
|
432
|
+
// - `<file>` is a basename this package still ships (SHIPPED_HOOK_FILES);
|
|
433
|
+
// - HOOK_MAP[event] no longer lists `<file>`.
|
|
434
|
+
// This is why a legacy wiki-auto-commit.mjs entry the rename step turns into
|
|
435
|
+
// hypo-auto-commit.mjs is still caught here even though hypo-auto-commit.mjs
|
|
436
|
+
// never appeared in any precomputed retired list: this function never reads
|
|
437
|
+
// one, only the settings the two steps above already produced. Only the
|
|
438
|
+
// matching hook entries go; a group loses its whole entry only when nothing
|
|
439
|
+
// else is left in it, and other keys on the group (matcher) stay with what
|
|
440
|
+
// remains.
|
|
441
|
+
function removeRetiredRegistrations(settings, hooksDir) {
|
|
442
|
+
const prefix = `node ${hooksDir.replace(HOME, '$HOME')}/`;
|
|
443
|
+
const retired = [];
|
|
444
|
+
for (const [event, groups] of Object.entries(settings.hooks || {})) {
|
|
445
|
+
if (!Array.isArray(groups)) continue;
|
|
446
|
+
const live = new Set(HOOK_MAP[event] || []);
|
|
447
|
+
for (const group of groups) {
|
|
448
|
+
if (!group || typeof group !== 'object' || !Array.isArray(group.hooks)) continue;
|
|
449
|
+
for (const hook of group.hooks) {
|
|
450
|
+
const cmd = hook?.command;
|
|
451
|
+
if (typeof cmd !== 'string' || !cmd.startsWith(prefix)) continue;
|
|
452
|
+
const file = cmd.slice(prefix.length);
|
|
453
|
+
if (SHIPPED_HOOK_FILES.has(file) && !live.has(file)) retired.push({ event, file, cmd });
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
if (retired.length === 0) return [];
|
|
458
|
+
const doomed = new Set(retired.map((r) => `${r.event}\n${r.cmd}`));
|
|
459
|
+
for (const [event, groups] of Object.entries(settings.hooks)) {
|
|
460
|
+
if (!Array.isArray(groups)) continue;
|
|
461
|
+
const kept = [];
|
|
462
|
+
for (const group of groups) {
|
|
463
|
+
if (group && typeof group === 'object' && Array.isArray(group.hooks)) {
|
|
464
|
+
const before = group.hooks.length;
|
|
465
|
+
group.hooks = group.hooks.filter((h) => !doomed.has(`${event}\n${h?.command}`));
|
|
466
|
+
if (before > 0 && group.hooks.length === 0) continue;
|
|
467
|
+
}
|
|
468
|
+
kept.push(group);
|
|
469
|
+
}
|
|
470
|
+
if (kept.length === 0) delete settings.hooks[event];
|
|
471
|
+
else settings.hooks[event] = kept;
|
|
472
|
+
}
|
|
473
|
+
return retired.map((r) => `${r.event}: ${r.file}`);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// The one place settings.json is mutated by --apply: rename, add, and retire
|
|
477
|
+
// merged into a single in-memory transform, committed with exactly one
|
|
478
|
+
// atomicWrite (temp file plus rename, see hooks/atomic-write.mjs). So there is
|
|
479
|
+
// no window where the file on disk reflects only some of the three, and a
|
|
480
|
+
// crash mid-write lands on a throwaway temp name instead of a torn
|
|
481
|
+
// settings.json. Known gap this does not close: no lock guards settingsPath,
|
|
482
|
+
// so a user hand-editing it in another program at the same moment can still
|
|
483
|
+
// have their edit overwritten by whichever write lands last. That is the same
|
|
484
|
+
// exposure the old three-write version had, just narrowed from three windows
|
|
485
|
+
// to one.
|
|
486
|
+
function applySettingsJsonTransform(settingsPath, hooksDir) {
|
|
355
487
|
let settings = {};
|
|
356
488
|
if (existsSync(settingsPath)) {
|
|
357
489
|
try {
|
|
358
490
|
settings = JSON.parse(readFileSync(settingsPath, 'utf-8'));
|
|
359
491
|
} catch {
|
|
360
|
-
return [];
|
|
492
|
+
return { renamed: [], added: [], retired: [] };
|
|
361
493
|
}
|
|
362
494
|
}
|
|
363
495
|
if (!settings.hooks) settings.hooks = {};
|
|
364
496
|
|
|
365
|
-
const
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
if (!Array.isArray(settings.hooks[s.event])) settings.hooks[s.event] = [];
|
|
369
|
-
// re-check the current parsed settings before appending.
|
|
370
|
-
// applyHookNameMigration may have rewritten a legacy wiki-*.mjs command to
|
|
371
|
-
// exactly `s.cmd` between checkSettingsJson and now — appending without
|
|
372
|
-
// this guard creates a duplicate registration (codex 2-worker review
|
|
373
|
-
// reproduced 11 duplicate hypo-*.mjs entries on a wiki-only legacy settings
|
|
374
|
-
// file). The precheck list is allowed to be stale; the apply path must
|
|
375
|
-
// self-heal against the on-disk truth.
|
|
376
|
-
const alreadyPresent = settings.hooks[s.event]
|
|
377
|
-
.flatMap((g) => g.hooks || [])
|
|
378
|
-
.some((h) => h.command === s.cmd);
|
|
379
|
-
if (alreadyPresent) continue;
|
|
380
|
-
settings.hooks[s.event].push({ hooks: [{ type: 'command', command: s.cmd }] });
|
|
381
|
-
applied.push(`${s.event}: ${s.file}`);
|
|
382
|
-
}
|
|
497
|
+
const renamed = migrateHookNamesInSettings(settings);
|
|
498
|
+
const added = addMissingRegistrations(settings, hooksDir);
|
|
499
|
+
const retired = removeRetiredRegistrations(settings, hooksDir);
|
|
383
500
|
|
|
384
|
-
if (
|
|
385
|
-
|
|
386
|
-
writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
|
|
501
|
+
if (renamed.length > 0 || added.length > 0 || retired.length > 0) {
|
|
502
|
+
atomicWrite(settingsPath, JSON.stringify(settings, null, 2) + '\n');
|
|
387
503
|
}
|
|
388
|
-
|
|
504
|
+
if (renamed.length > 0) copyRenamedHookFiles(hooksDir);
|
|
505
|
+
return { renamed, added, retired };
|
|
389
506
|
}
|
|
390
507
|
|
|
391
508
|
// Rename map: old wiki-*.mjs → new hypo-*.mjs
|
|
@@ -428,45 +545,12 @@ function checkOldHookNames(settingsPath) {
|
|
|
428
545
|
return found;
|
|
429
546
|
}
|
|
430
547
|
|
|
431
|
-
function
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
} catch {
|
|
438
|
-
return [];
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
const applied = [];
|
|
442
|
-
for (const [event, groups] of Object.entries(settings.hooks || {})) {
|
|
443
|
-
for (const group of Array.isArray(groups) ? groups : []) {
|
|
444
|
-
for (const hook of group.hooks || []) {
|
|
445
|
-
for (const [oldName, newName] of Object.entries(HOOK_RENAMES)) {
|
|
446
|
-
if ((hook.command || '').includes(oldName)) {
|
|
447
|
-
hook.command = hook.command.replace(oldName, newName);
|
|
448
|
-
applied.push(`${event}: ${oldName} → ${newName}`);
|
|
449
|
-
}
|
|
450
|
-
}
|
|
451
|
-
}
|
|
452
|
-
}
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
if (applied.length > 0) {
|
|
456
|
-
writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
|
|
457
|
-
// Copy renamed hook files to the target hooks dir (~/.claude/hooks or
|
|
458
|
-
// ~/.codex/hooks per the caller — codex mirror).
|
|
459
|
-
for (const [oldName, newName] of Object.entries(HOOK_RENAMES)) {
|
|
460
|
-
const oldPath = join(hooksDir, oldName);
|
|
461
|
-
const newPath = join(hooksDir, newName);
|
|
462
|
-
const srcPath = join(HOOKS_SRC, newName);
|
|
463
|
-
if (existsSync(oldPath) && !existsSync(newPath) && existsSync(srcPath)) {
|
|
464
|
-
copyFileSync(srcPath, newPath);
|
|
465
|
-
}
|
|
466
|
-
}
|
|
467
|
-
}
|
|
468
|
-
return applied;
|
|
469
|
-
}
|
|
548
|
+
// The write path used to live here as its own function (its own
|
|
549
|
+
// writeFileSync). It is now the rename step inside applySettingsJsonTransform
|
|
550
|
+
// above (migrateHookNamesInSettings + copyRenamedHookFiles), merged with the
|
|
551
|
+
// add and retire steps into one atomic commit. checkOldHookNames above stays:
|
|
552
|
+
// it is read-only, used for the report and for deciding whether there is
|
|
553
|
+
// anything for --apply to do.
|
|
470
554
|
|
|
471
555
|
// ── .hypoignore migration — ensure required runtime patterns are present ─────
|
|
472
556
|
//
|
|
@@ -555,17 +639,42 @@ function applyGitignoreMigration(result) {
|
|
|
555
639
|
return appended;
|
|
556
640
|
}
|
|
557
641
|
|
|
558
|
-
// ── wiki pre-commit hook:
|
|
642
|
+
// ── wiki pre-commit hook: migrate the version-pinned form to the runtime
|
|
643
|
+
// resolver ───────────────────────────────────────────────────────
|
|
644
|
+
//
|
|
645
|
+
// init.mjs used to bake an absolute install root into the vault's pre-commit
|
|
646
|
+
// hook (see the OLD form documented above PRE_COMMIT_WORKER_LINE in
|
|
647
|
+
// lib/git-hooks-dir.mjs). A plugin-channel upgrade moves PKG_ROOT to a new
|
|
648
|
+
// version directory every release, but nothing ever re-ran init afterward, so
|
|
649
|
+
// the hook kept calling whatever release happened to be current the day
|
|
650
|
+
// /hypo:init last ran — silently, since the entry-point file the hook calls is
|
|
651
|
+
// often byte-identical across versions while the module graph it pulls in
|
|
652
|
+
// from its own directory is not. wikiPreCommitContent() no longer bakes a
|
|
653
|
+
// root in at all: the hook resolves it itself, at commit time. So the fix
|
|
654
|
+
// here is not "repoint the baked root at the current install" (there is
|
|
655
|
+
// nothing left to repoint once a hook is on the new form) — it is "rewrite an
|
|
656
|
+
// OLD-form hook onto the new form", once, unconditionally. That migration
|
|
657
|
+
// does not need to know the active install root, which is why it is safe even
|
|
658
|
+
// when the registry cannot positively resolve one right now (see
|
|
659
|
+
// wikiPreCommitNeedsMigration below): the resolver embedded in the rewritten
|
|
660
|
+
// hook will look that up itself, fresh, at every future commit.
|
|
661
|
+
//
|
|
662
|
+
// Read-only: locates the hook (if any) and, only when it carries our marker in
|
|
663
|
+
// a shape isOwnedWikiPreCommitBody() recognizes (old OR new — see
|
|
664
|
+
// lib/git-hooks-dir.mjs), what parseWikiPreCommitRoot() read back from it.
|
|
559
665
|
//
|
|
560
|
-
//
|
|
561
|
-
//
|
|
562
|
-
//
|
|
563
|
-
//
|
|
564
|
-
//
|
|
565
|
-
//
|
|
566
|
-
//
|
|
567
|
-
//
|
|
568
|
-
//
|
|
666
|
+
// Returns `null` when there is nothing of ours to report (no hook, or a hook
|
|
667
|
+
// with no Hypomnema marker at all: some other tool's or the user's own).
|
|
668
|
+
// Returns `{ hookPath, unrecognized: true, root: null }` when our marker IS
|
|
669
|
+
// present but parseWikiPreCommitRoot could not read a body it recognizes: a
|
|
670
|
+
// hand-edited or corrupted hook, or (before this fix) a hook whose OLD-form
|
|
671
|
+
// worker line named an install root that no longer exists. That case used to
|
|
672
|
+
// collapse into the same `null` a genuinely foreign hook gets, which made
|
|
673
|
+
// upgrade.mjs's report skip the "Wiki pre-commit" line entirely and left
|
|
674
|
+
// doctor.mjs reporting the marker-present pass with no root warning beside
|
|
675
|
+
// it: a broken hook (MODULE_NOT_FOUND on every commit) reads as installed
|
|
676
|
+
// and healthy either way. `root: null` here means the same thing it does in
|
|
677
|
+
// applyWikiPreCommitRoot's caller: never attempt the migration write.
|
|
569
678
|
function checkWikiPreCommitRoot(hypoDir) {
|
|
570
679
|
const resolved = resolveGitHooksDir(hypoDir);
|
|
571
680
|
if (!resolved.ok || !resolved.owned) return null;
|
|
@@ -578,50 +687,105 @@ function checkWikiPreCommitRoot(hypoDir) {
|
|
|
578
687
|
return null;
|
|
579
688
|
}
|
|
580
689
|
const parsed = parseWikiPreCommitRoot(content);
|
|
581
|
-
if (!parsed.ok)
|
|
690
|
+
if (!parsed.ok) {
|
|
691
|
+
return parsed.hasMarker ? { hookPath, unrecognized: true, root: null } : null;
|
|
692
|
+
}
|
|
582
693
|
return { hookPath, root: parsed.root, lintStrict: parsed.lintStrict, hypoDir: parsed.hypoDir };
|
|
583
694
|
}
|
|
584
695
|
|
|
585
|
-
// Rewrites
|
|
586
|
-
// uses so the regenerated hook is byte-identical to
|
|
587
|
-
// produce — the --lint-strict step count
|
|
588
|
-
// exactly, never collapsed to one step, or the
|
|
589
|
-
// The --hypo-dir baked into a --lint-strict hook
|
|
590
|
-
// (what parseWikiPreCommitRoot read back), never
|
|
591
|
-
// --hypo-dir: upgrade can run against a vault with a
|
|
592
|
-
// the one init baked in, and substituting the
|
|
593
|
-
// silently repoint the lint gate at an unrelated
|
|
594
|
-
// embedded value is refused rather than trusted
|
|
595
|
-
// re-resolves it against upgrade's own cwd, which is
|
|
596
|
-
// value there was meant to mean), leaving the hook
|
|
696
|
+
// Rewrites an OLD-form hook onto the runtime-resolving form, reusing the exact
|
|
697
|
+
// writer init.mjs itself uses so the regenerated hook is byte-identical to
|
|
698
|
+
// what a fresh init would produce today — the --lint-strict step count
|
|
699
|
+
// (info.lintStrict) is preserved exactly, never collapsed to one step, or the
|
|
700
|
+
// lint gate goes silently dark. The --hypo-dir baked into a --lint-strict hook
|
|
701
|
+
// is preserved from `info` (what parseWikiPreCommitRoot read back), never
|
|
702
|
+
// taken from this run's own --hypo-dir: upgrade can run against a vault with a
|
|
703
|
+
// different --hypo-dir than the one init baked in, and substituting the
|
|
704
|
+
// current run's value would silently repoint the lint gate at an unrelated
|
|
705
|
+
// directory. A non-absolute embedded value is refused rather than trusted
|
|
706
|
+
// (wikiPreCommitContent() re-resolves it against upgrade's own cwd, which is
|
|
707
|
+
// never what a relative value there was meant to mean), leaving the hook
|
|
708
|
+
// untouched for that run.
|
|
597
709
|
//
|
|
598
|
-
//
|
|
599
|
-
//
|
|
600
|
-
//
|
|
710
|
+
// `wantRoot` is accepted for call-site compatibility (the caller still
|
|
711
|
+
// computes it for the drift report and JSON output) but is otherwise unused
|
|
712
|
+
// here: the new form never bakes a root, so there is nothing to substitute it
|
|
713
|
+
// into. This only migrates the FORM. If the vault itself has actually moved,
|
|
714
|
+
// the embedded --hypo-dir is wrong too and no amount of --apply fixes that —
|
|
715
|
+
// only re-running `hypomnema init` rewrites --hypo-dir.
|
|
601
716
|
//
|
|
602
717
|
// Returns `{ ok: true }` on a successful rewrite, or `{ ok: false, reason }`
|
|
603
718
|
// naming why the write was refused or failed, so the caller can surface that
|
|
604
719
|
// reason instead of silently reporting "unchanged".
|
|
605
|
-
function applyWikiPreCommitRoot(info
|
|
720
|
+
function applyWikiPreCommitRoot(info) {
|
|
606
721
|
const unsafe = unsafeHookTargetReason(info.hookPath);
|
|
607
722
|
if (unsafe) return { ok: false, reason: unsafe };
|
|
608
723
|
if (info.lintStrict && !isAbsolute(info.hypoDir)) {
|
|
609
724
|
return {
|
|
610
725
|
ok: false,
|
|
611
|
-
reason: `embedded --hypo-dir is not absolute (${info.hypoDir}) — refusing to
|
|
726
|
+
reason: `embedded --hypo-dir is not absolute (${info.hypoDir}) — refusing to migrate`,
|
|
612
727
|
};
|
|
613
728
|
}
|
|
614
729
|
// hypoDir only affects output when lintStrict is true (see
|
|
615
730
|
// wikiPreCommitContent); the fallback here never reaches the written file.
|
|
616
731
|
const hypoDir = info.lintStrict ? info.hypoDir : '.';
|
|
617
|
-
|
|
732
|
+
|
|
733
|
+
// Require the file ON DISK to already be, byte-for-byte, the exact OLD-form
|
|
734
|
+
// shape these SAME values (info.root, hypoDir, info.lintStrict) reconstruct.
|
|
735
|
+
// isOwnedWikiPreCommitBody upstream only validated the SPAN between the
|
|
736
|
+
// markers; it says nothing about content before the start marker or after
|
|
737
|
+
// the end one. A hand-crafted file pairing a forged-but-valid span (an
|
|
738
|
+
// old-form worker line naming a root that does not currently exist — exactly
|
|
739
|
+
// what isRewritableOldFormInstallRoot's "gone root" branch accepts) with real
|
|
740
|
+
// content outside that span would otherwise be silently discarded by the
|
|
741
|
+
// whole-file write below (codex reproduction, 2026-09-11). A legitimate
|
|
742
|
+
// install this codebase ever wrote IS exactly this reconstruction — nothing
|
|
743
|
+
// outside the span but the fixed shebang line — so this never refuses a real
|
|
744
|
+
// migration; see oldFormPreCommitContent's comment in lib/git-hooks-dir.mjs.
|
|
745
|
+
let currentContent;
|
|
746
|
+
try {
|
|
747
|
+
currentContent = readFileSync(info.hookPath, 'utf-8');
|
|
748
|
+
} catch (e) {
|
|
749
|
+
return {
|
|
750
|
+
ok: false,
|
|
751
|
+
reason: `cannot read hook to verify before migrating (${e.code || e.message})`,
|
|
752
|
+
};
|
|
753
|
+
}
|
|
754
|
+
const expectedOld = oldFormPreCommitContent(info.root, hypoDir, info.lintStrict);
|
|
755
|
+
if (currentContent !== expectedOld) {
|
|
756
|
+
return {
|
|
757
|
+
ok: false,
|
|
758
|
+
reason:
|
|
759
|
+
'hook file has content outside the managed marker span, or does not exactly match ' +
|
|
760
|
+
'the expected legacy shape — refusing to overwrite the whole file. Re-run ' +
|
|
761
|
+
"`hypomnema init --force-commands` (or the plugin's `/hypo:init --force-commands`) " +
|
|
762
|
+
'to reinstall the guard by hand',
|
|
763
|
+
};
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
const newContent = wikiPreCommitContent(hypoDir, info.lintStrict);
|
|
767
|
+
|
|
768
|
+
// Safety-net backup of the exact bytes about to be replaced, mirroring
|
|
769
|
+
// init.mjs's --force-commands convention (a .bak beside the hook). Guarded
|
|
770
|
+
// through the same symlink/regular-file check as the hook itself: a
|
|
771
|
+
// pre-existing `.bak` (or `.bak.N`) that is a symlink or non-regular file
|
|
772
|
+
// must not be written through, matching init's own bak-write guard.
|
|
773
|
+
const bakPath = uniqueBakPath(info.hookPath);
|
|
774
|
+
if (!bakPath) {
|
|
775
|
+
return {
|
|
776
|
+
ok: false,
|
|
777
|
+
reason: `refusing to migrate without a safe backup path (a backup name is a symlink or not a regular file)`,
|
|
778
|
+
};
|
|
779
|
+
}
|
|
780
|
+
writeFileSync(bakPath, currentContent);
|
|
781
|
+
|
|
618
782
|
// A REFUSAL (above) is a soft skip, matching init's contract that an unsafe
|
|
619
783
|
// or unresolvable target is logged and stepped over. A real I/O failure is
|
|
620
784
|
// not that: every other apply write in this file lets the exception through,
|
|
621
785
|
// and init does the same, so swallowing EACCES here would make this one
|
|
622
|
-
//
|
|
623
|
-
// the wrong thing: a chmod that fails AFTER the write says "still
|
|
624
|
-
//
|
|
786
|
+
// migration quieter than the rest of --apply for no reason. It also reported
|
|
787
|
+
// the wrong thing: a chmod that fails AFTER the write says "still on the old
|
|
788
|
+
// form" while the file already holds the new one.
|
|
625
789
|
writeFileSync(info.hookPath, newContent);
|
|
626
790
|
chmodSync(info.hookPath, 0o755);
|
|
627
791
|
return { ok: true };
|
|
@@ -697,7 +861,7 @@ checklist below is manual:
|
|
|
697
861
|
hard-required fields above
|
|
698
862
|
- [ ] Run \`hypomnema feedback-sync --check\` to verify the MEMORY/CLAUDE
|
|
699
863
|
projection still resolves cleanly
|
|
700
|
-
- [ ] Run \`hypomnema doctor\`
|
|
864
|
+
- [ ] Run doctor to verify installation health (\`/hypo:doctor\` in Claude Code, or \`hypomnema doctor\` with the npm CLI)
|
|
701
865
|
- [ ] **Re-run \`hypomnema lint\` after backfilling — confirm 0 feedback errors
|
|
702
866
|
remain (including the conditional \`claude-learned\` fields above)**
|
|
703
867
|
|
|
@@ -732,7 +896,7 @@ remaining steps are manual:
|
|
|
732
896
|
|
|
733
897
|
- [ ] Compare your \`SCHEMA.md\` (v${fromVersion}) with the package template (v${toVersion}) and merge changes manually
|
|
734
898
|
- [ ] Re-check all \`adr\` and \`learning\` pages for new required frontmatter fields once SCHEMA is updated
|
|
735
|
-
- [ ] Run \`/hypo:doctor\`
|
|
899
|
+
- [ ] Run doctor to verify installation health (\`/hypo:doctor\` in Claude Code, or \`hypomnema doctor\` with the npm CLI)
|
|
736
900
|
`;
|
|
737
901
|
|
|
738
902
|
const content = `---
|
|
@@ -1080,12 +1244,21 @@ const dualSkipWouldCorrect =
|
|
|
1080
1244
|
// dual-install notice tells the user to remove.
|
|
1081
1245
|
const wikiHookWantRoot = hypomnemaPluginEnabled ? pluginRegistryRoot : PKG_ROOT;
|
|
1082
1246
|
const wikiPreCommitInfo = checkWikiPreCommitRoot(args.hypoDir);
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1247
|
+
// `wikiPreCommitInfo.root` is non-null only for a hook still on the
|
|
1248
|
+
// OLD, version-pinned form (see parseWikiPreCommitRoot in
|
|
1249
|
+
// lib/git-hooks-dir.mjs) — the NEW, runtime-resolving form never bakes one in,
|
|
1250
|
+
// so `root === null` there means "nothing to migrate", not "unresolved".
|
|
1251
|
+
// Migrating does not need wikiHookWantRoot at all: unlike the old repoint
|
|
1252
|
+
// (which had to pick the CORRECT root to bake in, and had to stay silent when
|
|
1253
|
+
// it could not positively resolve one — see the dual-install preserve case
|
|
1254
|
+
// below), rewriting onto the resolver form is always a strict improvement,
|
|
1255
|
+
// even when this run cannot resolve the active install itself, because the
|
|
1256
|
+
// rewritten hook resolves it fresh at every future commit instead of baking
|
|
1257
|
+
// in a guess now.
|
|
1258
|
+
const wikiPreCommitDrift = wikiPreCommitInfo !== null && wikiPreCommitInfo.root !== null;
|
|
1087
1259
|
const commands = checkCommands();
|
|
1088
1260
|
const oldHookRefs = checkOldHookNames(claudeSettingsPath);
|
|
1261
|
+
const retiredSettings = checkRetiredRegistrations(claudeSettingsPath, claudeHooksDir);
|
|
1089
1262
|
const hypoignore = checkHypoignore(args.hypoDir);
|
|
1090
1263
|
const gitignore = checkGitignore(args.hypoDir);
|
|
1091
1264
|
|
|
@@ -1095,6 +1268,9 @@ const gitignore = checkGitignore(args.hypoDir);
|
|
|
1095
1268
|
const hooksCodex = args.codex ? checkHookFiles(codexHooksDir) : null;
|
|
1096
1269
|
const settingsCodex = args.codex ? checkSettingsJson(codexSettingsPath, codexHooksDir) : null;
|
|
1097
1270
|
const oldHookRefsCodex = args.codex ? checkOldHookNames(codexSettingsPath) : null;
|
|
1271
|
+
const retiredSettingsCodex = args.codex
|
|
1272
|
+
? checkRetiredRegistrations(codexSettingsPath, codexHooksDir)
|
|
1273
|
+
: null;
|
|
1098
1274
|
|
|
1099
1275
|
// Extensions companion. Read-only check; the apply
|
|
1100
1276
|
// happens below, AFTER applyCommands, so the per-target SHA map merges into the
|
|
@@ -1198,6 +1374,8 @@ let appliedHookNameRenames = [];
|
|
|
1198
1374
|
let appliedHooksCodex = [];
|
|
1199
1375
|
let appliedSettingsCodex = [];
|
|
1200
1376
|
let appliedHookNameRenamesCodex = [];
|
|
1377
|
+
let appliedRetiredSettings = [];
|
|
1378
|
+
let appliedRetiredSettingsCodex = [];
|
|
1201
1379
|
let appliedCommands = [];
|
|
1202
1380
|
let appliedHypoignore = [];
|
|
1203
1381
|
let appliedGitignore = [];
|
|
@@ -1209,28 +1387,79 @@ let appliedExtensionsCodex = null;
|
|
|
1209
1387
|
if (args.apply) {
|
|
1210
1388
|
// Downgrade guard: an `--apply` from an OLDER package than the
|
|
1211
1389
|
// active install would overwrite newer hooks (upgrade.mjs:287 copyFileSync) and
|
|
1212
|
-
// rewrite
|
|
1390
|
+
// rewrite the version record to the older one. Refuse before the first mutation.
|
|
1213
1391
|
// A dev workspace re-running its own --apply (incl. the post-commit sync hook)
|
|
1214
1392
|
// is exempt via realpath'd pkgRoot equality. Exit 2 = refused downgrade.
|
|
1215
|
-
|
|
1216
|
-
|
|
1393
|
+
//
|
|
1394
|
+
// Scoped to the two branches below that actually write a version-bearing
|
|
1395
|
+
// artifact this run does not own the comparison baseline for, and each
|
|
1396
|
+
// reads the baseline THAT branch actually writes, not a shared one:
|
|
1397
|
+
// - managesClaudeCore: applyHookFiles + writeProvenanceSidecar write this
|
|
1398
|
+
// run's own hooks and pkgVersion over the Claude-core hooks dir, whose
|
|
1399
|
+
// version of record is hypo-pkg.json (pkgJsonPath()) — the artifact
|
|
1400
|
+
// applyCommands below stamps. An older incoming version there really
|
|
1401
|
+
// would overwrite newer hooks.
|
|
1402
|
+
// - args.codex: reached even when managesClaudeCore is false (it is a
|
|
1403
|
+
// sibling branch, not nested under it — see :1383 below), and inside it
|
|
1404
|
+
// applyHookFiles(hooksCodex, ...) + writeProvenanceSidecar(codexHooksDir,
|
|
1405
|
+
// ..., readVersionAtRoot(PKG_ROOT), ...) copy codex hooks and stamp a
|
|
1406
|
+
// version into codexHooksDir's OWN `.hypo-provenance.json` sidecar, a
|
|
1407
|
+
// file distinct from hypo-pkg.json. A prior fix compared the codex
|
|
1408
|
+
// branch against hypo-pkg.json anyway (the Claude plugin's pointer,
|
|
1409
|
+
// unrelated to what codex hooks record their own version in) and so
|
|
1410
|
+
// never observed a downgrade there: in a dual install hypo-pkg.json
|
|
1411
|
+
// stays plugin-owned and un-bumped by a codex apply, so the "downgrade"
|
|
1412
|
+
// that guard could see was really just the plugin's untouched pointer,
|
|
1413
|
+
// leaving every SECOND `--apply --codex` refused forever (the codex
|
|
1414
|
+
// sidecar it should have compared against had already moved on).
|
|
1415
|
+
// Reading each branch's own artifact is the fix.
|
|
1416
|
+
// A dualSkip run with neither condition true (the plain `--apply` repeat
|
|
1417
|
+
// in a dual install, no `--codex`) copies no hook file and never writes its
|
|
1418
|
+
// own version: writeDualSkipProvenance stamps the REGISTRY's version, not
|
|
1419
|
+
// PKG_ROOT's, so there is nothing here for the guard to protect.
|
|
1420
|
+
if ((managesClaudeCore || args.codex) && !args.allowDowngrade) {
|
|
1217
1421
|
let _incomingVersion = null;
|
|
1218
1422
|
try {
|
|
1219
1423
|
_incomingVersion = JSON.parse(readFileSync(join(PKG_ROOT, 'package.json'), 'utf-8')).version;
|
|
1220
1424
|
} catch {
|
|
1221
1425
|
/* unreadable own package.json — cannot prove a downgrade, allow */
|
|
1222
1426
|
}
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1427
|
+
// `active` is { pkgRoot, pkgVersion } from whichever baseline the branch
|
|
1428
|
+
// actually writes to. `label` only feeds the error message.
|
|
1429
|
+
const refuseIfDowngrade = (active) => {
|
|
1430
|
+
if (
|
|
1431
|
+
active &&
|
|
1432
|
+
active.pkgRoot &&
|
|
1433
|
+
active.pkgVersion &&
|
|
1434
|
+
_incomingVersion &&
|
|
1435
|
+
classifyInstall(
|
|
1436
|
+
{ pkgRoot: PKG_ROOT, version: _incomingVersion },
|
|
1437
|
+
{ pkgRoot: active.pkgRoot, version: active.pkgVersion },
|
|
1438
|
+
) === 'downgrade'
|
|
1439
|
+
) {
|
|
1440
|
+
console.error(
|
|
1441
|
+
downgradeGuardMessage(_incomingVersion, active.pkgVersion, 'upgrade --apply'),
|
|
1442
|
+
);
|
|
1443
|
+
process.exit(2);
|
|
1444
|
+
}
|
|
1445
|
+
};
|
|
1446
|
+
if (managesClaudeCore) {
|
|
1447
|
+
refuseIfDowngrade(readPkgJsonSafe(pkgJsonPath()));
|
|
1448
|
+
}
|
|
1449
|
+
if (args.codex) {
|
|
1450
|
+
// readProvenanceSidecar returns null on both "no sidecar yet" (first-
|
|
1451
|
+
// ever `--apply --codex`, nothing to downgrade from) and "sidecar
|
|
1452
|
+
// exists but fails to parse" — deliberately treated the same as
|
|
1453
|
+
// absent, fail-open, rather than distinguished: a corrupt sidecar
|
|
1454
|
+
// carries no more of a trustworthy baseline than a missing one, and
|
|
1455
|
+
// the write immediately below this guard (writeProvenanceSidecar at
|
|
1456
|
+
// :~1419) heals it either way. Its {pkgRoot, pkgVersion} shape matches
|
|
1457
|
+
// what refuseIfDowngrade expects, unlike its `hypoSharedSha256`/
|
|
1458
|
+
// `copiedAt` fields, which classifyInstall never reads.
|
|
1459
|
+
const _codexActive = readProvenanceSidecar(codexHooksDir);
|
|
1460
|
+
refuseIfDowngrade(
|
|
1461
|
+
_codexActive && { pkgRoot: _codexActive.pkgRoot, pkgVersion: _codexActive.pkgVersion },
|
|
1462
|
+
);
|
|
1234
1463
|
}
|
|
1235
1464
|
}
|
|
1236
1465
|
// Migration report is vault-side (writes into the Hypomnema root) and applies
|
|
@@ -1244,13 +1473,13 @@ if (args.apply) {
|
|
|
1244
1473
|
});
|
|
1245
1474
|
}
|
|
1246
1475
|
if (managesClaudeCore) {
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1476
|
+
// Rename, add, and retire all commit through one atomicWrite inside this
|
|
1477
|
+
// call. See applySettingsJsonTransform's own comment for why they are no
|
|
1478
|
+
// longer three separate writeFileSync calls.
|
|
1479
|
+
const claudeSettingsResult = applySettingsJsonTransform(claudeSettingsPath, claudeHooksDir);
|
|
1480
|
+
appliedHookNameRenames = claudeSettingsResult.renamed;
|
|
1481
|
+
appliedSettings = claudeSettingsResult.added;
|
|
1482
|
+
appliedRetiredSettings = claudeSettingsResult.retired;
|
|
1254
1483
|
appliedHooks = applyHookFiles(hooks, claudeHooksDir);
|
|
1255
1484
|
// Refresh every run managesClaudeCore is true, not only when a stale hook
|
|
1256
1485
|
// was actually copied above: applyHookFiles OVERWRITES (never skips) a
|
|
@@ -1258,7 +1487,6 @@ if (args.apply) {
|
|
|
1258
1487
|
// must still keep the sidecar's pkgVersion truthful, same reasoning as
|
|
1259
1488
|
// installHooks's own refresh-every-run comment.
|
|
1260
1489
|
writeProvenanceSidecar(claudeHooksDir, PKG_ROOT, readVersionAtRoot(PKG_ROOT), HOOKS_SRC, false);
|
|
1261
|
-
appliedSettings = applySettingsJson(settings, claudeSettingsPath);
|
|
1262
1490
|
// applyCommands handles the single atomic hypo-pkg.json write (pkgRoot, version, schema, commands map)
|
|
1263
1491
|
appliedCommands = applyCommands(commands, args.forceCommands);
|
|
1264
1492
|
appliedPkgJson = true;
|
|
@@ -1350,23 +1578,19 @@ if (args.apply) {
|
|
|
1350
1578
|
// wikiHookWantRoot is null only when dualSkip cannot positively resolve the
|
|
1351
1579
|
// registry (preserve case), and wikiPreCommitDrift is already false then.
|
|
1352
1580
|
if (wikiPreCommitDrift) {
|
|
1353
|
-
const wikiPreCommitResult = applyWikiPreCommitRoot(wikiPreCommitInfo
|
|
1581
|
+
const wikiPreCommitResult = applyWikiPreCommitRoot(wikiPreCommitInfo);
|
|
1354
1582
|
appliedWikiPreCommitRoot = wikiPreCommitResult.ok;
|
|
1355
1583
|
if (!wikiPreCommitResult.ok) wikiPreCommitApplyFailReason = wikiPreCommitResult.reason;
|
|
1356
1584
|
}
|
|
1357
|
-
// codex core hooks + settings + wiki-*→hypo-* rename mirror. Same
|
|
1358
|
-
// as the claude side
|
|
1585
|
+
// codex core hooks + settings + wiki-*→hypo-* rename mirror. Same merged,
|
|
1586
|
+
// single-write transform as the claude side above.
|
|
1359
1587
|
if (args.codex) {
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
codexHooksDir,
|
|
1365
|
-
);
|
|
1366
|
-
}
|
|
1588
|
+
const codexSettingsResult = applySettingsJsonTransform(codexSettingsPath, codexHooksDir);
|
|
1589
|
+
appliedHookNameRenamesCodex = codexSettingsResult.renamed;
|
|
1590
|
+
appliedSettingsCodex = codexSettingsResult.added;
|
|
1591
|
+
appliedRetiredSettingsCodex = codexSettingsResult.retired;
|
|
1367
1592
|
appliedHooksCodex = applyHookFiles(hooksCodex, codexHooksDir);
|
|
1368
1593
|
writeProvenanceSidecar(codexHooksDir, PKG_ROOT, readVersionAtRoot(PKG_ROOT), HOOKS_SRC, false);
|
|
1369
|
-
appliedSettingsCodex = applySettingsJson(settingsCodex, codexSettingsPath);
|
|
1370
1594
|
}
|
|
1371
1595
|
// After applyCommands wrote hypo-pkg.json — merges extensions.<target> alongside.
|
|
1372
1596
|
appliedExtensions = syncExtensions({
|
|
@@ -1405,7 +1629,8 @@ const codexCoreDrift =
|
|
|
1405
1629
|
(staleHooksCodex.length > 0 ||
|
|
1406
1630
|
missingSettingsCodex.length > 0 ||
|
|
1407
1631
|
invalidSettingsCodex ||
|
|
1408
|
-
(oldHookRefsCodex?.length ?? 0) > 0
|
|
1632
|
+
(oldHookRefsCodex?.length ?? 0) > 0 ||
|
|
1633
|
+
(retiredSettingsCodex?.length ?? 0) > 0);
|
|
1409
1634
|
|
|
1410
1635
|
// Claude core-surface drift (hooks/settings/commands/rename/metadata). In plugin
|
|
1411
1636
|
// mode these are plugin-managed, so they must NOT count as drift — otherwise the
|
|
@@ -1420,6 +1645,7 @@ const claudeCoreDrift =
|
|
|
1420
1645
|
missingSettings.length > 0 ||
|
|
1421
1646
|
invalidSettings ||
|
|
1422
1647
|
oldHookRefs.length > 0 ||
|
|
1648
|
+
retiredSettings.length > 0 ||
|
|
1423
1649
|
staleCommands.length > 0 ||
|
|
1424
1650
|
userModifiedCommands.length > 0 ||
|
|
1425
1651
|
orphanedCommands.length > 0 ||
|
|
@@ -1453,13 +1679,21 @@ if (args.json) {
|
|
|
1453
1679
|
pkgJson,
|
|
1454
1680
|
commands,
|
|
1455
1681
|
oldHookRefs,
|
|
1682
|
+
retiredSettings,
|
|
1456
1683
|
hypoignore,
|
|
1457
1684
|
gitignore,
|
|
1458
1685
|
wikiPreCommitRoot: wikiPreCommitInfo
|
|
1459
1686
|
? {
|
|
1460
1687
|
current: wikiPreCommitInfo.root,
|
|
1688
|
+
// Not "where the hook will point": the migrated body resolves the
|
|
1689
|
+
// install itself at commit time. This is what THIS run resolved the
|
|
1690
|
+
// active install to, kept as diagnostic output.
|
|
1461
1691
|
wantRoot: wikiHookWantRoot,
|
|
1462
1692
|
drift: wikiPreCommitDrift,
|
|
1693
|
+
// true only for "marker present, body unreadable" (see
|
|
1694
|
+
// checkWikiPreCommitRoot above). Absent/false for both a clean
|
|
1695
|
+
// new-form hook and an old-form one this run can still parse.
|
|
1696
|
+
unrecognized: wikiPreCommitInfo.unrecognized === true,
|
|
1463
1697
|
}
|
|
1464
1698
|
: null,
|
|
1465
1699
|
extensions: extCheck,
|
|
@@ -1468,6 +1702,7 @@ if (args.json) {
|
|
|
1468
1702
|
hooksCodex,
|
|
1469
1703
|
settingsCodex,
|
|
1470
1704
|
oldHookRefsCodex,
|
|
1705
|
+
retiredSettingsCodex,
|
|
1471
1706
|
applied: {
|
|
1472
1707
|
hooks: appliedHooks,
|
|
1473
1708
|
settings: appliedSettings,
|
|
@@ -1482,6 +1717,8 @@ if (args.json) {
|
|
|
1482
1717
|
hooksCodex: appliedHooksCodex,
|
|
1483
1718
|
settingsCodex: appliedSettingsCodex,
|
|
1484
1719
|
hookNameRenamesCodex: appliedHookNameRenamesCodex,
|
|
1720
|
+
retiredSettings: appliedRetiredSettings,
|
|
1721
|
+
retiredSettingsCodex: appliedRetiredSettingsCodex,
|
|
1485
1722
|
},
|
|
1486
1723
|
migrationReport: migrationPath,
|
|
1487
1724
|
},
|
|
@@ -1596,6 +1833,12 @@ if (schema.bump === 'none') {
|
|
|
1596
1833
|
lines.push(
|
|
1597
1834
|
`⚠ SCHEMA version ${schema.installed} → ${schema.current} [minor update — review and update SCHEMA.md manually]`,
|
|
1598
1835
|
);
|
|
1836
|
+
// Name what actually changed in the versions between, when this map knows.
|
|
1837
|
+
// A version this map has no entry for (installed predates the map, or a
|
|
1838
|
+
// future bump forgot to add one) leaves the notice as it was above.
|
|
1839
|
+
for (const delta of schemaVersionDeltas(schema.installed, schema.current)) {
|
|
1840
|
+
lines.push(` - ${delta}`);
|
|
1841
|
+
}
|
|
1599
1842
|
}
|
|
1600
1843
|
|
|
1601
1844
|
// hypo-guide.md version stamp. Visibility only — a drift warning,
|
|
@@ -1616,12 +1859,18 @@ if (guide.bump === 'none') {
|
|
|
1616
1859
|
// install) but predates the version stamp entirely. Counted as drift above
|
|
1617
1860
|
// (guideDrift) — the message must be actionable, not "cannot compare",
|
|
1618
1861
|
// since a pre-versioning copy silently reporting "up to date" was the bug.
|
|
1862
|
+
//
|
|
1863
|
+
// Name both sides by their ABSOLUTE path, not the package-relative
|
|
1864
|
+
// `templates/hypo-guide.md`. A person running upgrade from their vault root
|
|
1865
|
+
// (the common case) has no `templates/` there at all, since init never
|
|
1866
|
+
// copies that directory into the vault, so the old wording sent them
|
|
1867
|
+
// looking for a file that does not exist on the machine they are standing on.
|
|
1619
1868
|
lines.push(
|
|
1620
|
-
`⚠ hypo-guide.md installed copy has no version stamp (pre-versioning stale copy; package=v${guide.current})
|
|
1869
|
+
`⚠ hypo-guide.md installed copy has no version stamp (pre-versioning stale copy; package=v${guide.current}). No stamp means there is no base version to diff a changelog from, so compare and merge by hand: \`diff "${guide.pkgPath}" "${guide.hypoPath}"\`. --apply does not overwrite it`,
|
|
1621
1870
|
);
|
|
1622
1871
|
} else {
|
|
1623
1872
|
lines.push(
|
|
1624
|
-
`⚠ hypo-guide.md v${guide.installed} → v${guide.current} [package template changed
|
|
1873
|
+
`⚠ hypo-guide.md v${guide.installed} → v${guide.current} [package template changed, compare and merge by hand: \`diff "${guide.pkgPath}" "${guide.hypoPath}"\`; --apply does not overwrite it]`,
|
|
1625
1874
|
);
|
|
1626
1875
|
}
|
|
1627
1876
|
|
|
@@ -1683,6 +1932,28 @@ if (managesClaudeCore) {
|
|
|
1683
1932
|
}
|
|
1684
1933
|
if (settingsCodex) pushSettingsSummary(settingsCodex, ' (codex)', invalidSettingsCodex);
|
|
1685
1934
|
|
|
1935
|
+
// Registrations a settings.json install still carries for a hook this package
|
|
1936
|
+
// has since moved out of that event (checkRetiredRegistrations). Silent when
|
|
1937
|
+
// there are none: the settings line above already says the live set is whole.
|
|
1938
|
+
// Under --apply this reports what was removed, which can include an entry the
|
|
1939
|
+
// pre-apply check could not name (a legacy wiki-*.mjs rename that landed on a
|
|
1940
|
+
// retired hook).
|
|
1941
|
+
function pushRetiredSummary(checked, applied, label) {
|
|
1942
|
+
const entries = args.apply ? applied : checked.map((r) => `${r.event}: ${r.file}`);
|
|
1943
|
+
if (entries.length === 0) return;
|
|
1944
|
+
const colR = `settings.json${label}`.padEnd(20);
|
|
1945
|
+
lines.push(
|
|
1946
|
+
args.apply
|
|
1947
|
+
? `✓ ${colR}removed ${entries.length} retired registration(s):`
|
|
1948
|
+
: `⚠ ${colR}${entries.length} retired registration(s) still fire. --apply removes them:`,
|
|
1949
|
+
);
|
|
1950
|
+
for (const e of entries) lines.push(` - ${e}`);
|
|
1951
|
+
}
|
|
1952
|
+
if (managesClaudeCore) pushRetiredSummary(retiredSettings, appliedRetiredSettings, '');
|
|
1953
|
+
if (retiredSettingsCodex) {
|
|
1954
|
+
pushRetiredSummary(retiredSettingsCodex, appliedRetiredSettingsCodex, ' (codex)');
|
|
1955
|
+
}
|
|
1956
|
+
|
|
1686
1957
|
// Package metadata
|
|
1687
1958
|
if (dualSkip && dualSkipCorrected) {
|
|
1688
1959
|
// npm-first correction actually WROTE this run: the pre-apply pkgJson
|
|
@@ -1735,23 +2006,37 @@ if (dualSkip && dualSkipCorrected) {
|
|
|
1735
2006
|
if (wikiPreCommitInfo && wikiPreCommitDrift) {
|
|
1736
2007
|
if (appliedWikiPreCommitRoot) {
|
|
1737
2008
|
lines.push(
|
|
1738
|
-
`✓ Wiki pre-commit hook
|
|
2009
|
+
`✓ Wiki pre-commit hook migrated off the baked-in root ${wikiPreCommitInfo.root}` +
|
|
2010
|
+
` — it now resolves the install at commit time and no longer goes stale on a release`,
|
|
1739
2011
|
);
|
|
1740
2012
|
} else if (args.apply) {
|
|
1741
2013
|
// --apply ran but the write was refused or failed — say why, rather than
|
|
1742
2014
|
// printing the same "run --apply" nudge that already ran and did nothing.
|
|
1743
2015
|
lines.push(
|
|
1744
|
-
`⚠ Wiki pre-commit could not
|
|
1745
|
-
`
|
|
2016
|
+
`⚠ Wiki pre-commit could not migrate hook (${wikiPreCommitApplyFailReason}) —` +
|
|
2017
|
+
` ${wikiPreCommitInfo.root} is still baked in and goes stale on the next release`,
|
|
1746
2018
|
);
|
|
1747
2019
|
} else {
|
|
1748
2020
|
lines.push(
|
|
1749
|
-
`⚠ Wiki pre-commit hook
|
|
1750
|
-
`
|
|
2021
|
+
`⚠ Wiki pre-commit hook has ${wikiPreCommitInfo.root} baked in, so it goes stale on` +
|
|
2022
|
+
` every release — run --apply to migrate it`,
|
|
1751
2023
|
);
|
|
1752
2024
|
}
|
|
2025
|
+
} else if (wikiPreCommitInfo && wikiPreCommitInfo.unrecognized) {
|
|
2026
|
+
// The marker is here but the body inside it is not one this codebase ever
|
|
2027
|
+
// wrote (hand-edited, corrupted, or an install root so far gone even the
|
|
2028
|
+
// rewrite-safe check above cannot read it). Nothing here is safe to
|
|
2029
|
+
// rewrite automatically, so name the break instead of staying silent about
|
|
2030
|
+
// it the way this branch used to (2026-09-11): a `--apply` that never
|
|
2031
|
+
// prints a Wiki pre-commit line at all reads as "nothing to do" when the
|
|
2032
|
+
// hook is actually failing every commit.
|
|
2033
|
+
lines.push(
|
|
2034
|
+
`⚠ Wiki pre-commit marker present but the hook body is not recognized (hand-edited,` +
|
|
2035
|
+
` corrupted, or too stale to read). Run \`hypomnema init --force-commands\` (or the` +
|
|
2036
|
+
` plugin's \`/hypo:init --force-commands\`) to reinstall the guard`,
|
|
2037
|
+
);
|
|
1753
2038
|
} else if (wikiPreCommitInfo) {
|
|
1754
|
-
lines.push(`✓ Wiki pre-commit hook
|
|
2039
|
+
lines.push(`✓ Wiki pre-commit hook resolves the install at commit time (nothing to migrate)`);
|
|
1755
2040
|
}
|
|
1756
2041
|
|
|
1757
2042
|
// Slash commands
|
|
@@ -1972,6 +2257,7 @@ const claudeCoreCount = managesClaudeCore
|
|
|
1972
2257
|
missingSettings.length +
|
|
1973
2258
|
(invalidSettings ? 1 : 0) +
|
|
1974
2259
|
oldHookRefs.length +
|
|
2260
|
+
retiredSettings.length +
|
|
1975
2261
|
staleCommands.length +
|
|
1976
2262
|
userModifiedCommands.length +
|
|
1977
2263
|
orphanedCommands.length +
|
|
@@ -2006,7 +2292,8 @@ const totalDrift =
|
|
|
2006
2292
|
staleHooksCodex.length +
|
|
2007
2293
|
missingSettingsCodex.length +
|
|
2008
2294
|
(invalidSettingsCodex ? 1 : 0) +
|
|
2009
|
-
(oldHookRefsCodex?.length ?? 0)
|
|
2295
|
+
(oldHookRefsCodex?.length ?? 0) +
|
|
2296
|
+
(retiredSettingsCodex?.length ?? 0);
|
|
2010
2297
|
if (totalDrift === 0) {
|
|
2011
2298
|
lines.push('Result: Hypomnema is up to date');
|
|
2012
2299
|
} else if (args.apply) {
|
|
@@ -2028,8 +2315,10 @@ if (totalDrift === 0) {
|
|
|
2028
2315
|
appliedExtCount +
|
|
2029
2316
|
appliedHooksCodex.length +
|
|
2030
2317
|
appliedSettingsCodex.length +
|
|
2031
|
-
appliedHookNameRenamesCodex.length
|
|
2032
|
-
|
|
2318
|
+
appliedHookNameRenamesCodex.length +
|
|
2319
|
+
appliedRetiredSettings.length +
|
|
2320
|
+
appliedRetiredSettingsCodex.length;
|
|
2321
|
+
lines.push(`Result: ${total} update(s) applied. Verify with ${DOCTOR_HINT}.`);
|
|
2033
2322
|
} else {
|
|
2034
2323
|
lines.push(`Result: ${totalDrift} item(s) need updating — run with --apply to install`);
|
|
2035
2324
|
}
|