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/doctor.mjs
CHANGED
|
@@ -18,6 +18,7 @@ import { homedir } from 'os';
|
|
|
18
18
|
import { spawnSync } from 'child_process';
|
|
19
19
|
import { fileURLToPath } from 'url';
|
|
20
20
|
import { resolveHypoRoot, expandHome } from './lib/hypo-root.mjs';
|
|
21
|
+
import { loadHookInventory } from './lib/hook-inventory.mjs';
|
|
21
22
|
import { loadHypoIgnore, isScanIgnored } from './lib/hypo-ignore.mjs';
|
|
22
23
|
import { readRenameMarker, renameMarkerPath, RENAME_MARKER_REL } from './lib/rename-marker.mjs';
|
|
23
24
|
import {
|
|
@@ -36,10 +37,12 @@ import {
|
|
|
36
37
|
localAndUtcDates,
|
|
37
38
|
normalizeVerifiedScope,
|
|
38
39
|
SESSION_CLOSED_MARKER_STALE_MS,
|
|
40
|
+
sessionClosedMarkerPath,
|
|
39
41
|
isUsablePkgRootLocal,
|
|
40
42
|
selfLocationPkgRootFrom,
|
|
41
43
|
} from '../hooks/hypo-shared.mjs';
|
|
42
|
-
import {
|
|
44
|
+
import { classifyProposals } from './proposal.mjs';
|
|
45
|
+
import { listProposalsChecked, orphanTmpTargetName } from '../hooks/proposal-store.mjs';
|
|
43
46
|
import {
|
|
44
47
|
discoverExtensions,
|
|
45
48
|
parseManifest,
|
|
@@ -64,6 +67,7 @@ import {
|
|
|
64
67
|
computeHooksDigest,
|
|
65
68
|
} from './lib/pkg-provenance.mjs';
|
|
66
69
|
import { resolveCliOnPath, classifyInstall, upgradeApplyHint } from '../hooks/version-check.mjs';
|
|
70
|
+
import { closeCheckpointState, isCloseComplete } from '../hooks/close-receipt.mjs';
|
|
67
71
|
import {
|
|
68
72
|
enabledHypomnemaPluginKey,
|
|
69
73
|
usablePkgRoot,
|
|
@@ -154,94 +158,22 @@ function fail(label, detail = '') {
|
|
|
154
158
|
}
|
|
155
159
|
|
|
156
160
|
// ── hook map (loaded from hooks/hooks.json — single source of truth) ─────────
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
if (!
|
|
167
|
-
console.error(
|
|
168
|
-
console.error(PKG_INTEGRITY_HINT);
|
|
169
|
-
process.exit(1);
|
|
170
|
-
}
|
|
171
|
-
if (
|
|
172
|
-
!_hookConfig.hooks ||
|
|
173
|
-
typeof _hookConfig.hooks !== 'object' ||
|
|
174
|
-
Array.isArray(_hookConfig.hooks)
|
|
175
|
-
) {
|
|
176
|
-
console.error('Error: hooks/hooks.json must contain a "hooks" object');
|
|
177
|
-
console.error(PKG_INTEGRITY_HINT);
|
|
178
|
-
process.exit(1);
|
|
179
|
-
}
|
|
180
|
-
function _extractCommandFileName(command) {
|
|
181
|
-
if (typeof command !== 'string') return null;
|
|
182
|
-
const matches = [...command.matchAll(/(?:^|[\/\\])([^\/\\\s"'`]+\.mjs)(?=$|[\s"'`])/g)];
|
|
183
|
-
if (matches.length > 0) return matches[matches.length - 1][1];
|
|
184
|
-
const bare = command.match(/(?:^|\s)([^\/\\\s"'`]+\.mjs)(?=$|[\s"'`])/);
|
|
185
|
-
return bare ? bare[1] : null;
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
function _isHookFileName(file) {
|
|
189
|
-
return typeof file === 'string' && /^[^/\\\s]+\.mjs$/.test(file.trim());
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
function _isHookGroup(group) {
|
|
193
|
-
return (
|
|
194
|
-
group &&
|
|
195
|
-
typeof group === 'object' &&
|
|
196
|
-
!Array.isArray(group) &&
|
|
197
|
-
Array.isArray(group.hooks) &&
|
|
198
|
-
group.hooks.length > 0 &&
|
|
199
|
-
group.hooks.every(
|
|
200
|
-
(hook) =>
|
|
201
|
-
hook &&
|
|
202
|
-
typeof hook === 'object' &&
|
|
203
|
-
!Array.isArray(hook) &&
|
|
204
|
-
hook.type === 'command' &&
|
|
205
|
-
_extractCommandFileName(hook.command),
|
|
206
|
-
)
|
|
207
|
-
);
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
// Extract .mjs file names from both old format (string[]) and new format (hook-group object[])
|
|
211
|
-
function _extractFileNames(groups) {
|
|
212
|
-
return groups.flatMap((group) => {
|
|
213
|
-
if (typeof group === 'string') return [group.trim()];
|
|
214
|
-
return group.hooks.map((hook) => _extractCommandFileName(hook.command));
|
|
215
|
-
});
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
for (const [event, groups] of Object.entries(_hookConfig.hooks)) {
|
|
219
|
-
const valid =
|
|
220
|
-
Array.isArray(groups) &&
|
|
221
|
-
groups.length > 0 &&
|
|
222
|
-
groups.every((group) => _isHookFileName(group) || _isHookGroup(group)) &&
|
|
223
|
-
_extractFileNames(groups).length > 0;
|
|
224
|
-
if (!valid) {
|
|
225
|
-
console.error(
|
|
226
|
-
`Error: hooks/hooks.json "hooks.${event}" must be a non-empty array of .mjs file names or Claude hook groups`,
|
|
227
|
-
);
|
|
228
|
-
console.error(PKG_INTEGRITY_HINT);
|
|
229
|
-
process.exit(1);
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
|
-
if (
|
|
233
|
-
_hookConfig.shared !== undefined &&
|
|
234
|
-
(!Array.isArray(_hookConfig.shared) || !_hookConfig.shared.every((f) => _isHookFileName(f)))
|
|
235
|
-
) {
|
|
236
|
-
console.error('Error: hooks/hooks.json "shared" must be an array of .mjs file names');
|
|
161
|
+
//
|
|
162
|
+
// loadHookInventory (scripts/lib/hook-inventory.mjs) is the one parser every
|
|
163
|
+
// hooks.json/shared.json consumer reads through — smoke-plugin, init, upgrade,
|
|
164
|
+
// and uninstall all call the same function. It existence-checks every named
|
|
165
|
+
// file against THIS package's own hooks/ before returning ok:true, so a
|
|
166
|
+
// corrupt or incomplete doctor.mjs's OWN package (never the vault being
|
|
167
|
+
// audited — that stays checkHooks()'s job below) fails here rather than
|
|
168
|
+
// auditing an install against a hook list doctor cannot trust.
|
|
169
|
+
const _hookInventory = loadHookInventory(PKG_ROOT);
|
|
170
|
+
if (!_hookInventory.ok) {
|
|
171
|
+
console.error(`Error: ${_hookInventory.error}`);
|
|
237
172
|
console.error(PKG_INTEGRITY_HINT);
|
|
238
173
|
process.exit(1);
|
|
239
174
|
}
|
|
240
|
-
|
|
241
|
-
const
|
|
242
|
-
Object.entries(_hookConfig.hooks).map(([e, gs]) => [e, _extractFileNames(gs)]),
|
|
243
|
-
);
|
|
244
|
-
const SHARED_FILES = _hookConfig.shared ?? [];
|
|
175
|
+
const HOOK_MAP = _hookInventory.hookMap;
|
|
176
|
+
const SHARED_FILES = _hookInventory.shared;
|
|
245
177
|
|
|
246
178
|
// ── checks ───────────────────────────────────────────────────────────────────
|
|
247
179
|
|
|
@@ -338,10 +270,16 @@ function checkHooks(coreManagedByPlugin) {
|
|
|
338
270
|
const claudeHooks = join(HOME, '.claude', 'hooks');
|
|
339
271
|
const allFiles = [...Object.values(HOOK_MAP).flat(), ...SHARED_FILES];
|
|
340
272
|
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
273
|
+
// A partial install used to only count the missing files, then stay at
|
|
274
|
+
// `warn` (rc=0). A real QA session hit exactly this: one hook file
|
|
275
|
+
// deleted, and neither the terminal output nor the exit code said which
|
|
276
|
+
// one, so an rc-gated CI job could not catch it and a user could not act
|
|
277
|
+
// on it without re-running upgrade just to find out. Naming the files
|
|
278
|
+
// reuses the same computation upgrade.mjs already does (checkHookFiles's
|
|
279
|
+
// per-file `status: 'missing'`), and `fail` gives the exit code something
|
|
280
|
+
// real to report.
|
|
281
|
+
const missingFiles = allFiles.filter((file) => !existsSync(join(claudeHooks, file)));
|
|
282
|
+
const missing = missingFiles.length;
|
|
345
283
|
|
|
346
284
|
if (missing === 0) {
|
|
347
285
|
pass('Hook files installed', claudeHooks);
|
|
@@ -353,7 +291,36 @@ function checkHooks(coreManagedByPlugin) {
|
|
|
353
291
|
`provided by the plugin loader (hooks/hooks.json) — none copied to ${claudeHooks} (expected)`,
|
|
354
292
|
);
|
|
355
293
|
} else if (missing < allFiles.length) {
|
|
356
|
-
|
|
294
|
+
// Naming the files is the part the QA incident actually asked for, so both
|
|
295
|
+
// channels get it. The exit code is not: on the plugin channel nothing the
|
|
296
|
+
// user can run copies these files (init.mjs gates installHooks on
|
|
297
|
+
// `!coreManagedByPlugin`, upgrade.mjs gates applyHookFiles on
|
|
298
|
+
// `managesClaudeCore`), so a `fail` there is a red the user cannot clear by
|
|
299
|
+
// doing anything, on every run, forever. Adding one file to
|
|
300
|
+
// hooks/shared.json is enough to put every dual install in this branch at
|
|
301
|
+
// once, which is exactly how this release found the problem.
|
|
302
|
+
//
|
|
303
|
+
// So the severity follows whether the exit code has an answer. A partial
|
|
304
|
+
// set on the plugin channel is leftovers from an earlier non-plugin
|
|
305
|
+
// install: harmless, since the loader supplies the real hooks, and worth
|
|
306
|
+
// saying out loud without failing over. checkSettingsJson below splits on
|
|
307
|
+
// the same flag for the same reason.
|
|
308
|
+
//
|
|
309
|
+
// The cost of this split: on the plugin channel a partial set stays rc=0,
|
|
310
|
+
// so an rc-gated check there still cannot see it. The file names are the
|
|
311
|
+
// only signal in that case, which is why they are printed either way.
|
|
312
|
+
const examples = missingFiles.slice(0, 3).join(', ');
|
|
313
|
+
const rest = missing > 3 ? `, +${missing - 3} more` : '';
|
|
314
|
+
const detail = `${missing}/${allFiles.length} missing in ${claudeHooks}: ${examples}${rest}`;
|
|
315
|
+
if (coreManagedByPlugin) {
|
|
316
|
+
warn(
|
|
317
|
+
'Hook files installed',
|
|
318
|
+
`${detail} — the plugin loader supplies these, so this copy is leftovers from an ` +
|
|
319
|
+
`earlier non-plugin install; nothing to fix unless you want the directory clean`,
|
|
320
|
+
);
|
|
321
|
+
} else {
|
|
322
|
+
fail('Hook files installed', `${detail}. Run /hypo:init`);
|
|
323
|
+
}
|
|
357
324
|
} else {
|
|
358
325
|
fail('Hook files installed', `No hook files found in ${claudeHooks} — run /hypo:init`);
|
|
359
326
|
}
|
|
@@ -361,6 +328,49 @@ function checkHooks(coreManagedByPlugin) {
|
|
|
361
328
|
checkProvenanceSidecar(claudeHooks, 'hooks/.hypo-provenance.json');
|
|
362
329
|
}
|
|
363
330
|
|
|
331
|
+
// Registrations for a hook this package still ships, under an event hooks.json
|
|
332
|
+
// no longer maps it to. The forward walk in checkSettingsJson only asks whether
|
|
333
|
+
// each live hook is registered, so an install that kept the four pre-orchestrator
|
|
334
|
+
// Stop entries next to hypo-stop.mjs read as fully registered while each of those
|
|
335
|
+
// steps ran twice per Stop. Same boundary as upgrade.mjs's
|
|
336
|
+
// checkRetiredRegistrations, which is what removes them: the exact `$HOME`-form
|
|
337
|
+
// command init writes, a basename in this package's hooks.json or shared.json,
|
|
338
|
+
// and an event whose HOOK_MAP entry does not list it. Anything else (a user's own
|
|
339
|
+
// hook, a hypo-ext-* extension, a command with arguments) is not ours to name.
|
|
340
|
+
function retiredRegistrations(settings, hooksDir) {
|
|
341
|
+
const shipped = new Set([...Object.values(HOOK_MAP).flat(), ...SHARED_FILES]);
|
|
342
|
+
const prefix = `node ${hooksDir.replace(HOME, '$HOME')}/`;
|
|
343
|
+
const found = [];
|
|
344
|
+
for (const [event, groups] of Object.entries(settings.hooks || {})) {
|
|
345
|
+
if (!Array.isArray(groups)) continue;
|
|
346
|
+
const live = new Set(HOOK_MAP[event] || []);
|
|
347
|
+
for (const g of groups) {
|
|
348
|
+
if (!g || typeof g !== 'object' || !Array.isArray(g.hooks)) continue;
|
|
349
|
+
for (const h of g.hooks) {
|
|
350
|
+
if (typeof h?.command !== 'string' || !h.command.startsWith(prefix)) continue;
|
|
351
|
+
const file = h.command.slice(prefix.length);
|
|
352
|
+
if (shipped.has(file) && !live.has(file)) found.push({ event, file, cmd: h.command });
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
return found;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function reportRetiredRegistrations(label, retired, remedy) {
|
|
360
|
+
if (retired.length === 0) {
|
|
361
|
+
pass(label, 'None');
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
const names = retired.map((r) => `${r.event}: ${r.file}`);
|
|
365
|
+
warn(
|
|
366
|
+
label,
|
|
367
|
+
`${retired.length} registration(s) for a hook hooks/hooks.json no longer registers under ` +
|
|
368
|
+
`that event. Each still fires on its own, on top of whatever runs it now (the Stop steps ` +
|
|
369
|
+
`are spawned by hypo-stop.mjs), so it runs twice. ${remedy}: ` +
|
|
370
|
+
`${names.slice(0, 4).join(', ')}${names.length > 4 ? `, +${names.length - 4} more` : ''}`,
|
|
371
|
+
);
|
|
372
|
+
}
|
|
373
|
+
|
|
364
374
|
function checkSettingsJson(coreManagedByPlugin) {
|
|
365
375
|
const settingsPath = join(HOME, '.claude', 'settings.json');
|
|
366
376
|
if (!existsSync(settingsPath)) {
|
|
@@ -398,7 +408,32 @@ function checkSettingsJson(coreManagedByPlugin) {
|
|
|
398
408
|
}
|
|
399
409
|
}
|
|
400
410
|
|
|
401
|
-
if (registered
|
|
411
|
+
if (coreManagedByPlugin && registered > 0) {
|
|
412
|
+
// The plugin loader registers these hooks itself, from hooks/hooks.json.
|
|
413
|
+
// Entries in settings.json on top of that are not a second half of one
|
|
414
|
+
// install: they are a whole second registration, left over from an earlier
|
|
415
|
+
// npm or manual install, and every hook they name fires twice per event.
|
|
416
|
+
//
|
|
417
|
+
// This branch has to come before the `registered === total` pass below,
|
|
418
|
+
// which would otherwise read a full duplicate set as the healthiest
|
|
419
|
+
// possible state and print "15/15 registered". It also has to come before
|
|
420
|
+
// the partial-warn, whose `/hypo:init` prescription does nothing here:
|
|
421
|
+
// init.mjs skips mergeSettingsJson entirely on this channel.
|
|
422
|
+
//
|
|
423
|
+
// Left as warn rather than fail: doctor has never removed a settings entry
|
|
424
|
+
// and guessing which hook group belongs to Hypomnema is how it would start
|
|
425
|
+
// deleting someone else's. Saying the state out loud, with the exact
|
|
426
|
+
// command prefix to look for, is what doctor can do honestly. The cost is
|
|
427
|
+
// that an rc-gated check still cannot see a double-firing install.
|
|
428
|
+
warn(
|
|
429
|
+
'settings.json hook registrations',
|
|
430
|
+
`${registered}/${total} Hypomnema hook(s) also registered in settings.json while the plugin ` +
|
|
431
|
+
`loader is active — these fire on top of the plugin's own, so each one runs twice. ` +
|
|
432
|
+
`They are leftovers from an earlier npm or manual install: remove the entries whose ` +
|
|
433
|
+
`command starts with \`node ${hooksDir.replace(HOME, '$HOME')}/hypo-\` from settings.json, ` +
|
|
434
|
+
`or run /hypo:uninstall before reinstalling the plugin`,
|
|
435
|
+
);
|
|
436
|
+
} else if (registered === total) {
|
|
402
437
|
pass('settings.json hook registrations', `${registered}/${total} registered`);
|
|
403
438
|
} else if (registered > 0) {
|
|
404
439
|
warn(
|
|
@@ -421,6 +456,11 @@ function checkSettingsJson(coreManagedByPlugin) {
|
|
|
421
456
|
// so they are intentionally absent from HOOK_MAP. Excluded here; their
|
|
422
457
|
// integrity (SHA + manifest + entry match) is checked separately in E5.
|
|
423
458
|
const isExtCommand = (cmd) => /(?:^|[/\s])hypo-ext-[^/\s]+\.mjs(?=$|["'\s])/.test(cmd);
|
|
459
|
+
// Retired registrations get their own line with a prescription that works
|
|
460
|
+
// (upgrade --apply removes them); listing them here as well would print the
|
|
461
|
+
// same entry twice under a remedy that does not.
|
|
462
|
+
const retired = retiredRegistrations(settings, hooksDir);
|
|
463
|
+
const retiredCmds = new Set(retired.map((r) => r.cmd));
|
|
424
464
|
const expectedCmds = new Set(
|
|
425
465
|
Object.entries(HOOK_MAP).flatMap(([, files]) =>
|
|
426
466
|
files.map((f) => `node ${hooksDir.replace(HOME, '$HOME')}/${f}`),
|
|
@@ -436,7 +476,8 @@ function checkSettingsJson(coreManagedByPlugin) {
|
|
|
436
476
|
typeof h.command === 'string' &&
|
|
437
477
|
/hypo-[^/]+\.mjs/.test(h.command) &&
|
|
438
478
|
!isExtCommand(h.command) &&
|
|
439
|
-
!expectedCmds.has(h.command)
|
|
479
|
+
!expectedCmds.has(h.command) &&
|
|
480
|
+
!retiredCmds.has(h.command)
|
|
440
481
|
) {
|
|
441
482
|
stale.push(h.command);
|
|
442
483
|
}
|
|
@@ -451,6 +492,15 @@ function checkSettingsJson(coreManagedByPlugin) {
|
|
|
451
492
|
} else {
|
|
452
493
|
pass('settings.json stale hypo-* entries', 'None');
|
|
453
494
|
}
|
|
495
|
+
reportRetiredRegistrations(
|
|
496
|
+
'settings.json retired hook registrations',
|
|
497
|
+
retired,
|
|
498
|
+
// upgrade --apply leaves settings.json alone on the plugin channel, so it
|
|
499
|
+
// is not a remedy there.
|
|
500
|
+
coreManagedByPlugin
|
|
501
|
+
? 'Remove them from settings.json by hand'
|
|
502
|
+
: 'Run /hypo:upgrade (or `hypomnema upgrade --apply`) to remove them',
|
|
503
|
+
);
|
|
454
504
|
|
|
455
505
|
// duplicate hypo-* entries per event
|
|
456
506
|
const dupes = [];
|
|
@@ -477,6 +527,71 @@ function checkSettingsJson(coreManagedByPlugin) {
|
|
|
477
527
|
}
|
|
478
528
|
}
|
|
479
529
|
|
|
530
|
+
// ── dual install (plugin + manual/npm core hook conflict) ────────────────────
|
|
531
|
+
//
|
|
532
|
+
// A plugin-channel install and a manual/npm install can both register
|
|
533
|
+
// the SAME core hooks from two different PKG_ROOTs, and each hooks/hypo-shared.mjs
|
|
534
|
+
// copy resolves its own root by self-location, so nothing forces the two to agree
|
|
535
|
+
// — one hook can call v1.7.0's scripts/ while another in the same session calls
|
|
536
|
+
// v1.7.1's. upgrade.mjs already detects this (dualInstallCoreConflict/dualSkip)
|
|
537
|
+
// and skips double-registering on --apply; the 2026-09-08 policy investigation's
|
|
538
|
+
// recommendation was for doctor to report the same state on every run, not only
|
|
539
|
+
// when upgrade happens to be invoked. `pluginChannel` above is computed once via
|
|
540
|
+
// the SAME resolvePluginChannel() upgrade.mjs and init.mjs call
|
|
541
|
+
// (lib/plugin-detect.mjs) — this reuses that judgment rather than re-deriving it.
|
|
542
|
+
// `hypomnemaPluginEnabled` is `reason !== 'not-enabled'`, so it stays true when the
|
|
543
|
+
// registry could not be read or the enabled key resolved to no install on disk. Both
|
|
544
|
+
// mean settings NAMES a plugin whose files were not located, which is a different
|
|
545
|
+
// state from "two installs are really present". checkPkgIntegrity below asks the same
|
|
546
|
+
// question, so the two share this name rather than each spelling the pair out.
|
|
547
|
+
function channelJudgmentFailed(pluginChannel) {
|
|
548
|
+
return (
|
|
549
|
+
pluginChannel.hypomnemaPluginEnabled &&
|
|
550
|
+
(pluginChannel.rootReason === 'registry-unreadable' ||
|
|
551
|
+
pluginChannel.rootReason === 'unresolved')
|
|
552
|
+
);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
function checkDualInstall(pluginChannel) {
|
|
556
|
+
const { pluginMode, hypomnemaPluginEnabled } = pluginChannel;
|
|
557
|
+
if (pluginMode) {
|
|
558
|
+
pass(
|
|
559
|
+
'Dual install (plugin + manual/npm)',
|
|
560
|
+
'Running as the plugin install — no manual/npm registration to conflict with',
|
|
561
|
+
);
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
if (!hypomnemaPluginEnabled) {
|
|
565
|
+
pass('Dual install (plugin + manual/npm)', 'Manual/npm install only — plugin not enabled');
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
// Not a dual install: telling this user to `npm uninstall -g hypomnema` would remove
|
|
569
|
+
// the only copy they actually have. A stale `enabledPlugins` entry left behind by an
|
|
570
|
+
// uninstall is exactly this shape.
|
|
571
|
+
if (channelJudgmentFailed(pluginChannel)) {
|
|
572
|
+
warn(
|
|
573
|
+
'Dual install (plugin + manual/npm)',
|
|
574
|
+
`~/.claude/settings.json enables the Hypomnema plugin, but the registry says ` +
|
|
575
|
+
`\`${pluginChannel.rootReason}\` — the plugin's files could not be located, so whether this is a ` +
|
|
576
|
+
`real dual install is UNDETERMINED. Do not remove the npm/manual copy on the strength ` +
|
|
577
|
+
`of this line: if the plugin is not actually installed, that copy is the only one you ` +
|
|
578
|
+
`have. Check ~/.claude/plugins/installed_plugins.json, then either re-install the ` +
|
|
579
|
+
`plugin or drop the stale entry from enabledPlugins.`,
|
|
580
|
+
);
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
warn(
|
|
584
|
+
'Dual install (plugin + manual/npm)',
|
|
585
|
+
'The Hypomnema plugin is ALSO enabled in ~/.claude/settings.json while this is the ' +
|
|
586
|
+
'manual/npm install — the plugin loader already provides the core hooks, slash ' +
|
|
587
|
+
'commands, and settings.json wiring, and /hypo:upgrade skips re-registering them by ' +
|
|
588
|
+
'default (doing so would fire every core hook twice). Pick one install: remove the ' +
|
|
589
|
+
'npm/manual copy (`npm uninstall -g hypomnema`) and upgrade via `/plugin marketplace ' +
|
|
590
|
+
'update hypomnema`, or run `hypomnema upgrade --apply --allow-dual-install` to ' +
|
|
591
|
+
'knowingly register both.',
|
|
592
|
+
);
|
|
593
|
+
}
|
|
594
|
+
|
|
480
595
|
function checkGit(hypoDir) {
|
|
481
596
|
if (!existsSync(join(hypoDir, '.git'))) {
|
|
482
597
|
warn(
|
|
@@ -543,15 +658,38 @@ function checkGit(hypoDir) {
|
|
|
543
658
|
// plugin-cache leaf-drift precedent: reporting must never turn into
|
|
544
659
|
// filtering, or a still-real, still-resolvable root looks like it vanished).
|
|
545
660
|
const parsedRoot = parseWikiPreCommitRoot(content);
|
|
546
|
-
|
|
547
|
-
|
|
661
|
+
if (parsedRoot.ok && parsedRoot.root === null) {
|
|
662
|
+
// The runtime-resolving form: the hook looks up its own
|
|
663
|
+
// install root at commit time, so there is no baked value to compare
|
|
664
|
+
// against the active install — it cannot structurally go stale the way
|
|
665
|
+
// the old, version-pinned form could. Report that fact in place of the
|
|
666
|
+
// stale-root comparison below, rather than falling silent.
|
|
667
|
+
pass(`${label} root`, 'Resolved at commit time by the hook itself — never goes stale');
|
|
668
|
+
} else if (!parsedRoot.ok) {
|
|
669
|
+
// The pass above only checked that our START marker is present, not
|
|
670
|
+
// that the body it wraps is one this codebase can actually run. This is
|
|
671
|
+
// the fail-open this branch used to leave: a hand-edited body, a
|
|
672
|
+
// corrupted one, or (before the rewrite-safe root check) an OLD-form
|
|
673
|
+
// hook whose baked-in install root no longer existed all read as a
|
|
674
|
+
// clean "guard installed" pass while the hook fails MODULE_NOT_FOUND on
|
|
675
|
+
// every real commit.
|
|
548
676
|
warn(
|
|
549
677
|
`${label} root`,
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
)}.`,
|
|
678
|
+
'Guard marker present but the hook body is not recognized, it may be failing on ' +
|
|
679
|
+
`every commit. Run \`hypomnema init --force-commands\` (or the plugin's ` +
|
|
680
|
+
`\`/hypo:init --force-commands\`) to reinstall it.`,
|
|
554
681
|
);
|
|
682
|
+
} else {
|
|
683
|
+
const wantRoot = resolveDurableHookRoot();
|
|
684
|
+
if (wantRoot !== null && wantRoot !== parsedRoot.root) {
|
|
685
|
+
warn(
|
|
686
|
+
`${label} root`,
|
|
687
|
+
`Points at an old install root (${parsedRoot.root}) — the active install is ` +
|
|
688
|
+
`${wantRoot}. To repoint it, run ${upgradeApplyHint(
|
|
689
|
+
pluginMode || hypomnemaPluginEnabled,
|
|
690
|
+
)}.`,
|
|
691
|
+
);
|
|
692
|
+
}
|
|
555
693
|
}
|
|
556
694
|
} else {
|
|
557
695
|
warn(label, 'Exists but not managed by Hypomnema — manual git add can bypass .hypoignore');
|
|
@@ -1033,6 +1171,10 @@ function checkSessionCloseArtifacts(hypoDir) {
|
|
|
1033
1171
|
// already-expired marker can't vouch for anything.
|
|
1034
1172
|
const cacheDir = join(hypoDir, '.cache');
|
|
1035
1173
|
const markers = [];
|
|
1174
|
+
// Markers closeCheckpointState does not call a finished close (the receipt
|
|
1175
|
+
// they name is missing, invalid or another generation, or a valid receipt
|
|
1176
|
+
// disagrees with them). They cover nothing, and the warning says why.
|
|
1177
|
+
const brokenMarkers = [];
|
|
1036
1178
|
let cacheEntries = [];
|
|
1037
1179
|
if (existsSync(cacheDir)) {
|
|
1038
1180
|
try {
|
|
@@ -1047,6 +1189,19 @@ function checkSessionCloseArtifacts(hypoDir) {
|
|
|
1047
1189
|
const data = JSON.parse(readFileSync(join(cacheDir, file), 'utf-8'));
|
|
1048
1190
|
const ts = Date.parse(data?.closed_at || '');
|
|
1049
1191
|
if (!Number.isFinite(ts) || Date.now() - ts > SESSION_CLOSED_MARKER_STALE_MS) continue;
|
|
1192
|
+
// The same verdict Stop blocks on, fed the marker parsed above so the
|
|
1193
|
+
// reader never unlinks anything. The file name carries the sanitized id,
|
|
1194
|
+
// which can differ from the real one; the marker body carries the real
|
|
1195
|
+
// one, and the receipt's own session id check needs exactly that.
|
|
1196
|
+
const sessionId =
|
|
1197
|
+
typeof data?.session_id === 'string' && data.session_id
|
|
1198
|
+
? data.session_id
|
|
1199
|
+
: file.slice('session-closed-'.length, -'.marker'.length);
|
|
1200
|
+
const checkpoint = closeCheckpointState(hypoDir, sessionId, { marker: data });
|
|
1201
|
+
if (!isCloseComplete(checkpoint)) {
|
|
1202
|
+
brokenMarkers.push({ sessionId, reason: checkpoint.reason });
|
|
1203
|
+
continue;
|
|
1204
|
+
}
|
|
1050
1205
|
// ONLY the v4 `projects` array counts as project-scope evidence. A
|
|
1051
1206
|
// legacy flat `project` field can be a recency-derived misattribution
|
|
1052
1207
|
// (the P1 bug resolveCloseScope's own doc comment describes) — the
|
|
@@ -1086,6 +1241,15 @@ function checkSessionCloseArtifacts(hypoDir) {
|
|
|
1086
1241
|
.map((a) => `${a.where}${a.date ? ` (${a.date})` : ''}`)
|
|
1087
1242
|
.join(', ');
|
|
1088
1243
|
const extra = unmatched.length > 5 ? ` (+${unmatched.length - 5} more)` : '';
|
|
1244
|
+
const brokenNote =
|
|
1245
|
+
brokenMarkers.length > 0
|
|
1246
|
+
? `. ${brokenMarkers.length} session-closed marker(s) did not count because their close ` +
|
|
1247
|
+
`checkpoint is broken: ` +
|
|
1248
|
+
brokenMarkers
|
|
1249
|
+
.slice(0, 3)
|
|
1250
|
+
.map((b) => `${b.sessionId} (${b.reason})`)
|
|
1251
|
+
.join('; ')
|
|
1252
|
+
: '';
|
|
1089
1253
|
// Not necessarily UNAPPROVED — an approved log-only marker (deliberately
|
|
1090
1254
|
// empty `projects`) or one scoped to a different project would ALSO land
|
|
1091
1255
|
// here, since neither covers a specific project's artifact. Both readings
|
|
@@ -1095,7 +1259,65 @@ function checkSessionCloseArtifacts(hypoDir) {
|
|
|
1095
1259
|
'Session-close artifacts',
|
|
1096
1260
|
`${unmatched.length} close artifact(s) with no session-closed marker covering their ` +
|
|
1097
1261
|
`date and project scope — may be an unapproved hand-made close, or an approved close ` +
|
|
1098
|
-
`whose marker doesn't attribute this project: ${sample}${extra}`,
|
|
1262
|
+
`whose marker doesn't attribute this project: ${sample}${extra}${brokenNote}`,
|
|
1263
|
+
);
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
// A close receipt with no session-closed marker beside it is a half-finished
|
|
1267
|
+
// close (the run stopped before the marker landed, or only the marker was
|
|
1268
|
+
// invalidated). checkSessionCloseArtifacts starts from close artifacts and
|
|
1269
|
+
// then walks markers, so this state is invisible to it. Enumerate the
|
|
1270
|
+
// receipts instead, judge each markerless one with the verdict Stop blocks
|
|
1271
|
+
// on, and stay silent otherwise. Same 7-day window as the marker walk, read
|
|
1272
|
+
// off the receipt file's mtime.
|
|
1273
|
+
function checkHalfClosedReceipts(hypoDir) {
|
|
1274
|
+
const sessionsDir = join(hypoDir, '.cache', 'sessions');
|
|
1275
|
+
let dirs = [];
|
|
1276
|
+
try {
|
|
1277
|
+
dirs = readdirSync(sessionsDir);
|
|
1278
|
+
} catch {
|
|
1279
|
+
return; // no sessions directory, or unreadable: nothing to judge
|
|
1280
|
+
}
|
|
1281
|
+
const broken = [];
|
|
1282
|
+
for (const name of dirs) {
|
|
1283
|
+
const file = join(sessionsDir, name, 'close-receipt.json');
|
|
1284
|
+
try {
|
|
1285
|
+
if (Date.now() - statSync(file).mtimeMs > SESSION_CLOSED_MARKER_STALE_MS) continue;
|
|
1286
|
+
// The directory name is the sanitized id; the receipt body carries the real one.
|
|
1287
|
+
const body = JSON.parse(readFileSync(file, 'utf-8'));
|
|
1288
|
+
const sessionId =
|
|
1289
|
+
typeof body?.sessionId === 'string' && body.sessionId ? body.sessionId : name;
|
|
1290
|
+
if (existsSync(sessionClosedMarkerPath(hypoDir, sessionId))) continue;
|
|
1291
|
+
const checkpoint = closeCheckpointState(hypoDir, sessionId, { marker: null });
|
|
1292
|
+
if (checkpoint.state !== 'broken') continue;
|
|
1293
|
+
// --mark-session-closed refuses a receipt whose commit is no longer an
|
|
1294
|
+
// ancestor of HEAD (history rewritten), so do not offer it for that case.
|
|
1295
|
+
const commit = typeof body?.commit === 'string' ? body.commit : '';
|
|
1296
|
+
const rewritten =
|
|
1297
|
+
/^[0-9a-f]{40}([0-9a-f]{24})?$/.test(commit) &&
|
|
1298
|
+
spawnSync('git', ['-C', hypoDir, 'rev-parse', '--verify', '--quiet', 'HEAD']).status ===
|
|
1299
|
+
0 &&
|
|
1300
|
+
spawnSync('git', ['-C', hypoDir, 'merge-base', '--is-ancestor', commit, 'HEAD']).status !==
|
|
1301
|
+
0;
|
|
1302
|
+
broken.push({ sessionId, reason: checkpoint.reason, rewritten });
|
|
1303
|
+
} catch {
|
|
1304
|
+
// unreadable or corrupt receipt: not this check's job to diagnose
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
if (broken.length === 0) return;
|
|
1308
|
+
const crystallize = join(PKG_ROOT, 'scripts', 'crystallize.mjs');
|
|
1309
|
+
warn(
|
|
1310
|
+
'Session-close checkpoint',
|
|
1311
|
+
`${broken.length} session(s) hold a close receipt but no session-closed marker: ` +
|
|
1312
|
+
broken
|
|
1313
|
+
.slice(0, 3)
|
|
1314
|
+
.map((b) =>
|
|
1315
|
+
b.rewritten
|
|
1316
|
+
? `${b.sessionId} (${b.reason}; the commit it certified is no longer in this branch's history). Restore that commit, or ask the user to close again and apply it with /hypo:crystallize`
|
|
1317
|
+
: `${b.sessionId} (${b.reason}). Recover with: node "${crystallize}" --mark-session-closed --session-id=${b.sessionId}`,
|
|
1318
|
+
)
|
|
1319
|
+
.join('; ') +
|
|
1320
|
+
(broken.length > 3 ? ` (+${broken.length - 3} more)` : ''),
|
|
1099
1321
|
);
|
|
1100
1322
|
}
|
|
1101
1323
|
|
|
@@ -1272,38 +1494,195 @@ function checkProjectSuggestions(hypoDir) {
|
|
|
1272
1494
|
}
|
|
1273
1495
|
|
|
1274
1496
|
function checkProposals(hypoDir) {
|
|
1275
|
-
//
|
|
1276
|
-
//
|
|
1277
|
-
//
|
|
1278
|
-
//
|
|
1279
|
-
//
|
|
1280
|
-
//
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1497
|
+
// Surface only: warn (never fail), pass at 0, because a parked proposal is a
|
|
1498
|
+
// normal state awaiting a human rather than a broken install, and this check
|
|
1499
|
+
// discovers without changing anything.
|
|
1500
|
+
//
|
|
1501
|
+
// Ask the store directly FIRST, before classifying anything. `ok: false`
|
|
1502
|
+
// means `.cache/proposals` itself could not be listed (permission denied,
|
|
1503
|
+
// or a file sitting where the directory should be): the backlog is
|
|
1504
|
+
// unmeasured, not zero. classifyProposals reads listProposals underneath,
|
|
1505
|
+
// which folds that same readdir failure into `[]` (right for its own
|
|
1506
|
+
// callers, which want a best-effort listing, wrong for a health check), so
|
|
1507
|
+
// classifying first and asking the store second let an unmeasured backlog
|
|
1508
|
+
// slip through the `classified.length === 0` gate below and report a
|
|
1509
|
+
// lying pass. This warn branch used to read
|
|
1510
|
+
// `inventory.ok ? inventory.unreadable : []`, which threw away `ok: false`
|
|
1511
|
+
// entirely and fell straight into that same lying pass.
|
|
1512
|
+
const inventory = listProposalsChecked(hypoDir);
|
|
1513
|
+
if (!inventory.ok) {
|
|
1285
1514
|
warn(
|
|
1286
1515
|
'Pending proposals',
|
|
1287
|
-
`${
|
|
1516
|
+
`${join(hypoDir, '.cache', 'proposals')} could not be listed (permission denied, or a ` +
|
|
1517
|
+
`file sitting where the directory should be): the parked write-proposal count is ` +
|
|
1518
|
+
`unmeasured here, not zero. Run \`ls -la ${join(hypoDir, '.cache', 'proposals')}\` to ` +
|
|
1519
|
+
`see what is blocking it, then fix the permissions or remove the stray file.`,
|
|
1520
|
+
);
|
|
1521
|
+
return;
|
|
1522
|
+
}
|
|
1523
|
+
const unreadable = inventory.unreadable;
|
|
1524
|
+
|
|
1525
|
+
// Not a count of artifacts. Three different states share that shape and want
|
|
1526
|
+
// three different things from the user, and telling them apart needs the
|
|
1527
|
+
// audit log and the page's current hash, which is exactly the judgment
|
|
1528
|
+
// `proposal reconcile` makes. Reuse that judgment rather than derive a second
|
|
1529
|
+
// one here: the failure this closes is a doctor that told the user to review
|
|
1530
|
+
// a write a human had already approved and that was already on the page.
|
|
1531
|
+
const classified = classifyProposals(hypoDir);
|
|
1532
|
+
const pending = classified.filter((c) => c.kind === 'pending');
|
|
1533
|
+
const recoverable = classified.filter((c) => c.kind === 'recoverable');
|
|
1534
|
+
const broken = classified.filter((c) => c.kind === 'evidence-broken');
|
|
1535
|
+
|
|
1536
|
+
if (classified.length === 0 && unreadable.length === 0) {
|
|
1537
|
+
pass('Pending proposals', 'No parked write-proposals');
|
|
1538
|
+
return;
|
|
1539
|
+
}
|
|
1540
|
+
const parts = [];
|
|
1541
|
+
if (recoverable.length > 0) {
|
|
1542
|
+
parts.push(
|
|
1543
|
+
`${recoverable.length} approved and already written, but their close handoff receipt ` +
|
|
1544
|
+
`never landed: run \`hypomnema proposal reconcile\` (it recovers the receipt from ` +
|
|
1545
|
+
`evidence and writes no page). Do NOT re-run the close first`,
|
|
1546
|
+
);
|
|
1547
|
+
}
|
|
1548
|
+
if (broken.length > 0) {
|
|
1549
|
+
parts.push(
|
|
1550
|
+
`${broken.length} whose audit entry no longer matches the page (${broken
|
|
1551
|
+
.slice(0, 3)
|
|
1552
|
+
.map((c) => c.target)
|
|
1553
|
+
.join(', ')}): neither reconcile nor a re-close can settle these without a human`,
|
|
1554
|
+
);
|
|
1555
|
+
}
|
|
1556
|
+
if (pending.length > 0) {
|
|
1557
|
+
// No drain to advertise: reconcile never removes a 'pending' artifact (see the
|
|
1558
|
+
// note above reconcileProposals). `list` shows age and target activity so a
|
|
1559
|
+
// human can triage, and every removal is their own `discard`.
|
|
1560
|
+
parts.push(
|
|
1561
|
+
`${pending.length} awaiting review; inspect with \`hypomnema proposal list\` ` +
|
|
1562
|
+
`(it shows each one's age and whether its target moved since)`,
|
|
1563
|
+
);
|
|
1564
|
+
}
|
|
1565
|
+
if (unreadable.length > 0) {
|
|
1566
|
+
parts.push(
|
|
1567
|
+
`${unreadable.length} candidate file(s) in .cache/proposals could not be read or ` +
|
|
1568
|
+
`parsed (corrupt, permission-denied, or hand-edited): ${unreadable
|
|
1569
|
+
.slice(0, 3)
|
|
1570
|
+
.join(', ')}${unreadable.length > 3 ? `, +${unreadable.length - 3} more` : ''}; ` +
|
|
1571
|
+
`neither \`proposal list\` nor \`proposal reconcile\` sees these. Run ` +
|
|
1572
|
+
`\`ls -la ${join(hypoDir, '.cache', 'proposals')}\` to see the rest and open each file ` +
|
|
1573
|
+
`directly`,
|
|
1288
1574
|
);
|
|
1289
1575
|
}
|
|
1576
|
+
// Separate sentence, not folded into the wording above: an orphan tmp file
|
|
1577
|
+
// is a subset of `unreadable` (a name can sit in both arrays, see
|
|
1578
|
+
// scanProposalFiles), but it is not corrupt and not hand-edited. It is a
|
|
1579
|
+
// complete proposal body whose rename from `<id>.json.<pid>.<rand>.tmp` to
|
|
1580
|
+
// `<id>.json` never landed, so the fix is `mv`, not deletion.
|
|
1581
|
+
const orphanTmp = inventory.orphanTmp || [];
|
|
1582
|
+
if (orphanTmp.length > 0) {
|
|
1583
|
+
const proposalsDir = join(hypoDir, '.cache', 'proposals');
|
|
1584
|
+
const hints = orphanTmp
|
|
1585
|
+
.slice(0, 3)
|
|
1586
|
+
.map(
|
|
1587
|
+
(name) =>
|
|
1588
|
+
`mv "${join(proposalsDir, name)}" "${join(proposalsDir, orphanTmpTargetName(name))}"`,
|
|
1589
|
+
)
|
|
1590
|
+
.join('; ');
|
|
1591
|
+
parts.push(
|
|
1592
|
+
`${orphanTmp.length} of the file(s) above are recoverable, not corrupt: a writer finished ` +
|
|
1593
|
+
`serializing a proposal body but crashed before the rename to its \`.json\` name landed. ` +
|
|
1594
|
+
`Restore with, e.g., \`${hints}\`${orphanTmp.length > 3 ? ` (+${orphanTmp.length - 3} more, same pattern: drop the \`.<pid>.<rand>.tmp\` suffix)` : ''}`,
|
|
1595
|
+
);
|
|
1596
|
+
}
|
|
1597
|
+
warn('Pending proposals', parts.join('. '));
|
|
1598
|
+
}
|
|
1599
|
+
|
|
1600
|
+
/**
|
|
1601
|
+
* Surface leftover `.cache/close-intent/` records: crystallize-close-apply's
|
|
1602
|
+
* torn-set witness (see closeIntentPath's doc comment there). A record
|
|
1603
|
+
* outlives its close only when that close crashed between writeCloseIntent
|
|
1604
|
+
* and clearCloseIntent, so every one found here means an interrupted apply,
|
|
1605
|
+
* never a normal state to leave silent. Read-only: whether an aged-out
|
|
1606
|
+
* record gets deleted is hasTornCloseIntent's call, made the next time a
|
|
1607
|
+
* close actually runs the probe, not doctor's.
|
|
1608
|
+
*/
|
|
1609
|
+
function checkCloseIntent(hypoDir) {
|
|
1610
|
+
const dir = join(hypoDir, '.cache', 'close-intent');
|
|
1611
|
+
if (!existsSync(dir)) {
|
|
1612
|
+
pass('Close-intent records', 'No leftover .cache/close-intent/ records');
|
|
1613
|
+
return;
|
|
1614
|
+
}
|
|
1615
|
+
let names;
|
|
1616
|
+
try {
|
|
1617
|
+
names = readdirSync(dir);
|
|
1618
|
+
} catch (e) {
|
|
1619
|
+
// The same failure hasTornCloseIntent treats as 'unreadable' rather than
|
|
1620
|
+
// silently torn: this check cannot tell either, so it says so instead of
|
|
1621
|
+
// reporting a clean count that might be a lie.
|
|
1622
|
+
warn(
|
|
1623
|
+
'Close-intent records',
|
|
1624
|
+
`${dir} could not be listed (${e.code || e.message}): whether a leftover record is sitting ` +
|
|
1625
|
+
`there is unmeasured, not zero, and every no-payload close probe against this vault treats ` +
|
|
1626
|
+
`an unreadable directory as unresolved (payload required) until this is fixed. Run ` +
|
|
1627
|
+
`\`ls -la ${dir}\` to see what is blocking it.`,
|
|
1628
|
+
);
|
|
1629
|
+
return;
|
|
1630
|
+
}
|
|
1631
|
+
const records = names.filter((n) => n.endsWith('.json'));
|
|
1632
|
+
if (records.length === 0) {
|
|
1633
|
+
pass('Close-intent records', 'No leftover .cache/close-intent/ records');
|
|
1634
|
+
return;
|
|
1635
|
+
}
|
|
1636
|
+
let unreadableCount = 0;
|
|
1637
|
+
let oldestMinutes = null;
|
|
1638
|
+
for (const name of records) {
|
|
1639
|
+
try {
|
|
1640
|
+
const parsed = JSON.parse(readFileSync(join(dir, name), 'utf-8'));
|
|
1641
|
+
const ts = Date.parse(parsed?.startedAt);
|
|
1642
|
+
if (Number.isFinite(ts)) {
|
|
1643
|
+
const minutes = Math.round((Date.now() - ts) / 60_000);
|
|
1644
|
+
if (oldestMinutes === null || minutes > oldestMinutes) oldestMinutes = minutes;
|
|
1645
|
+
continue;
|
|
1646
|
+
}
|
|
1647
|
+
} catch {
|
|
1648
|
+
// falls through to unreadableCount below
|
|
1649
|
+
}
|
|
1650
|
+
unreadableCount++;
|
|
1651
|
+
}
|
|
1652
|
+
const parts = [
|
|
1653
|
+
`${records.length} record(s) in .cache/close-intent/, left behind by a close that began ` +
|
|
1654
|
+
`writing and never called clearCloseIntent (a crash or a killed session mid-apply, or a close that ended ok:false before its commit landed)`,
|
|
1655
|
+
];
|
|
1656
|
+
if (oldestMinutes !== null) parts.push(`oldest is ${oldestMinutes} minute(s) old`);
|
|
1657
|
+
if (unreadableCount > 0) {
|
|
1658
|
+
parts.push(
|
|
1659
|
+
`${unreadableCount} could not be read or parsed, so its age is unknown; every no-payload ` +
|
|
1660
|
+
`close probe for this vault treats an unreadable record the same as a torn one (payload ` +
|
|
1661
|
+
`required) until it is removed`,
|
|
1662
|
+
);
|
|
1663
|
+
}
|
|
1664
|
+
warn('Close-intent records', parts.join('; '));
|
|
1290
1665
|
}
|
|
1291
1666
|
|
|
1292
1667
|
function checkCodexPaths() {
|
|
1293
1668
|
const codexHooks = join(HOME, '.codex', 'hooks');
|
|
1294
1669
|
const allFiles = [...Object.values(HOOK_MAP).flat(), ...SHARED_FILES];
|
|
1295
1670
|
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1671
|
+
// Names the files for the same reason checkHooks above does: a count alone
|
|
1672
|
+
// leaves the user re-running upgrade just to find out which one. The severity
|
|
1673
|
+
// stays warn here rather than following checkHooks to fail, because this
|
|
1674
|
+
// channel is opt-in and nothing reads its exit code.
|
|
1675
|
+
const missingFiles = allFiles.filter((file) => !existsSync(join(codexHooks, file)));
|
|
1676
|
+
const missing = missingFiles.length;
|
|
1300
1677
|
|
|
1301
1678
|
if (missing === 0) {
|
|
1302
1679
|
pass('Codex hook files installed', codexHooks);
|
|
1303
1680
|
} else if (missing < allFiles.length) {
|
|
1681
|
+
const examples = missingFiles.slice(0, 3).join(', ');
|
|
1682
|
+
const rest = missing > 3 ? `, +${missing - 3} more` : '';
|
|
1304
1683
|
warn(
|
|
1305
1684
|
'Codex hook files installed',
|
|
1306
|
-
`${missing}/${allFiles.length} missing in ${codexHooks} — run /hypo:init --codex`,
|
|
1685
|
+
`${missing}/${allFiles.length} missing in ${codexHooks}: ${examples}${rest} — run /hypo:init --codex`,
|
|
1307
1686
|
);
|
|
1308
1687
|
} else {
|
|
1309
1688
|
fail(
|
|
@@ -1359,6 +1738,11 @@ function checkCodexPaths() {
|
|
|
1359
1738
|
`0/${total} registered — run /hypo:init --codex`,
|
|
1360
1739
|
);
|
|
1361
1740
|
}
|
|
1741
|
+
reportRetiredRegistrations(
|
|
1742
|
+
'Codex settings.json retired hook registrations',
|
|
1743
|
+
retiredRegistrations(settings, hooksDir),
|
|
1744
|
+
'Run `hypomnema upgrade --apply --codex` to remove them',
|
|
1745
|
+
);
|
|
1362
1746
|
}
|
|
1363
1747
|
|
|
1364
1748
|
// ── extensions integrity (E5) ─────────────────────────────────────
|
|
@@ -2281,13 +2665,56 @@ function checkProvenanceSidecar(hooksDir, label) {
|
|
|
2281
2665
|
// trusts.
|
|
2282
2666
|
//
|
|
2283
2667
|
// Returns null when there is nothing to report (field absent, or digests
|
|
2284
|
-
// match), or a ready-to-append reason string
|
|
2668
|
+
// match), or a ready-to-append reason string: either a diverged-files
|
|
2669
|
+
// mismatch, or the "cannot verify" case below.
|
|
2285
2670
|
function hooksDigestMismatch(hooksDir, recordedDigest) {
|
|
2286
2671
|
if (typeof recordedDigest !== 'string' || !recordedDigest) return null;
|
|
2287
2672
|
const actualDigest = computeHooksDigest(PKG_ROOT, hooksDir);
|
|
2288
|
-
if (actualDigest ===
|
|
2673
|
+
if (actualDigest === recordedDigest) return null;
|
|
2289
2674
|
|
|
2290
2675
|
const allFiles = [...Object.values(HOOK_MAP).flat(), ...SHARED_FILES];
|
|
2676
|
+
|
|
2677
|
+
// computeHooksDigest returns null for two different reasons, and neither is
|
|
2678
|
+
// "the digests matched": either hooks.json itself could not be read from
|
|
2679
|
+
// PKG_ROOT, or one of the files it lists could not be read out of hooksDir.
|
|
2680
|
+
// This branch used to be folded into the early return as
|
|
2681
|
+
// `if (actualDigest === null || actualDigest === recordedDigest) return null`,
|
|
2682
|
+
// which reported a failed recompute as nothing-to-report, so a deleted hook
|
|
2683
|
+
// file still printed `verified`. "Could not verify" has to stay a distinct,
|
|
2684
|
+
// non-silent state from "verified". It is not promoted to fail here because
|
|
2685
|
+
// checkProvenanceSidecar keeps this label warn-or-pass, never fail, same as
|
|
2686
|
+
// every other branch in that function, and checkHooks() above already
|
|
2687
|
+
// names the same deletion as a fail, so the exit code is covered there.
|
|
2688
|
+
//
|
|
2689
|
+
// Of those two causes only the second can reach this line: loadHookInventory
|
|
2690
|
+
// reads the same two JSON files at module load and exits 1 when they are bad,
|
|
2691
|
+
// so a doctor that got this far has a readable config. Naming the files is
|
|
2692
|
+
// still best-effort, because it re-reads them a moment after
|
|
2693
|
+
// computeHooksDigest did: a file that vanished in between shows up in the
|
|
2694
|
+
// digest failure and not in this list. When the list comes back empty the
|
|
2695
|
+
// message says that instead of asserting a count of zero unreadable files,
|
|
2696
|
+
// which would read as "nothing is wrong" on the one line that exists to say
|
|
2697
|
+
// otherwise.
|
|
2698
|
+
if (actualDigest === null) {
|
|
2699
|
+
const unreadable = allFiles.filter((file) => {
|
|
2700
|
+
try {
|
|
2701
|
+
readFileSync(join(hooksDir, file));
|
|
2702
|
+
return false;
|
|
2703
|
+
} catch {
|
|
2704
|
+
return true;
|
|
2705
|
+
}
|
|
2706
|
+
});
|
|
2707
|
+
const which = unreadable.length
|
|
2708
|
+
? `${unreadable.length} hook file(s) unreadable in ${hooksDir} ` +
|
|
2709
|
+
`(e.g. ${unreadable.slice(0, 3).join(', ')})`
|
|
2710
|
+
: `every listed hook file reads back from ${hooksDir} now, so whatever the recompute ` +
|
|
2711
|
+
`could not read has already changed underneath it`;
|
|
2712
|
+
return (
|
|
2713
|
+
`hooksDigest cannot be recomputed. ${which}. Run ` +
|
|
2714
|
+
`${upgradeApplyHint(pluginMode || hypomnemaPluginEnabled)}`
|
|
2715
|
+
);
|
|
2716
|
+
}
|
|
2717
|
+
|
|
2291
2718
|
const diverged = allFiles.filter((file) => {
|
|
2292
2719
|
try {
|
|
2293
2720
|
return !readFileSync(join(hooksDir, file)).equals(readFileSync(join(HOOKS_SRC, file)));
|
|
@@ -2329,13 +2756,9 @@ function checkPkgIntegrity(claudeHome) {
|
|
|
2329
2756
|
// failed ('registry-unreadable'/'unresolved'), init/upgrade skip the very
|
|
2330
2757
|
// same write on every re-run until the registry is fixed first (see
|
|
2331
2758
|
// resolveDurableRoot in init.mjs and the dualSkip branch in upgrade.mjs).
|
|
2332
|
-
const channelJudgmentFailed =
|
|
2333
|
-
hypomnemaPluginEnabled &&
|
|
2334
|
-
(pluginChannel.rootReason === 'registry-unreadable' ||
|
|
2335
|
-
pluginChannel.rootReason === 'unresolved');
|
|
2336
2759
|
warn(
|
|
2337
2760
|
'hypo-pkg.json pkgRoot',
|
|
2338
|
-
channelJudgmentFailed
|
|
2761
|
+
channelJudgmentFailed(pluginChannel)
|
|
2339
2762
|
? `hypo-pkg.json has no pkgRoot field because the enabled plugin's channel judgment failed ` +
|
|
2340
2763
|
`(${pluginChannel.rootReason}): re-running /hypo:init or /hypo:upgrade will skip the same ` +
|
|
2341
2764
|
`write until this resolves. Fix: repair or refresh ~/.claude/plugins/installed_plugins.json ` +
|
|
@@ -2451,6 +2874,7 @@ if (rootOk) {
|
|
|
2451
2874
|
}
|
|
2452
2875
|
checkHooks(coreManagedByPlugin);
|
|
2453
2876
|
checkSettingsJson(coreManagedByPlugin);
|
|
2877
|
+
checkDualInstall(pluginChannel);
|
|
2454
2878
|
checkPkgIntegrity(args.claudeHome);
|
|
2455
2879
|
checkStaleSibling();
|
|
2456
2880
|
checkPluginLeafVersion();
|
|
@@ -2460,9 +2884,11 @@ if (rootOk) checkExtensions(args.hypoDir, args.claudeHome, 'claude');
|
|
|
2460
2884
|
if (rootOk && args.codex) checkExtensions(args.hypoDir, args.claudeHome, 'codex');
|
|
2461
2885
|
if (rootOk) checkProjectIndexAnchors(args.hypoDir);
|
|
2462
2886
|
if (rootOk) checkSessionCloseArtifacts(args.hypoDir);
|
|
2887
|
+
if (rootOk) checkHalfClosedReceipts(args.hypoDir);
|
|
2463
2888
|
if (rootOk) checkSyncState(args.hypoDir);
|
|
2464
2889
|
if (rootOk) checkProjectSuggestions(args.hypoDir);
|
|
2465
2890
|
if (rootOk) checkProposals(args.hypoDir);
|
|
2891
|
+
if (rootOk) checkCloseIntent(args.hypoDir);
|
|
2466
2892
|
if (rootOk) checkFeedbackProjection(args.hypoDir, args.claudeHome, args.projectId);
|
|
2467
2893
|
checkGit(args.hypoDir);
|
|
2468
2894
|
|