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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.ko.md +2 -1
- package/README.md +2 -1
- package/commands/capture.md +40 -0
- package/docs/CONTRIBUTING.md +18 -0
- package/package.json +1 -1
- package/scripts/capture.mjs +695 -0
- package/scripts/crystallize.mjs +245 -128
- package/scripts/doctor.mjs +71 -4
- package/scripts/init.mjs +16 -5
- package/scripts/lib/core-hooks.mjs +156 -0
- package/scripts/lib/extensions.mjs +317 -9
- package/scripts/uninstall.mjs +84 -42
- package/templates/hypo-config.md +1 -1
package/scripts/crystallize.mjs
CHANGED
|
@@ -65,11 +65,19 @@
|
|
|
65
65
|
* hard-fails regardless of scope.
|
|
66
66
|
*/
|
|
67
67
|
|
|
68
|
-
import {
|
|
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
|
|
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
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
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
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1193
|
-
//
|
|
1194
|
-
|
|
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
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
if (
|
|
1297
|
-
|
|
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
|
-
|
|
1301
|
-
|
|
1302
|
-
}
|
|
1403
|
+
function main() {
|
|
1404
|
+
const args = parseArgs(process.argv);
|
|
1303
1405
|
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1406
|
+
if (args.markSessionClosed) {
|
|
1407
|
+
runMarkSessionClosed(args); // exits
|
|
1408
|
+
}
|
|
1307
1409
|
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1410
|
+
if (args.applySessionClose) {
|
|
1411
|
+
applySessionClose(args); // exits
|
|
1412
|
+
}
|
|
1311
1413
|
|
|
1312
|
-
|
|
1313
|
-
|
|
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
|
|
1323
|
-
const
|
|
1324
|
-
const
|
|
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
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
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
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
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
|
-
//
|
|
1338
|
-
const
|
|
1339
|
-
|
|
1340
|
-
|
|
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
|
-
//
|
|
1344
|
-
|
|
1345
|
-
|
|
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
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1467
|
+
if (args.json) {
|
|
1468
|
+
console.log(JSON.stringify({ synthesisGroups, unlinked, drafts, coldCandidates }, null, 2));
|
|
1469
|
+
process.exit(0);
|
|
1470
|
+
}
|
|
1352
1471
|
|
|
1353
|
-
|
|
1354
|
-
console.log(JSON.stringify({ synthesisGroups, unlinked, drafts, coldCandidates }, null, 2));
|
|
1355
|
-
process.exit(0);
|
|
1356
|
-
}
|
|
1472
|
+
let found = false;
|
|
1357
1473
|
|
|
1358
|
-
|
|
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 (
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
console.log(
|
|
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 (
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
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
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
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
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
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 (
|
|
1404
|
-
console.log('✓ No crystallization candidates found — Hypomnema looks well-connected.');
|
|
1405
|
-
}
|
|
1522
|
+
if (isMain()) main();
|
package/scripts/doctor.mjs
CHANGED
|
@@ -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
|
-
|
|
869
|
-
|
|
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)
|
|
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
|
-
|
|
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([
|
|
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
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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);
|