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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.ko.md +36 -18
- package/README.md +26 -8
- package/commands/audit.md +1 -1
- package/commands/capture.md +40 -0
- package/commands/crystallize.md +15 -7
- package/docs/ARCHITECTURE.md +2 -2
- package/docs/CONTRIBUTING.md +18 -0
- package/hooks/hypo-lookup.mjs +37 -2
- package/hooks/hypo-session-start.mjs +28 -2
- package/hooks/hypo-shared.mjs +159 -14
- package/package.json +1 -1
- package/scripts/capture.mjs +695 -0
- package/scripts/crystallize.mjs +292 -121
- 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/lib/page-usage.mjs +141 -0
- package/scripts/uninstall.mjs +84 -42
- package/scripts/upgrade.mjs +68 -0
- package/skills/crystallize/SKILL.md +10 -4
- package/skills/graph/SKILL.md +1 -1
- package/skills/ingest/SKILL.md +1 -1
- package/skills/lint/SKILL.md +1 -1
- package/skills/query/SKILL.md +1 -1
- package/skills/verify/SKILL.md +1 -1
- package/templates/hypo-config.md +1 -1
- package/templates/hypo-guide.md +1 -1
package/scripts/crystallize.mjs
CHANGED
|
@@ -65,14 +65,23 @@
|
|
|
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';
|
|
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
|
-
//
|
|
757
|
-
//
|
|
758
|
-
//
|
|
759
|
-
//
|
|
760
|
-
//
|
|
761
|
-
//
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
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
|
|
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
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
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
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1162
|
-
//
|
|
1163
|
-
|
|
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
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
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
|
-
|
|
1266
|
-
|
|
1267
|
-
}
|
|
1403
|
+
function main() {
|
|
1404
|
+
const args = parseArgs(process.argv);
|
|
1268
1405
|
|
|
1269
|
-
if (args.
|
|
1270
|
-
|
|
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
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1410
|
+
if (args.applySessionClose) {
|
|
1411
|
+
applySessionClose(args); // exits
|
|
1412
|
+
}
|
|
1280
1413
|
|
|
1281
|
-
|
|
1282
|
-
|
|
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
|
|
1292
|
-
const
|
|
1293
|
-
const
|
|
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
|
|
1296
|
-
|
|
1297
|
-
|
|
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
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
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
|
-
//
|
|
1307
|
-
const
|
|
1308
|
-
|
|
1309
|
-
|
|
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
|
-
//
|
|
1313
|
-
|
|
1314
|
-
|
|
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
|
-
|
|
1320
|
-
|
|
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
|
-
|
|
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 (
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
console.log(
|
|
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
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
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 (
|
|
1343
|
-
|
|
1344
|
-
|
|
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 (
|
|
1350
|
-
console.log('✓ No crystallization candidates found — Hypomnema looks well-connected.');
|
|
1351
|
-
}
|
|
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);
|