hypomnema 1.5.1 → 1.6.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.
@@ -65,11 +65,19 @@
65
65
  * hard-fails regardless of scope.
66
66
  */
67
67
 
68
- import { existsSync, statSync, readFileSync, writeFileSync, mkdirSync, renameSync } from 'fs';
68
+ import {
69
+ existsSync,
70
+ statSync,
71
+ readFileSync,
72
+ writeFileSync,
73
+ mkdirSync,
74
+ renameSync,
75
+ realpathSync,
76
+ } from 'fs';
69
77
  import { join, dirname } from 'path';
70
78
  import { hostname } from 'os';
71
79
  import { spawnSync } from 'child_process';
72
- import { fileURLToPath } from 'url';
80
+ import { fileURLToPath, pathToFileURL } from 'url';
73
81
  import { resolveHypoRoot, expandHome } from './lib/hypo-root.mjs';
74
82
  import { loadHypoIgnore } from './lib/hypo-ignore.mjs';
75
83
  import { collectPagesCrystallize, extractWikilinks } from './lib/wikilink.mjs';
@@ -645,6 +653,67 @@ function runMarkSessionClosed(args) {
645
653
  process.exit(0);
646
654
  }
647
655
 
656
+ // The close pipeline's marker decision as a PURE function of pre-resolved
657
+ // signals. The caller (applySessionClose) keeps the IO lazy (commit first, then
658
+ // transcript resolve, then gate, then user-signal scan only once the gate
659
+ // passes) and feeds the resulting booleans here; this function owns only the
660
+ // branch PRIORITY and the reason strings, so a deterministic table test can
661
+ // exercise the state machine without spawning the
662
+ // CLI. Returns { write, skipReason }: `write` true means the caller should
663
+ // attempt writeSessionClosedMarker; `skipReason` (non-null on every non-write
664
+ // branch) is the surfaced reason the marker was withheld.
665
+ // - !ok / no session id → not a marker-write path (skipReason null)
666
+ // - commit failed → commit-failed: <reason>
667
+ // - compact gate not ok → compact-gate-not-ok
668
+ // - no transcript → transcript-unresolved
669
+ // - transcript, no signal → no-user-close-signal
670
+ // - all clear → write:true
671
+ export function planMarkerDecision({
672
+ ok,
673
+ hasSessionId,
674
+ committed,
675
+ commitReason,
676
+ gateOk,
677
+ transcriptResolved,
678
+ hasUserSignal,
679
+ }) {
680
+ if (!ok || !hasSessionId) return { write: false, skipReason: null };
681
+ if (!committed) return { write: false, skipReason: `commit-failed: ${commitReason}` };
682
+ if (!gateOk) return { write: false, skipReason: 'compact-gate-not-ok' };
683
+ if (!transcriptResolved || !hasUserSignal) {
684
+ return {
685
+ write: false,
686
+ skipReason: transcriptResolved ? 'no-user-close-signal' : 'transcript-unresolved',
687
+ };
688
+ }
689
+ return { write: true, skipReason: null };
690
+ }
691
+
692
+ // The runtime close-result invariant self-check. Given
693
+ // the settled marker fields, return a non-null contradiction tag when the result
694
+ // is internally inconsistent, else null. This is a REGRESSION GUARD: every
695
+ // non-write branch of planMarkerDecision already records a reason today, so the
696
+ // contradictions below are unreachable — the seam exists so a future branch that
697
+ // forgets to record a reason (or double-sets a written marker with a skip
698
+ // reason) fails LOUDLY (ok flipped false, exit 1) instead of silently emitting a
699
+ // misleading ok:true that a skill-following model reads as "session closed".
700
+ // A "real reason" is a non-blank STRING — every legitimate skip reason is one.
701
+ // Anything else (null, a blank string, or a non-string like false/0/{}) is not a
702
+ // surfaced reason and must not suppress the check, so a future bad assignment
703
+ // cannot hide a withheld marker behind a bogus value.
704
+ // A: ok:true but the marker was withheld with no reason recorded.
705
+ // B: a marker was written AND a skip reason was also set (mutually exclusive).
706
+ export function closeResultContradiction({ ok, markerWritten, markerSkipReason }) {
707
+ const hasRealReason = typeof markerSkipReason === 'string' && markerSkipReason.trim() !== '';
708
+ if (ok === true && markerWritten === false && !hasRealReason) {
709
+ return 'internal-contradiction:marker-withheld-without-reason';
710
+ }
711
+ if (markerWritten === true && hasRealReason) {
712
+ return 'internal-contradiction:marker-written-with-skip-reason';
713
+ }
714
+ return null;
715
+ }
716
+
648
717
  function applySessionClose(args) {
649
718
  // Option D: early-exit fires only when NO payload was supplied.
650
719
  // Rationale: payload presence is explicit close intent and must always run
@@ -1067,7 +1136,9 @@ function applySessionClose(args) {
1067
1136
  ));
1068
1137
  }
1069
1138
  const postLintOk = !postApplyCrashed && postBlocking.length === 0;
1070
- const ok = verification.ok && postLintOk;
1139
+ // `let` (not const): the close-result invariant self-check below may flip this
1140
+ // to false when the settled close result is internally contradictory.
1141
+ let ok = verification.ok && postLintOk;
1071
1142
 
1072
1143
  // Scope the non-blocking notice to the close-target project: debt under
1073
1144
  // projects/<project>/ stays listed; debt elsewhere folds to a count so the
@@ -1100,47 +1171,77 @@ function applySessionClose(args) {
1100
1171
  let markerWritten = false;
1101
1172
  let markerSkipReason = null;
1102
1173
  if (ok && args.sessionId) {
1174
+ // IO stays lazy so this preserves the exact side-effect order (codex design
1175
+ // review): commit first (the only mutation), then resolve the
1176
+ // transcript, then run the compact gate with that transcript, then scan the
1177
+ // user-close signal ONLY once the gate passes. planMarkerDecision owns the
1178
+ // branch priority + reason strings; the booleans below are computed in that
1179
+ // same short-circuiting order so no read runs earlier than it does today.
1103
1180
  const commitOutcome = commitWikiChanges(args.hypoDir);
1104
- if (!commitOutcome.committed) {
1105
- markerSkipReason = `commit-failed: ${commitOutcome.reason}`;
1106
- } else {
1107
- const closeTranscript = resolveTranscriptBySessionId(args.sessionId);
1108
- const markerGate = precompactGateStatus(
1181
+ let closeTranscript = null;
1182
+ let gateOk = false;
1183
+ if (commitOutcome.committed) {
1184
+ closeTranscript = resolveTranscriptBySessionId(args.sessionId);
1185
+ gateOk = precompactGateStatus(
1109
1186
  args.hypoDir,
1110
1187
  closeTranscript ? { transcriptPath: closeTranscript } : {},
1111
- );
1112
- if (!markerGate.ok) {
1113
- // compact gate not ok → skip. Caller's `result.ok` already reflects the
1114
- // file/lint state; next Stop re-blocks until the remaining blocker
1115
- // (feedback/W8/hot/lint — git is now committed) is resolved.
1116
- markerSkipReason = 'compact-gate-not-ok';
1117
- } else if (!closeTranscript || !hasUserCloseSignal(closeTranscript)) {
1118
- // User-close hard gate: apply succeeded (payload files written)
1119
- // but the user never signalled session close, so the marker — which attests
1120
- // "user closed" is withheld. The wiki record stands; the session is simply
1121
- // not marked closed. Surfaced (not silent) so the caller knows.
1122
- markerSkipReason = closeTranscript ? 'no-user-close-signal' : 'transcript-unresolved';
1188
+ ).ok;
1189
+ }
1190
+ const decision = planMarkerDecision({
1191
+ ok,
1192
+ hasSessionId: true,
1193
+ committed: commitOutcome.committed,
1194
+ commitReason: commitOutcome.reason,
1195
+ gateOk,
1196
+ transcriptResolved: !!closeTranscript,
1197
+ // Scan the signal only when the gate passed AND a transcript resolved
1198
+ // hasUserCloseSignal never runs earlier than the original nested `else if`.
1199
+ hasUserSignal: gateOk && !!closeTranscript && hasUserCloseSignal(closeTranscript),
1200
+ });
1201
+ markerSkipReason = decision.skipReason;
1202
+ if (decision.write) {
1203
+ writeSessionClosedMarker(args.hypoDir, args.sessionId, { project });
1204
+ // Codex CONCERN: the writer swallows IO errors (best-effort).
1205
+ // Verify the file actually landed — mirroring the standalone path — instead of
1206
+ // asserting markerWritten=true, so a .cache permission/disk problem surfaces
1207
+ // rather than the caller reporting "closed" while the next Stop re-blocks.
1208
+ if (existsSync(sessionClosedMarkerPath(args.hypoDir, args.sessionId))) {
1209
+ markerWritten = true;
1123
1210
  } else {
1124
- writeSessionClosedMarker(args.hypoDir, args.sessionId, { project });
1125
- // Codex CONCERN: the writer swallows IO errors (best-effort).
1126
- // Verify the file actually landed — mirroring the standalone path — instead of
1127
- // asserting markerWritten=true, so a .cache permission/disk problem surfaces
1128
- // rather than the caller reporting "closed" while the next Stop re-blocks.
1129
- if (existsSync(sessionClosedMarkerPath(args.hypoDir, args.sessionId))) {
1130
- markerWritten = true;
1131
- } else {
1132
- markerSkipReason = 'marker-did-not-land';
1133
- }
1211
+ markerSkipReason = 'marker-did-not-land';
1134
1212
  }
1135
1213
  }
1136
1214
  }
1137
- const stage = ok
1215
+ let stage = ok
1138
1216
  ? null
1139
1217
  : !verification.ok && !postLintOk
1140
1218
  ? 'post-apply-verification+lint'
1141
1219
  : !verification.ok
1142
1220
  ? 'post-apply-verification'
1143
1221
  : 'post-apply-lint';
1222
+ // Runtime close-result invariant self-check. When a
1223
+ // marker-write path (args.sessionId present) settles into an internally
1224
+ // contradictory shape — ok:true with the marker silently withheld and no
1225
+ // reason, or a written marker that also carries a skip reason — flip ok:false
1226
+ // and stage-tag it so the existing `process.exit(ok ? 0 : 1)` yields exit 1.
1227
+ // That non-zero exit is the discriminator that separates a genuine
1228
+ // contradiction (a code bug) from a legitimate withhold (exit 0, e.g.
1229
+ // no-user-close-signal). apply is idempotent, so a non-zero re-run is safe.
1230
+ // Unreachable today; this is a regression guard for future refactors.
1231
+ if (args.sessionId) {
1232
+ const contradiction = closeResultContradiction({ ok, markerWritten, markerSkipReason });
1233
+ if (contradiction) {
1234
+ ok = false;
1235
+ stage = contradiction;
1236
+ process.stderr.write(
1237
+ `\n🛑 INTERNAL CONTRADICTION in session-close result: ${contradiction}\n` +
1238
+ ` markerWritten=${markerWritten}, markerSkipReason=${JSON.stringify(markerSkipReason)}.\n` +
1239
+ ` This is a close-pipeline bug, not a normal withhold. Exiting non-zero so it\n` +
1240
+ ` cannot masquerade as a successful close. The applied payload files stand;\n` +
1241
+ ` re-running apply is idempotent once the pipeline is fixed.\n`,
1242
+ );
1243
+ }
1244
+ }
1144
1245
  const result = {
1145
1246
  ok,
1146
1247
  stage,
@@ -1189,9 +1290,11 @@ function applySessionClose(args) {
1189
1290
  // When ok:true but the session-close marker was NOT written, the Stop-chain
1190
1291
  // still sees an open session and will re-prompt at the next Stop. Surface this
1191
1292
  // loudly so neither the human nor a skill-following model reads "ok:true" as
1192
- // "session fully closed". Gate on markerSkipReason (non-null exactly when
1193
- // args.sessionId is present and the marker was withheld).
1194
- if (markerSkipReason) {
1293
+ // "session fully closed". Gate on `!markerWritten` too so this "marker NOT
1294
+ // written" line cannot fire on the contradiction-B path (a written marker that
1295
+ // also carried a skip reason) — there the invariant's own 🛑 line already
1296
+ // explains the failure, and this message would contradict markerWritten:true.
1297
+ if (markerSkipReason && !markerWritten) {
1195
1298
  process.stderr.write(
1196
1299
  `\n⚠️ session-close marker NOT written (reason: ${markerSkipReason})\n` +
1197
1300
  ` The 5 mandatory files were applied and verified (ok:true), but the\n` +
@@ -1287,119 +1390,133 @@ function parseTags(fm) {
1287
1390
 
1288
1391
  // ── main ─────────────────────────────────────────────────────────────────────
1289
1392
 
1290
- const args = parseArgs(process.argv);
1291
-
1292
- if (args.markSessionClosed) {
1293
- runMarkSessionClosed(args); // exits
1294
- }
1295
-
1296
- if (args.applySessionClose) {
1297
- applySessionClose(args); // exits
1393
+ // Guard the CLI dispatch behind an entry check: the module now exports
1394
+ // pure functions (planMarkerDecision / closeResultContradiction) that the test
1395
+ // runner imports, and importing must NOT execute the CLI (its process.exit would
1396
+ // kill the runner). Mirror feedback-sync.mjs's realpath + pathToFileURL guard so
1397
+ // `node crystallize.mjs …` still runs main() while `import` does not.
1398
+ function isMain() {
1399
+ if (!process.argv[1]) return false;
1400
+ return pathToFileURL(realpathSync(process.argv[1])).href === import.meta.url;
1298
1401
  }
1299
1402
 
1300
- if (args.checkSessionClose) {
1301
- runSessionCloseCheck(args); // exits
1302
- }
1403
+ function main() {
1404
+ const args = parseArgs(process.argv);
1303
1405
 
1304
- const ignorePatterns = loadHypoIgnore(args.hypoDir);
1305
- const pagesDir = join(args.hypoDir, 'pages');
1306
- const pages = collectPagesCrystallize(pagesDir, args.hypoDir, ignorePatterns);
1406
+ if (args.markSessionClosed) {
1407
+ runMarkSessionClosed(args); // exits
1408
+ }
1307
1409
 
1308
- const tagGroups = {}; // tag → [{ slug, title }]
1309
- const unlinked = []; // pages with no outbound wikilinks
1310
- const drafts = []; // pages tagged draft
1410
+ if (args.applySessionClose) {
1411
+ applySessionClose(args); // exits
1412
+ }
1311
1413
 
1312
- for (const { path, rel } of pages) {
1313
- let content;
1314
- try {
1315
- content = readFileSync(path, 'utf-8');
1316
- } catch {
1317
- continue;
1414
+ if (args.checkSessionClose) {
1415
+ runSessionCloseCheck(args); // exits
1318
1416
  }
1319
- const fm = parseFrontmatter(content);
1320
- if (!fm) continue;
1321
1417
 
1322
- const slug = rel.replace(/\.md$/, '');
1323
- const title = fm.title || slug;
1324
- const tags = parseTags(fm);
1418
+ const ignorePatterns = loadHypoIgnore(args.hypoDir);
1419
+ const pagesDir = join(args.hypoDir, 'pages');
1420
+ const pages = collectPagesCrystallize(pagesDir, args.hypoDir, ignorePatterns);
1325
1421
 
1326
- // tag groups
1327
- for (const tag of tags) {
1328
- if (!tagGroups[tag]) tagGroups[tag] = [];
1329
- tagGroups[tag].push({ slug, title });
1330
- }
1422
+ const tagGroups = {}; // tag → [{ slug, title }]
1423
+ const unlinked = []; // pages with no outbound wikilinks
1424
+ const drafts = []; // pages tagged draft
1425
+
1426
+ for (const { path, rel } of pages) {
1427
+ let content;
1428
+ try {
1429
+ content = readFileSync(path, 'utf-8');
1430
+ } catch {
1431
+ continue;
1432
+ }
1433
+ const fm = parseFrontmatter(content);
1434
+ if (!fm) continue;
1435
+
1436
+ const slug = rel.replace(/\.md$/, '');
1437
+ const title = fm.title || slug;
1438
+ const tags = parseTags(fm);
1439
+
1440
+ // tag groups
1441
+ for (const tag of tags) {
1442
+ if (!tagGroups[tag]) tagGroups[tag] = [];
1443
+ tagGroups[tag].push({ slug, title });
1444
+ }
1331
1445
 
1332
- // draft detection
1333
- if (tags.includes('draft') || fm.confidence === 'speculative') {
1334
- drafts.push({ slug, title, confidence: fm.confidence });
1446
+ // draft detection
1447
+ if (tags.includes('draft') || fm.confidence === 'speculative') {
1448
+ drafts.push({ slug, title, confidence: fm.confidence });
1449
+ }
1450
+
1451
+ // unlinked (no outbound wikilinks in body)
1452
+ const body = content.replace(/^---[\s\S]*?---/, '');
1453
+ const links = extractWikilinks(body);
1454
+ if (links.length === 0) unlinked.push({ slug, title });
1335
1455
  }
1336
1456
 
1337
- // unlinked (no outbound wikilinks in body)
1338
- const body = content.replace(/^---[\s\S]*?---/, '');
1339
- const links = extractWikilinks(body);
1340
- if (links.length === 0) unlinked.push({ slug, title });
1341
- }
1457
+ // filter tag groups by min-group
1458
+ const synthesisGroups = Object.entries(tagGroups)
1459
+ .filter(([, pages]) => pages.length >= args.minGroup)
1460
+ .sort((a, b) => b[1].length - a[1].length)
1461
+ .map(([tag, pages]) => ({ tag, pages }));
1342
1462
 
1343
- // filter tag groups by min-group
1344
- const synthesisGroups = Object.entries(tagGroups)
1345
- .filter(([, pages]) => pages.length >= args.minGroup)
1346
- .sort((a, b) => b[1].length - a[1].length)
1347
- .map(([tag, pages]) => ({ tag, pages }));
1463
+ // Lookup-cold candidates (B): pages with inbound wikilinks that lookup has not
1464
+ // injected within the recency window. Advisory only, never gates, never mutates.
1465
+ const coldCandidates = aggregateColdCandidates(args.hypoDir, { ignorePatterns });
1348
1466
 
1349
- // Lookup-cold candidates (B): pages with inbound wikilinks that lookup has not
1350
- // injected within the recency window. Advisory only, never gates, never mutates.
1351
- const coldCandidates = aggregateColdCandidates(args.hypoDir, { ignorePatterns });
1467
+ if (args.json) {
1468
+ console.log(JSON.stringify({ synthesisGroups, unlinked, drafts, coldCandidates }, null, 2));
1469
+ process.exit(0);
1470
+ }
1352
1471
 
1353
- if (args.json) {
1354
- console.log(JSON.stringify({ synthesisGroups, unlinked, drafts, coldCandidates }, null, 2));
1355
- process.exit(0);
1356
- }
1472
+ let found = false;
1357
1473
 
1358
- let found = false;
1474
+ if (synthesisGroups.length > 0) {
1475
+ found = true;
1476
+ console.log(`Synthesis candidates by tag (${synthesisGroups.length} group(s)):\n`);
1477
+ for (const { tag, pages: grp } of synthesisGroups) {
1478
+ console.log(` [${tag}] (${grp.length} pages):`);
1479
+ for (const p of grp) console.log(` [[${p.slug}]] — ${p.title}`);
1480
+ }
1481
+ console.log('');
1482
+ }
1359
1483
 
1360
- if (synthesisGroups.length > 0) {
1361
- found = true;
1362
- console.log(`Synthesis candidates by tag (${synthesisGroups.length} group(s)):\n`);
1363
- for (const { tag, pages: grp } of synthesisGroups) {
1364
- console.log(` [${tag}] (${grp.length} pages):`);
1365
- for (const p of grp) console.log(` [[${p.slug}]] — ${p.title}`);
1484
+ if (unlinked.length > 0) {
1485
+ found = true;
1486
+ console.log(`Unlinked pages (no outbound [[wikilinks]]) — ${unlinked.length}:`);
1487
+ for (const p of unlinked) console.log(` [[${p.slug}]] ${p.title}`);
1488
+ console.log('');
1366
1489
  }
1367
- console.log('');
1368
- }
1369
1490
 
1370
- if (unlinked.length > 0) {
1371
- found = true;
1372
- console.log(`Unlinked pages (no outbound [[wikilinks]]) — ${unlinked.length}:`);
1373
- for (const p of unlinked) console.log(` [[${p.slug}]] — ${p.title}`);
1374
- console.log('');
1375
- }
1491
+ if (drafts.length > 0) {
1492
+ found = true;
1493
+ console.log(`Draft/speculative pages ready to crystallize — ${drafts.length}:`);
1494
+ for (const p of drafts) console.log(` [[${p.slug}]] — ${p.title}`);
1495
+ console.log('');
1496
+ }
1376
1497
 
1377
- if (drafts.length > 0) {
1378
- found = true;
1379
- console.log(`Draft/speculative pages ready to crystallize ${drafts.length}:`);
1380
- for (const p of drafts) console.log(` [[${p.slug}]] — ${p.title}`);
1381
- console.log('');
1382
- }
1498
+ // Advisory (non-gating): pages the graph treats as live but lookup has not
1499
+ // injected recently. Held until enough page-usage history accrues.
1500
+ if (coldCandidates.status === 'ok' && coldCandidates.candidates.length > 0) {
1501
+ found = true;
1502
+ console.log(
1503
+ `Lookup-cold pages (${coldCandidates.candidates.length}), inbound links but not injected recently:`,
1504
+ );
1505
+ for (const p of coldCandidates.candidates) console.log(` [[${p.slug}]] (${p.title})`);
1506
+ console.log('');
1507
+ } else if (
1508
+ coldCandidates.status === 'insufficient-data' &&
1509
+ coldCandidates.reason === 'span-too-short'
1510
+ ) {
1511
+ // Only surface the "held" notice once a log is actually accruing (span under
1512
+ // the cold-start window). A vault with no log at all stays silent so this
1513
+ // advisory never becomes permanent noise on every crystallize run.
1514
+ console.log('Lookup-cold scan held: not enough page-usage history yet (advisory).\n');
1515
+ }
1383
1516
 
1384
- // Advisory (non-gating): pages the graph treats as live but lookup has not
1385
- // injected recently. Held until enough page-usage history accrues.
1386
- if (coldCandidates.status === 'ok' && coldCandidates.candidates.length > 0) {
1387
- found = true;
1388
- console.log(
1389
- `Lookup-cold pages (${coldCandidates.candidates.length}), inbound links but not injected recently:`,
1390
- );
1391
- for (const p of coldCandidates.candidates) console.log(` [[${p.slug}]] (${p.title})`);
1392
- console.log('');
1393
- } else if (
1394
- coldCandidates.status === 'insufficient-data' &&
1395
- coldCandidates.reason === 'span-too-short'
1396
- ) {
1397
- // Only surface the "held" notice once a log is actually accruing (span under
1398
- // the cold-start window). A vault with no log at all stays silent so this
1399
- // advisory never becomes permanent noise on every crystallize run.
1400
- console.log('Lookup-cold scan held: not enough page-usage history yet (advisory).\n');
1517
+ if (!found) {
1518
+ console.log('✓ No crystallization candidates found Hypomnema looks well-connected.');
1519
+ }
1401
1520
  }
1402
1521
 
1403
- if (!found) {
1404
- console.log('✓ No crystallization candidates found — Hypomnema looks well-connected.');
1405
- }
1522
+ if (isMain()) main();
@@ -28,6 +28,9 @@ import {
28
28
  readExtensionPkgStateNoMutate,
29
29
  collectOurOccurrences,
30
30
  pickCanonicalOccurrence,
31
+ resolveInstallFile,
32
+ buildHookCommand,
33
+ parseExtKey,
31
34
  EXT_TYPES,
32
35
  CODEX_TYPES,
33
36
  } from './lib/extensions.mjs';
@@ -865,13 +868,76 @@ function checkExtensions(hypoDir, claudeHome, target = 'claude') {
865
868
  // manifest itself, never naming the stale settings entry.
866
869
  // Surfaced separately so the user knows the lingering
867
870
  // entry needs cleanup independent of the manifest fix.
868
- const cmdFor = (ext) => `node ${hooksDir.replace(HOME, '$HOME')}/${ext.file}`;
869
- const sourceCmds = new Set(discovered.hooks.map(cmdFor));
871
+ // Reconstruct each discovered hook's registered command through the SAME
872
+ // installName-aware path the forward sync uses (resolveInstallFile +
873
+ // buildHookCommand), so a reverse-captured hook registered under its original
874
+ // name is matched here too, not just wiki hooks. A wiki hook (no installName)
875
+ // resolves back to `ext.file`, so this is byte-identical to the old shape (no
876
+ // regression). An invalid installName resolves to `{skip}` → no command.
877
+ const cmdFor = (ext) => {
878
+ const resolved = resolveInstallFile(ext);
879
+ return resolved.skip ? null : buildHookCommand(hooksDir, resolved.installFile);
880
+ };
881
+ const sourceCmds = new Set(discovered.hooks.map(cmdFor).filter(Boolean));
870
882
  const unregistrableCmds = new Set();
871
883
  for (const ext of discovered.hooks) {
872
884
  if (!ext.manifestPath) continue; // (c-warn) already names this case
873
885
  const parsed = parseManifest(ext.manifestPath);
874
- if (!parsed.ok || !parsed.registrable) unregistrableCmds.add(cmdFor(ext));
886
+ if (!parsed.ok || !parsed.registrable) {
887
+ const cmd = cmdFor(ext);
888
+ if (cmd) unregistrableCmds.add(cmd);
889
+ // Manifest-invalid fallback (codex pre-commit CONCERN): when the manifest
890
+ // is unparseable, resolveInstallFile falls back to the wiki storage name,
891
+ // so cmdFor no longer reproduces the command this hook was actually
892
+ // registered under (its installName). Recover the real registered command
893
+ // by matching the wiki source SHA against the recorded owned-set: forward
894
+ // sync records each install `.mjs` key under the SHA of its wiki source, so
895
+ // an equal SHA identifies the true install path even after the manifest
896
+ // breaks. Without this the lingering installName entry is misreported as
897
+ // "source extension removed" though the source is present. Guarded on
898
+ // `!sourceCmds.has` so a command a healthy source already owns is never
899
+ // reclassified as unregistrable.
900
+ if (!parsed.ok) {
901
+ const srcBuf = readFileIfRegular(ext.srcPath);
902
+ if (srcBuf) {
903
+ const srcSha = sha256(srcBuf);
904
+ // The pkg map stores only the source SHA, not the source identity, so
905
+ // this linkage is trustworthy ONLY when the SHA uniquely names one
906
+ // recorded install key. If two or more recorded `.mjs` keys carry this
907
+ // same SHA (e.g. a source-removed hook and a malformed hook with
908
+ // byte-identical content), the match is ambiguous: reclassifying it
909
+ // would misreport the source-removed hook as "manifest unregistrable".
910
+ // Collect the matching commands first and only reclassify when exactly
911
+ // one exists; leave the ambiguous case to the source-removed path.
912
+ const matched = [];
913
+ for (const [key, sha] of Object.entries(recorded)) {
914
+ if (sha !== srcSha) continue;
915
+ const pk = parseExtKey(key, types);
916
+ if (!pk || pk.type !== 'hooks' || !pk.installFile.endsWith('.mjs')) continue;
917
+ matched.push(buildHookCommand(hooksDir, pk.installFile));
918
+ }
919
+ if (matched.length === 1 && !sourceCmds.has(matched[0])) {
920
+ unregistrableCmds.add(matched[0]);
921
+ }
922
+ }
923
+ }
924
+ }
925
+ }
926
+ // Owned-command set for the orphan prefilter (P2): the hypo-ext-* regex below
927
+ // only recognizes wiki-storage-name commands. A reverse-captured hook registers
928
+ // under its original name and carries no hypo-ext-* marker, so widen the
929
+ // prefilter with the recorded per-target owned commands (regex OR owned-set).
930
+ // Reuses the already-loaded `recorded` map, no second read. Only `.mjs` keys
931
+ // are commands; the paired `.manifest.json` sidecar keys are dropped. Known
932
+ // boundary (plan): if the user hand-removes both the source and the recorded
933
+ // key, the lingering entry is no longer classifiable: an accepted limit, not
934
+ // a fabricated marker.
935
+ const ownedHookCmds = new Set();
936
+ for (const key of Object.keys(recorded)) {
937
+ const parsed = parseExtKey(key, types);
938
+ if (!parsed || parsed.type !== 'hooks') continue;
939
+ if (!parsed.installFile.endsWith('.mjs')) continue;
940
+ ownedHookCmds.add(buildHookCommand(hooksDir, parsed.installFile));
875
941
  }
876
942
  // A single hypo-ext-* command can appear
877
943
  // in multiple groups/events when settings.json was hand-edited or migrated
@@ -891,7 +957,8 @@ function checkExtensions(hypoDir, claudeHome, target = 'claude') {
891
957
  if (!Array.isArray(g.hooks)) continue;
892
958
  for (const h of g.hooks) {
893
959
  if (typeof h.command !== 'string') continue;
894
- if (!/(?:^|[/\s])hypo-ext-[^/\s]+\.mjs(?=$|["'\s])/.test(h.command)) continue;
960
+ const isHypoExt = /(?:^|[/\s])hypo-ext-[^/\s]+\.mjs(?=$|["'\s])/.test(h.command);
961
+ if (!isHypoExt && !ownedHookCmds.has(h.command)) continue;
895
962
  let kind = null;
896
963
  if (unregistrableCmds.has(h.command)) kind = 'unregistrable';
897
964
  else if (!sourceCmds.has(h.command)) kind = 'source-removed';
package/scripts/init.mjs CHANGED
@@ -38,6 +38,7 @@ import { execSync, spawnSync } from 'child_process';
38
38
  import { fileURLToPath } from 'url';
39
39
  import { createHash } from 'crypto';
40
40
  import { expandHome, resolveHypoRoot } from './lib/hypo-root.mjs';
41
+ import { readCoreHooksConfig } from './lib/core-hooks.mjs';
41
42
  import {
42
43
  readPkgJson as readPkgJsonSafe,
43
44
  writePkgJsonAtomic,
@@ -85,7 +86,14 @@ function sha256(buf) {
85
86
  // keeps running init for backwards compatibility — that's the documented
86
87
  // Path-B onboarding command. An explicit `hypomnema init` is accepted too,
87
88
  // and is stripped before flag parsing so the rest of this file is unchanged.
88
- const KNOWN_SUBCOMMANDS = new Set(['init', 'upgrade', 'doctor', 'uninstall', 'feedback-sync']);
89
+ const KNOWN_SUBCOMMANDS = new Set([
90
+ 'init',
91
+ 'upgrade',
92
+ 'doctor',
93
+ 'uninstall',
94
+ 'feedback-sync',
95
+ 'capture',
96
+ ]);
89
97
  const _verb = process.argv[2];
90
98
  if (_verb && KNOWN_SUBCOMMANDS.has(_verb) && _verb !== 'init') {
91
99
  const _target = join(SCRIPT_DIR, `${_verb}.mjs`);
@@ -296,14 +304,17 @@ function writeGitignore(hypoDir, dryRun) {
296
304
  // ── hook installation ────────────────────────────────────────────────────────
297
305
 
298
306
  function loadHookMap() {
299
- let cfg;
300
- try {
301
- cfg = JSON.parse(readFileSync(join(PKG_ROOT, 'hooks', 'hooks.json'), 'utf-8'));
302
- } catch {
307
+ // Read + parse via the exit-free shared helper; keep init's own validation and
308
+ // exit(1) behavior below. The helper omits `cfg` only on read/parse failure
309
+ // (JSON.parse never yields undefined), so key presence discriminates a
310
+ // read/parse failure from a parsed-but-malformed shape.
311
+ const res = readCoreHooksConfig(PKG_ROOT);
312
+ if (!('cfg' in res)) {
303
313
  console.error(`Error: cannot read hooks/hooks.json from package root: ${PKG_ROOT}`);
304
314
  console.error(PKG_INTEGRITY_HINT);
305
315
  process.exit(1);
306
316
  }
317
+ const cfg = res.cfg;
307
318
  if (!cfg || typeof cfg !== 'object' || Array.isArray(cfg)) {
308
319
  console.error('Error: hooks/hooks.json must be a JSON object');
309
320
  console.error(PKG_INTEGRITY_HINT);