hypomnema 1.5.0 → 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,14 +65,23 @@
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';
84
+ import { aggregateColdCandidates } from './lib/page-usage.mjs';
76
85
  import { isValidProjectName } from './lib/project-create.mjs';
77
86
  import { appendPendingTags, checkForbidden } from './lib/schema-vocab.mjs';
78
87
  import {
@@ -88,6 +97,8 @@ import {
88
97
  sessionLogReadCandidates,
89
98
  sessionLogScopePath,
90
99
  rootLogEntry,
100
+ hasSessionLogHeading,
101
+ hasLogEntry,
91
102
  resolveTranscriptBySessionId,
92
103
  hasUserCloseSignal,
93
104
  commitWikiChanges,
@@ -642,6 +653,67 @@ function runMarkSessionClosed(args) {
642
653
  process.exit(0);
643
654
  }
644
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
+
645
717
  function applySessionClose(args) {
646
718
  // Option D: early-exit fires only when NO payload was supplied.
647
719
  // Rationale: payload presence is explicit close intent and must always run
@@ -753,21 +825,49 @@ function applySessionClose(args) {
753
825
  }
754
826
  const date = payload.date || todayLocal();
755
827
 
756
- // B-1 derive precondition: when `log` is omitted, apply reconstructs the root
757
- // log.md entry from THIS close's sessionLog heading. If sessionLog.entry has no
758
- // `## [<date>] …` heading there is nothing to derive — and on a same-day SECOND
759
- // close the date-level freshness verifier would still pass on the earlier
760
- // close's entry, so the no-write would slip through as ok:true. Fail loud here,
761
- // before any writes (codex pre-commit review).
762
- if (
763
- !payload.log &&
764
- !new RegExp(`^#{1,6} \\[${date}\\]`, 'm').test(payload.sessionLog.entry || '')
765
- ) {
766
- const msg =
767
- `payload.sessionLog.entry has no "## [${date}] …" heading to derive the log.md ` +
768
- `entry from. Give it a dated heading, or supply payload.log explicitly.`;
769
- console.log(args.json ? JSON.stringify({ ok: false, error: msg }, null, 2) : `✗ ${msg}`);
828
+ // Pre-apply freshness-contract gate: the post-apply verification holds
829
+ // sessionCloseFileStatus's hasSessionLogHeading / hasLogEntry as the
830
+ // definition of "closed today". Enforce that SAME contract on the payload
831
+ // BEFORE writing a byte, so a heading the gate won't recognize is rejected
832
+ // here as a format mismatch not written and then misdiagnosed downstream as
833
+ // "stale" (the "not updated" vs "format mismatch" conflation). All checks
834
+ // exit 1 with stage='pre-apply-verification' and leave the tree untouched.
835
+ const failPreApply = (msg) => {
836
+ console.log(
837
+ args.json
838
+ ? JSON.stringify({ ok: false, stage: 'pre-apply-verification', error: msg }, null, 2)
839
+ : `✗ ${msg}`,
840
+ );
770
841
  process.exit(1);
842
+ };
843
+ // (a) The session-log entry must carry a dated `## [<date>] …` ATX heading. The
844
+ // post-apply gate checks the session-log file for exactly this heading, so a
845
+ // headingless entry would write then false-fail as "stale". This also doubles
846
+ // as the B-1 derive precondition: when `log` is omitted the root log.md entry
847
+ // is reconstructed from THIS heading, and on a same-day SECOND close the
848
+ // date-level verifier would still pass on the earlier entry, so a no-derive
849
+ // would slip through as ok:true. The `!payload.log` branch keeps the original
850
+ // derive-specific wording (a test asserts it).
851
+ if (!hasSessionLogHeading(payload.sessionLog.entry || '', date)) {
852
+ failPreApply(
853
+ !payload.log
854
+ ? `payload.sessionLog.entry has no "## [${date}] …" heading to derive the log.md ` +
855
+ `entry from. Give it a dated heading, or supply payload.log explicitly.`
856
+ : `payload.sessionLog.entry has no "## [${date}] …" heading. The close gate ` +
857
+ `identifies a session-log by its dated ATX heading; give the entry a ` +
858
+ `"## [${date}] <title>" heading (the brackets are required).`,
859
+ );
860
+ }
861
+ // (b) An explicit payload.log entry must match the canonical
862
+ // `## [<date>] session | <project>` line the gate looks for (colon or space
863
+ // delimiter after the slug). Otherwise the write lands but post-apply
864
+ // verification reports log.md as stale. When `log` is omitted the line is
865
+ // derived canonically (rootLogEntry) so this cannot mismatch.
866
+ if (payload.log && !hasLogEntry(payload.log.entry || '', date, project)) {
867
+ failPreApply(
868
+ `payload.log.entry has no "## [${date}] session | ${project}" heading that the ` +
869
+ `close gate recognizes. Fix the entry heading, or omit payload.log to derive it.`,
870
+ );
771
871
  }
772
872
 
773
873
  // Preflight: lint the wiki BEFORE writing any payload bytes. If lint
@@ -1036,7 +1136,9 @@ function applySessionClose(args) {
1036
1136
  ));
1037
1137
  }
1038
1138
  const postLintOk = !postApplyCrashed && postBlocking.length === 0;
1039
- 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;
1040
1142
 
1041
1143
  // Scope the non-blocking notice to the close-target project: debt under
1042
1144
  // projects/<project>/ stays listed; debt elsewhere folds to a count so the
@@ -1069,47 +1171,77 @@ function applySessionClose(args) {
1069
1171
  let markerWritten = false;
1070
1172
  let markerSkipReason = null;
1071
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.
1072
1180
  const commitOutcome = commitWikiChanges(args.hypoDir);
1073
- if (!commitOutcome.committed) {
1074
- markerSkipReason = `commit-failed: ${commitOutcome.reason}`;
1075
- } else {
1076
- const closeTranscript = resolveTranscriptBySessionId(args.sessionId);
1077
- const markerGate = precompactGateStatus(
1181
+ let closeTranscript = null;
1182
+ let gateOk = false;
1183
+ if (commitOutcome.committed) {
1184
+ closeTranscript = resolveTranscriptBySessionId(args.sessionId);
1185
+ gateOk = precompactGateStatus(
1078
1186
  args.hypoDir,
1079
1187
  closeTranscript ? { transcriptPath: closeTranscript } : {},
1080
- );
1081
- if (!markerGate.ok) {
1082
- // compact gate not ok → skip. Caller's `result.ok` already reflects the
1083
- // file/lint state; next Stop re-blocks until the remaining blocker
1084
- // (feedback/W8/hot/lint — git is now committed) is resolved.
1085
- markerSkipReason = 'compact-gate-not-ok';
1086
- } else if (!closeTranscript || !hasUserCloseSignal(closeTranscript)) {
1087
- // User-close hard gate: apply succeeded (payload files written)
1088
- // but the user never signalled session close, so the marker — which attests
1089
- // "user closed" is withheld. The wiki record stands; the session is simply
1090
- // not marked closed. Surfaced (not silent) so the caller knows.
1091
- 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;
1092
1210
  } else {
1093
- writeSessionClosedMarker(args.hypoDir, args.sessionId, { project });
1094
- // Codex CONCERN: the writer swallows IO errors (best-effort).
1095
- // Verify the file actually landed — mirroring the standalone path — instead of
1096
- // asserting markerWritten=true, so a .cache permission/disk problem surfaces
1097
- // rather than the caller reporting "closed" while the next Stop re-blocks.
1098
- if (existsSync(sessionClosedMarkerPath(args.hypoDir, args.sessionId))) {
1099
- markerWritten = true;
1100
- } else {
1101
- markerSkipReason = 'marker-did-not-land';
1102
- }
1211
+ markerSkipReason = 'marker-did-not-land';
1103
1212
  }
1104
1213
  }
1105
1214
  }
1106
- const stage = ok
1215
+ let stage = ok
1107
1216
  ? null
1108
1217
  : !verification.ok && !postLintOk
1109
1218
  ? 'post-apply-verification+lint'
1110
1219
  : !verification.ok
1111
1220
  ? 'post-apply-verification'
1112
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
+ }
1113
1245
  const result = {
1114
1246
  ok,
1115
1247
  stage,
@@ -1158,9 +1290,11 @@ function applySessionClose(args) {
1158
1290
  // When ok:true but the session-close marker was NOT written, the Stop-chain
1159
1291
  // still sees an open session and will re-prompt at the next Stop. Surface this
1160
1292
  // loudly so neither the human nor a skill-following model reads "ok:true" as
1161
- // "session fully closed". Gate on markerSkipReason (non-null exactly when
1162
- // args.sessionId is present and the marker was withheld).
1163
- 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) {
1164
1298
  process.stderr.write(
1165
1299
  `\n⚠️ session-close marker NOT written (reason: ${markerSkipReason})\n` +
1166
1300
  ` The 5 mandatory files were applied and verified (ok:true), but the\n` +
@@ -1256,96 +1390,133 @@ function parseTags(fm) {
1256
1390
 
1257
1391
  // ── main ─────────────────────────────────────────────────────────────────────
1258
1392
 
1259
- const args = parseArgs(process.argv);
1260
-
1261
- if (args.markSessionClosed) {
1262
- runMarkSessionClosed(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;
1263
1401
  }
1264
1402
 
1265
- if (args.applySessionClose) {
1266
- applySessionClose(args); // exits
1267
- }
1403
+ function main() {
1404
+ const args = parseArgs(process.argv);
1268
1405
 
1269
- if (args.checkSessionClose) {
1270
- runSessionCloseCheck(args); // exits
1271
- }
1272
-
1273
- const ignorePatterns = loadHypoIgnore(args.hypoDir);
1274
- const pagesDir = join(args.hypoDir, 'pages');
1275
- const pages = collectPagesCrystallize(pagesDir, args.hypoDir, ignorePatterns);
1406
+ if (args.markSessionClosed) {
1407
+ runMarkSessionClosed(args); // exits
1408
+ }
1276
1409
 
1277
- const tagGroups = {}; // tag → [{ slug, title }]
1278
- const unlinked = []; // pages with no outbound wikilinks
1279
- const drafts = []; // pages tagged draft
1410
+ if (args.applySessionClose) {
1411
+ applySessionClose(args); // exits
1412
+ }
1280
1413
 
1281
- for (const { path, rel } of pages) {
1282
- let content;
1283
- try {
1284
- content = readFileSync(path, 'utf-8');
1285
- } catch {
1286
- continue;
1414
+ if (args.checkSessionClose) {
1415
+ runSessionCloseCheck(args); // exits
1287
1416
  }
1288
- const fm = parseFrontmatter(content);
1289
- if (!fm) continue;
1290
1417
 
1291
- const slug = rel.replace(/\.md$/, '');
1292
- const title = fm.title || slug;
1293
- 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);
1294
1421
 
1295
- // tag groups
1296
- for (const tag of tags) {
1297
- if (!tagGroups[tag]) tagGroups[tag] = [];
1298
- tagGroups[tag].push({ slug, title });
1299
- }
1422
+ const tagGroups = {}; // tag → [{ slug, title }]
1423
+ const unlinked = []; // pages with no outbound wikilinks
1424
+ const drafts = []; // pages tagged draft
1300
1425
 
1301
- // draft detection
1302
- if (tags.includes('draft') || fm.confidence === 'speculative') {
1303
- drafts.push({ slug, title, confidence: fm.confidence });
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
+ }
1445
+
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 });
1304
1455
  }
1305
1456
 
1306
- // unlinked (no outbound wikilinks in body)
1307
- const body = content.replace(/^---[\s\S]*?---/, '');
1308
- const links = extractWikilinks(body);
1309
- if (links.length === 0) unlinked.push({ slug, title });
1310
- }
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 }));
1311
1462
 
1312
- // filter tag groups by min-group
1313
- const synthesisGroups = Object.entries(tagGroups)
1314
- .filter(([, pages]) => pages.length >= args.minGroup)
1315
- .sort((a, b) => b[1].length - a[1].length)
1316
- .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 });
1317
1466
 
1318
- if (args.json) {
1319
- console.log(JSON.stringify({ synthesisGroups, unlinked, drafts }, null, 2));
1320
- process.exit(0);
1321
- }
1467
+ if (args.json) {
1468
+ console.log(JSON.stringify({ synthesisGroups, unlinked, drafts, coldCandidates }, null, 2));
1469
+ process.exit(0);
1470
+ }
1471
+
1472
+ let found = false;
1473
+
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
+ }
1322
1483
 
1323
- let found = false;
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('');
1489
+ }
1324
1490
 
1325
- if (synthesisGroups.length > 0) {
1326
- found = true;
1327
- console.log(`Synthesis candidates by tag (${synthesisGroups.length} group(s)):\n`);
1328
- for (const { tag, pages: grp } of synthesisGroups) {
1329
- console.log(` [${tag}] (${grp.length} pages):`);
1330
- for (const p of grp) console.log(` [[${p.slug}]] — ${p.title}`);
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('');
1331
1496
  }
1332
- console.log('');
1333
- }
1334
1497
 
1335
- if (unlinked.length > 0) {
1336
- found = true;
1337
- console.log(`Unlinked pages (no outbound [[wikilinks]]) ${unlinked.length}:`);
1338
- for (const p of unlinked) console.log(` [[${p.slug}]] — ${p.title}`);
1339
- console.log('');
1340
- }
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
+ }
1341
1516
 
1342
- if (drafts.length > 0) {
1343
- found = true;
1344
- console.log(`Draft/speculative pages ready to crystallize — ${drafts.length}:`);
1345
- for (const p of drafts) console.log(` [[${p.slug}]] — ${p.title}`);
1346
- console.log('');
1517
+ if (!found) {
1518
+ console.log('✓ No crystallization candidates found Hypomnema looks well-connected.');
1519
+ }
1347
1520
  }
1348
1521
 
1349
- if (!found) {
1350
- console.log('✓ No crystallization candidates found — Hypomnema looks well-connected.');
1351
- }
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);