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.
@@ -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.0",
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",
@@ -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
- const commitOutcome = commitWikiChanges(args.hypoDir);
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) {