hypomnema 1.7.0 → 1.7.2
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 +79 -50
- package/README.md +63 -34
- package/hooks/hooks.json +11 -0
- package/hooks/hypo-auto-commit.mjs +92 -15
- package/hooks/hypo-auto-stage.mjs +28 -18
- package/hooks/hypo-close-guard.mjs +246 -0
- package/hooks/hypo-hot-rebuild.mjs +43 -5
- package/hooks/hypo-session-start.mjs +166 -13
- package/hooks/hypo-shared.mjs +1358 -130
- package/hooks/version-check.mjs +92 -0
- package/package.json +4 -1
- package/scripts/crystallize.mjs +112 -2
- package/scripts/doctor.mjs +627 -17
- package/scripts/graph.mjs +27 -12
- package/scripts/init.mjs +66 -7
- package/scripts/lib/git-hooks-dir.mjs +229 -0
- package/scripts/lib/pkg-provenance.mjs +166 -0
- package/scripts/lib/project-create.mjs +5 -1
- package/scripts/lib/rename-marker.mjs +39 -0
- package/scripts/lint.mjs +84 -2
- package/scripts/rename.mjs +223 -18
- package/scripts/stats.mjs +14 -2
- package/scripts/uninstall.mjs +12 -0
- package/scripts/upgrade.mjs +8 -0
- package/templates/gitignore +4 -0
- package/templates/hypo-config.md +1 -1
package/hooks/version-check.mjs
CHANGED
|
@@ -385,6 +385,98 @@ export function markSiblingNotified(path, key) {
|
|
|
385
385
|
}
|
|
386
386
|
}
|
|
387
387
|
|
|
388
|
+
// ── pkgRoot drift notice ────────────────────────────────────────────────────
|
|
389
|
+
// Same notify-once shape as the sibling banner above, in a field of its own
|
|
390
|
+
// (`pkgRootDriftNotifiedFor`) so the two never suppress each other — they are
|
|
391
|
+
// unrelated tuples that happen to share this cache file.
|
|
392
|
+
|
|
393
|
+
/** Has this exact (cached → self-location) pkgRoot-drift pair already been surfaced? */
|
|
394
|
+
export function pkgRootDriftAlreadyNotified(cache, key) {
|
|
395
|
+
return Boolean(cache && cache.pkgRootDriftNotifiedFor === key);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/** Record that the pkgRoot-drift banner for `key` was shown (read-merge-write). */
|
|
399
|
+
export function markPkgRootDriftNotified(path, key) {
|
|
400
|
+
try {
|
|
401
|
+
const cache = readCache(path) || {};
|
|
402
|
+
cache.pkgRootDriftNotifiedFor = key;
|
|
403
|
+
writeCacheAtomic(path, cache);
|
|
404
|
+
} catch {
|
|
405
|
+
/* best-effort */
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Clear a previously-recorded pkgRoot-drift mark once the drift is observed
|
|
411
|
+
* to be resolved (cache catches up with self-location again — status
|
|
412
|
+
* 'match'). Without this the mark is permanent: the SAME (cached →
|
|
413
|
+
* self-location) pair recurring later — e.g. hypo-pkg.json catches up, then a
|
|
414
|
+
* later plugin update or a manual edit drifts it right back to the identical
|
|
415
|
+
* pair — would stay silently suppressed forever, because only a DIFFERENT
|
|
416
|
+
* pair would ever produce a new key. Callers must NOT call this on status
|
|
417
|
+
* 'unknown' (self-location could not be resolved) — that would wrongly wipe a
|
|
418
|
+
* mark this session had no grounds to judge one way or the other. Read-merge-
|
|
419
|
+
* write, best-effort (a failure here just means the notice stays suppressed
|
|
420
|
+
* one extra cycle, never a crash).
|
|
421
|
+
*/
|
|
422
|
+
export function clearPkgRootDriftNotified(path) {
|
|
423
|
+
try {
|
|
424
|
+
const cache = readCache(path);
|
|
425
|
+
if (!cache || !('pkgRootDriftNotifiedFor' in cache)) return;
|
|
426
|
+
delete cache.pkgRootDriftNotifiedFor;
|
|
427
|
+
writeCacheAtomic(path, cache);
|
|
428
|
+
} catch {
|
|
429
|
+
/* best-effort */
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// ── pkgRoot-null notice ─────────────────────────────────────────────────────
|
|
434
|
+
// A DIFFERENT failure than drift above: drift only ever fires when
|
|
435
|
+
// self-location resolved (PKG_ROOT is non-null, just disagreeing with the
|
|
436
|
+
// cache). This one fires when PKG_ROOT itself resolved to null — self-location
|
|
437
|
+
// failed AND no verified provenance sidecar covered it — the case where
|
|
438
|
+
// PreCompact's lint/feedback calls silently no-op because they have no root to
|
|
439
|
+
// shell scripts through. The two conditions can never both hold in the same
|
|
440
|
+
// session (drift requires a non-null self-location), so there is no "which
|
|
441
|
+
// wins" question in practice, but the two use separate cache fields regardless
|
|
442
|
+
// so neither implementation depends on that being true forever.
|
|
443
|
+
// Boolean (not a pair-key like drift) — the notified state is just "already
|
|
444
|
+
// told them this session's install has no resolvable pkgRoot".
|
|
445
|
+
|
|
446
|
+
/** Has the PKG_ROOT-null state already been surfaced since it last cleared? */
|
|
447
|
+
export function pkgRootNullAlreadyNotified(cache) {
|
|
448
|
+
return Boolean(cache && cache.pkgRootNullNotified === true);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/** Record that the PKG_ROOT-null banner was shown (read-merge-write). */
|
|
452
|
+
export function markPkgRootNullNotified(path) {
|
|
453
|
+
try {
|
|
454
|
+
const cache = readCache(path) || {};
|
|
455
|
+
cache.pkgRootNullNotified = true;
|
|
456
|
+
writeCacheAtomic(path, cache);
|
|
457
|
+
} catch {
|
|
458
|
+
/* best-effort */
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Clear a previously-recorded PKG_ROOT-null mark once PKG_ROOT resolves again
|
|
464
|
+
* (self-location or a verified provenance sidecar). Without this, a null state
|
|
465
|
+
* that resolves and then recurs later (e.g. the provenance sidecar's hash
|
|
466
|
+
* binding breaks again after a partial re-install) would stay silently
|
|
467
|
+
* suppressed forever. Read-merge-write, best-effort.
|
|
468
|
+
*/
|
|
469
|
+
export function clearPkgRootNullNotified(path) {
|
|
470
|
+
try {
|
|
471
|
+
const cache = readCache(path);
|
|
472
|
+
if (!cache || !('pkgRootNullNotified' in cache)) return;
|
|
473
|
+
delete cache.pkgRootNullNotified;
|
|
474
|
+
writeCacheAtomic(path, cache);
|
|
475
|
+
} catch {
|
|
476
|
+
/* best-effort */
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
388
480
|
/**
|
|
389
481
|
* Shared one-line message for the init/upgrade downgrade guard (P). `op` is
|
|
390
482
|
* 'init' or 'upgrade'. Kept here so guard text stays identical across both CLIs.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hypomnema",
|
|
3
|
-
"version": "1.7.
|
|
3
|
+
"version": "1.7.2",
|
|
4
4
|
"description": "LLM-native personal wiki system for Claude Code",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -33,12 +33,15 @@
|
|
|
33
33
|
"scripts/lib/failure-type.mjs",
|
|
34
34
|
"scripts/lib/feedback-scope.mjs",
|
|
35
35
|
"scripts/lib/frontmatter.mjs",
|
|
36
|
+
"scripts/lib/git-hooks-dir.mjs",
|
|
36
37
|
"scripts/lib/hypo-ignore.mjs",
|
|
37
38
|
"scripts/lib/hypo-root.mjs",
|
|
38
39
|
"scripts/lib/page-usage.mjs",
|
|
39
40
|
"scripts/lib/pkg-json.mjs",
|
|
41
|
+
"scripts/lib/pkg-provenance.mjs",
|
|
40
42
|
"scripts/lib/plugin-detect.mjs",
|
|
41
43
|
"scripts/lib/project-create.mjs",
|
|
44
|
+
"scripts/lib/rename-marker.mjs",
|
|
42
45
|
"scripts/lib/schema-vocab.mjs",
|
|
43
46
|
"scripts/lib/template-schema-version.mjs",
|
|
44
47
|
"scripts/lib/wd-match.mjs",
|
package/scripts/crystallize.mjs
CHANGED
|
@@ -73,6 +73,9 @@ import {
|
|
|
73
73
|
mkdirSync,
|
|
74
74
|
renameSync,
|
|
75
75
|
realpathSync,
|
|
76
|
+
openSync,
|
|
77
|
+
writeSync,
|
|
78
|
+
closeSync,
|
|
76
79
|
} from 'fs';
|
|
77
80
|
import { join, dirname } from 'path';
|
|
78
81
|
import { spawnSync } from 'child_process';
|
|
@@ -81,7 +84,7 @@ import { resolveHypoRoot, expandHome } from './lib/hypo-root.mjs';
|
|
|
81
84
|
import { loadHypoIgnore } from './lib/hypo-ignore.mjs';
|
|
82
85
|
import { collectPagesCrystallize, extractWikilinks } from './lib/wikilink.mjs';
|
|
83
86
|
import { aggregateColdCandidates } from './lib/page-usage.mjs';
|
|
84
|
-
import { isValidProjectName } from './lib/project-create.mjs';
|
|
87
|
+
import { isValidProjectName, substituteTokens, TEMPLATE_DIR } from './lib/project-create.mjs';
|
|
85
88
|
import { appendPendingTags, checkForbidden } from './lib/schema-vocab.mjs';
|
|
86
89
|
import {
|
|
87
90
|
sessionCloseFileStatus,
|
|
@@ -101,6 +104,7 @@ import {
|
|
|
101
104
|
resolveTranscriptBySessionId,
|
|
102
105
|
hasUserCloseSignal,
|
|
103
106
|
commitWikiChanges,
|
|
107
|
+
vaultCommitLockTarget,
|
|
104
108
|
currentDevice,
|
|
105
109
|
scopeVisible,
|
|
106
110
|
readVisibilityScope,
|
|
@@ -905,6 +909,70 @@ function verifyCloseAuthority(sessionId) {
|
|
|
905
909
|
return { ok: true };
|
|
906
910
|
}
|
|
907
911
|
|
|
912
|
+
// A-1 (project index lifecycle): seed projects/<project>/index.md from the
|
|
913
|
+
// template the first time this project closes without one. SCHEMA.md declares
|
|
914
|
+
// project-index at projects/*/index.md and templates/projects/_template/ ships
|
|
915
|
+
// one, but createProject (the auto-project-offer path) is the only writer of
|
|
916
|
+
// it today — a project directory created any other way (a manual mkdir, an
|
|
917
|
+
// older vault, direct Write tool calls) never gets one. Idempotent: an
|
|
918
|
+
// existing index.md is left untouched; this only fills the gap, and only the
|
|
919
|
+
// three tokens the template defines are substituted — the rest (one-line
|
|
920
|
+
// description, Progress checklist) stays human-authored prose exactly as
|
|
921
|
+
// createProject already leaves it.
|
|
922
|
+
//
|
|
923
|
+
// `working_dir` has no authoritative source in this flow: apply never
|
|
924
|
+
// receives the session's cwd (see hooks/hypo-shared.mjs's precompactGateStatus
|
|
925
|
+
// doc — process.cwd() is explicitly non-authoritative here, since it reflects
|
|
926
|
+
// this script's own launch directory, not the session's). It is left EMPTY,
|
|
927
|
+
// not filled with a placeholder string: hooks/hypo-shared.mjs's collector
|
|
928
|
+
// (collectProjectWorkingDirs) and findBackfillCandidate both treat any truthy
|
|
929
|
+
// `working_dir` as "already anchored" and stop offering to backfill the real
|
|
930
|
+
// cwd — a fake placeholder is truthy, so it would silently and permanently
|
|
931
|
+
// swallow the exact anchor-recovery path a human would otherwise get. Empty
|
|
932
|
+
// stays falsy there, so the project surfaces as a genuine backfill candidate
|
|
933
|
+
// until a person (or the auto-project-offer flow) fills in a real path. The
|
|
934
|
+
// key itself stays present in the frontmatter (substituted to an empty value,
|
|
935
|
+
// not omitted) so the shape matches every other index.md and a human sees
|
|
936
|
+
// exactly where to type the answer.
|
|
937
|
+
// "Never overwrite an existing index" is this feature's explicit contract, so
|
|
938
|
+
// creation uses an EXCLUSIVE create (`wx`), not the existsSync-then-atomicWrite
|
|
939
|
+
// shape every other target in this file uses. atomicWrite's tmp+rename
|
|
940
|
+
// replaces whatever sits at `dest` the instant rename fires — existing or not —
|
|
941
|
+
// so a plain `if (existsSync(dest)) return null` beforehand only narrows the
|
|
942
|
+
// race, it does not close it: another writer (a human, or a concurrent close)
|
|
943
|
+
// can land real bytes at `dest` between that check and this function's rename.
|
|
944
|
+
// `wx` makes the OS do the check-and-create atomically, mirroring
|
|
945
|
+
// hooks/base-store.mjs's snapshotBase (`openSync(path, 'wx')` + EEXIST ==
|
|
946
|
+
// "someone already got there, leave it"). No tmp+rename is needed here: unlike
|
|
947
|
+
// atomicWrite's use case (replacing bytes a reader might already be mid-read
|
|
948
|
+
// of), a `wx` create can never observably tear — the file either doesn't
|
|
949
|
+
// exist yet (nothing to tear) or the open fails outright.
|
|
950
|
+
export function ensureProjectIndex(hypoDir, project, relPath, today) {
|
|
951
|
+
const dest = join(hypoDir, relPath);
|
|
952
|
+
const src = join(TEMPLATE_DIR, 'index.md');
|
|
953
|
+
if (!existsSync(src)) return null; // template missing — nothing to scaffold from
|
|
954
|
+
const content = substituteTokens(readFileSync(src, 'utf-8'), {
|
|
955
|
+
name: project,
|
|
956
|
+
started: today,
|
|
957
|
+
workingDir: '',
|
|
958
|
+
today,
|
|
959
|
+
});
|
|
960
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
961
|
+
let fd;
|
|
962
|
+
try {
|
|
963
|
+
fd = openSync(dest, 'wx');
|
|
964
|
+
} catch (e) {
|
|
965
|
+
if (e && e.code === 'EEXIST') return null; // another writer already created it
|
|
966
|
+
throw e;
|
|
967
|
+
}
|
|
968
|
+
try {
|
|
969
|
+
writeSync(fd, content);
|
|
970
|
+
} finally {
|
|
971
|
+
closeSync(fd);
|
|
972
|
+
}
|
|
973
|
+
return relPath;
|
|
974
|
+
}
|
|
975
|
+
|
|
908
976
|
function applySessionClose(args) {
|
|
909
977
|
// Option D: early-exit fires only when NO payload was supplied.
|
|
910
978
|
// Rationale: payload presence is explicit close intent and must always run
|
|
@@ -1185,6 +1253,11 @@ function applySessionClose(args) {
|
|
|
1185
1253
|
// normalization, so it must use the OS-native separator the sibling entries use.
|
|
1186
1254
|
const sessionLogWriteTarget = join('projects', project, 'session-log', `${date}.md`);
|
|
1187
1255
|
const sessionLogEvidence = join(...sessionLogScopePath(args.hypoDir, project, date).split('/'));
|
|
1256
|
+
// A-1: known here (read-only check, no write yet) so a freshly-scaffolded
|
|
1257
|
+
// index.md is scoped to THIS close's own payloadScope below, rather than
|
|
1258
|
+
// showing up as an unrelated pre-existing-content notice.
|
|
1259
|
+
const indexRelPath = join('projects', project, 'index.md');
|
|
1260
|
+
const indexMissing = !existsSync(join(args.hypoDir, indexRelPath));
|
|
1188
1261
|
const payloadScope = new Set([
|
|
1189
1262
|
join('projects', project, 'session-state.md'),
|
|
1190
1263
|
join('projects', project, 'hot.md'),
|
|
@@ -1193,6 +1266,7 @@ function applySessionClose(args) {
|
|
|
1193
1266
|
sessionLogEvidence, // == write target, except a hybrid-month monthly fallback
|
|
1194
1267
|
'log.md',
|
|
1195
1268
|
...(payload.openQuestions ? [join('pages', 'open-questions.md')] : []),
|
|
1269
|
+
...(indexMissing ? [indexRelPath] : []),
|
|
1196
1270
|
]);
|
|
1197
1271
|
|
|
1198
1272
|
let preflightLint;
|
|
@@ -1229,6 +1303,12 @@ function applySessionClose(args) {
|
|
|
1229
1303
|
|
|
1230
1304
|
const applied = [];
|
|
1231
1305
|
const skipped = [];
|
|
1306
|
+
// The ACTUAL vault-relative paths this apply wrote, kept separate
|
|
1307
|
+
// from `applied` (whose entries are display strings like `key (relPath)`,
|
|
1308
|
+
// not bare paths). This is the scope handed to commitWikiChanges below;
|
|
1309
|
+
// never the broader `payloadScope` above, which also includes lint/evidence
|
|
1310
|
+
// candidates this apply may not have written a byte to.
|
|
1311
|
+
const appliedPaths = [];
|
|
1232
1312
|
// Overwrite targets this apply refused to write because the page moved under
|
|
1233
1313
|
// it. T6 turns these into `.cache/proposals/` artifacts; here they are already
|
|
1234
1314
|
// enough to withhold the bytes and fail the close.
|
|
@@ -1286,6 +1366,7 @@ function applySessionClose(args) {
|
|
|
1286
1366
|
if (args.sessionId)
|
|
1287
1367
|
advanceBase(args.hypoDir, args.sessionId, relPath, hashContent(field.content));
|
|
1288
1368
|
applied.push(`${key} (${relPath})`);
|
|
1369
|
+
appliedPaths.push(relPath);
|
|
1289
1370
|
};
|
|
1290
1371
|
|
|
1291
1372
|
overwrite('sessionState', join('projects', project, 'session-state.md'), payload.sessionState);
|
|
@@ -1293,6 +1374,17 @@ function applySessionClose(args) {
|
|
|
1293
1374
|
overwrite('rootHot', 'hot.md', payload.rootHot);
|
|
1294
1375
|
overwrite('openQuestions', join('pages', 'open-questions.md'), payload.openQuestions);
|
|
1295
1376
|
|
|
1377
|
+
// A-1: fill a missing project index as part of this close's writes (after
|
|
1378
|
+
// preflight passed, so an aborted close never leaves a half-applied side
|
|
1379
|
+
// effect on disk).
|
|
1380
|
+
if (indexMissing) {
|
|
1381
|
+
const createdIndex = ensureProjectIndex(args.hypoDir, project, indexRelPath, date);
|
|
1382
|
+
if (createdIndex) {
|
|
1383
|
+
applied.push(`projectIndex (${createdIndex})`);
|
|
1384
|
+
appliedPaths.push(createdIndex);
|
|
1385
|
+
}
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1296
1388
|
// Append idempotency: dedup by exact-entry presence, not by "any heading
|
|
1297
1389
|
// dated today". The freshness gate (sessionCloseFileStatus) is what answers
|
|
1298
1390
|
// "was this file touched today?"; that's a different concern and must not
|
|
@@ -1368,6 +1460,7 @@ function applySessionClose(args) {
|
|
|
1368
1460
|
{ timeoutMs: APPEND_LOCK_TIMEOUT_MS },
|
|
1369
1461
|
);
|
|
1370
1462
|
(outcome === 'skipped' ? skipped : applied).push(`sessionLog (${rel})`);
|
|
1463
|
+
if (outcome !== 'skipped') appliedPaths.push(rel);
|
|
1371
1464
|
} catch (err) {
|
|
1372
1465
|
// Only a lock-TIMEOUT is withheld as a conflict. A real fn() write error
|
|
1373
1466
|
// (disk-full, EACCES, mkdir failure) must NOT be masked as a proposal-
|
|
@@ -1419,6 +1512,7 @@ function applySessionClose(args) {
|
|
|
1419
1512
|
{ timeoutMs: APPEND_LOCK_TIMEOUT_MS },
|
|
1420
1513
|
);
|
|
1421
1514
|
(wrote ? applied : skipped).push('log (log.md)');
|
|
1515
|
+
if (wrote) appliedPaths.push('log.md');
|
|
1422
1516
|
} catch (err) {
|
|
1423
1517
|
if (err?.code !== 'ELOCKTIMEOUT') throw err;
|
|
1424
1518
|
// proposedContent is append-ready root-log bytes (the custom log line).
|
|
@@ -1455,6 +1549,7 @@ function applySessionClose(args) {
|
|
|
1455
1549
|
{ timeoutMs: APPEND_LOCK_TIMEOUT_MS },
|
|
1456
1550
|
);
|
|
1457
1551
|
(wroteAny ? applied : skipped).push('log (log.md, derived)');
|
|
1552
|
+
if (wroteAny) appliedPaths.push('log.md');
|
|
1458
1553
|
} catch (err) {
|
|
1459
1554
|
if (err?.code !== 'ELOCKTIMEOUT') throw err;
|
|
1460
1555
|
// `derived: true` discriminates this from the payload.log conflict above:
|
|
@@ -1646,7 +1741,22 @@ function applySessionClose(args) {
|
|
|
1646
1741
|
// user-close signal ONLY once the gate passes. planMarkerDecision owns the
|
|
1647
1742
|
// branch priority + reason strings; the booleans below are computed in that
|
|
1648
1743
|
// same short-circuiting order so no read runs earlier than it does today.
|
|
1649
|
-
|
|
1744
|
+
// Scope this commit to the paths THIS apply actually wrote
|
|
1745
|
+
// (appliedPaths), never the broader payloadScope, which also
|
|
1746
|
+
// names lint/evidence candidates apply may not have touched a byte of.
|
|
1747
|
+
// Locked against the SAME target the auto-commit Stop hook holds, so a
|
|
1748
|
+
// concurrent Stop-chain commit on this vault can't interleave with this
|
|
1749
|
+
// apply's stage+commit. A lock-timeout is treated exactly like any other
|
|
1750
|
+
// commit failure below (skip the marker, surface the reason) rather than
|
|
1751
|
+
// crashing the apply.
|
|
1752
|
+
let commitOutcome;
|
|
1753
|
+
try {
|
|
1754
|
+
commitOutcome = withFileLock(vaultCommitLockTarget(args.hypoDir), () =>
|
|
1755
|
+
commitWikiChanges(args.hypoDir, appliedPaths),
|
|
1756
|
+
);
|
|
1757
|
+
} catch (err) {
|
|
1758
|
+
commitOutcome = { committed: false, reason: `vault-commit-lock: ${err?.message || err}` };
|
|
1759
|
+
}
|
|
1650
1760
|
let closeTranscript = null;
|
|
1651
1761
|
let gateOk = false;
|
|
1652
1762
|
if (commitOutcome.committed) {
|