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.
@@ -284,17 +284,22 @@ export function sessionLogScopePath(hypoDir, project, date) {
284
284
  * True if `content` carries a today-dated `## [date] session | <project>` entry
285
285
  * in log.md.
286
286
  *
287
- * Bounded with an explicit `(?=\s|$)` lookahead, NOT `\b`: a regex word boundary
288
- * matches between word and non-word chars, so `\b` after "foo" still matches in
289
- * "foo-bar" (hyphen is non-word). The canonical log format always separates the
290
- * project slug from anything that follows by whitespace or end-of-line, so the
291
- * lookahead correctly rejects "session | foo-bar" when looking for "foo".
292
- * (Was a pre-existing bug in sessionCloseFileStatus that the helper extraction
293
- * inherited.)
287
+ * Bounded with an explicit `(?=[\s:]|$)` lookahead, NOT `\b`: a regex word
288
+ * boundary matches between word and non-word chars, so `\b` after "foo" still
289
+ * matches in "foo-bar" (hyphen is non-word). The canonical log format separates
290
+ * the project slug from anything that follows by whitespace, a colon, or
291
+ * end-of-line, so the lookahead correctly rejects "session | foo-bar" when
292
+ * looking for "foo". Both delimiters are canonical: the derive path
293
+ * (rootLogEntry) emits `<project> — <title>` (space), while the dominant
294
+ * hand-written convention is `<project>: <title>` (colon, since the tone rule
295
+ * banned the em dash). The colon must be accepted: without it, a close whose
296
+ * only log.md evidence used the colon form was misread as "stale" and blocked
297
+ * non-deterministically. (Was a pre-existing boundary bug in
298
+ * sessionCloseFileStatus that the helper extraction inherited.)
294
299
  */
295
300
  export function hasLogEntry(content, date, project) {
296
301
  return new RegExp(
297
- '^## \\[' + escapeRegExp(date) + '\\] session \\| ' + escapeRegExp(project) + '(?=\\s|$)',
302
+ '^## \\[' + escapeRegExp(date) + '\\] session \\| ' + escapeRegExp(project) + '(?=[\\s:]|$)',
298
303
  'm',
299
304
  ).test(content || '');
300
305
  }
@@ -352,6 +357,139 @@ function parseFrontmatterField(content, key) {
352
357
  .replace(/^['"]|['"]$/g, '');
353
358
  }
354
359
 
360
+ // ── freshness: overdue verify_by_date predicate + STALE injection marker ─────
361
+ // Authority is doctor.mjs:487-491 (D1). Same characters, same meaning: only a
362
+ // well-formed ISO date strictly before `today` is overdue. verify_by (the
363
+ // natural-language question) is never a date and is never consulted here.
364
+ // today is caller-supplied and must use the UTC convention (new Date()
365
+ // .toISOString().slice(0,10)) so this set matches doctor's.
366
+ export function isOverdueDate(dateStr, today) {
367
+ return /^\d{4}-\d{2}-\d{2}$/.test(dateStr) && dateStr < today;
368
+ }
369
+
370
+ // Compute the injection marker for a page's raw content. Returns
371
+ // `[STALE verify_by_date=YYYY-MM-DD]` when overdue, otherwise an empty string
372
+ // (so non-targets pass through unchanged). Callers prepend the non-empty result
373
+ // at injection time.
374
+ //
375
+ // The value read here must be normalized exactly as scripts/lib/frontmatter.mjs
376
+ // does (doctor's parser), so the overdue set stays char-identical to doctor.mjs.
377
+ // In particular it strips a trailing YAML inline comment (`2020-01-01 # note`):
378
+ // the plain parseFrontmatterField does not, which would silently miss dates
379
+ // doctor flags overdue. Top-level line only (skip indented / list entries),
380
+ // first-wins, strip comment, then strip surrounding quotes.
381
+ export function staleMarkerFor(rawContent, today) {
382
+ const m = rawContent.match(/^---\r?\n([\s\S]*?)\r?\n---/);
383
+ if (!m) return '';
384
+ let value = null;
385
+ for (const line of m[1].split(/\r?\n/)) {
386
+ if (/^\s/.test(line) || /^-(\s|$)/.test(line)) continue; // nested / list item
387
+ const idx = line.indexOf(':');
388
+ if (idx < 0) continue;
389
+ if (line.slice(0, idx).trim() !== 'verify_by_date') continue; // key match (idx: tolerates `key : v`)
390
+ value = line
391
+ .slice(idx + 1)
392
+ .trim()
393
+ .replace(/\s+#.*$/, '')
394
+ .replace(/^["']|["']$/g, '');
395
+ break; // first-wins
396
+ }
397
+ return value && isOverdueDate(value, today) ? `[STALE verify_by_date=${value}]` : '';
398
+ }
399
+
400
+ // ── page-usage logging: commit-coverage guard + append (B, D6) ───────────────
401
+ // Logging which pages lookup injects is observability, never an injection path.
402
+ // The log lives at .cache/page-usage.jsonl and must never be committed. Before
403
+ // any append, prove that file cannot be staged/committed: it must be covered by
404
+ // BOTH git's ignore rules (check-ignore) AND .hypoignore (which is what gates
405
+ // hypo-auto-stage and commitWikiChanges). Missing either signal, or any error
406
+ // (no git, timeout, non-repo), returns false so nothing is written (fail-closed
407
+ // logging). Injection stays fail-open and is unaffected. The verdict is cached
408
+ // per session (keyed by session_id plus a vault-path hash so two vaults in one
409
+ // session can't cross-contaminate) so git runs at most once per session.
410
+ export const PAGE_USAGE_REL = '.cache/page-usage.jsonl';
411
+
412
+ export function pageUsageGuardCachePath(sessionId, hypoDir) {
413
+ const safe = String(sessionId || 'default').replace(/[^A-Za-z0-9._-]/g, '_') || 'default';
414
+ // djb2 over the vault path so the cache key is per-(session, vault).
415
+ let h = 5381;
416
+ const s = String(hypoDir || '');
417
+ for (let i = 0; i < s.length; i++) h = ((h * 33) ^ s.charCodeAt(i)) >>> 0;
418
+ return join(tmpdir(), `hypo-pageusage-guard-${safe}-${h.toString(36)}.json`);
419
+ }
420
+
421
+ export function pageUsageLoggingAllowed(hypoDir, sessionId) {
422
+ // The load-bearing commit gate is .hypoignore: it is what hypo-auto-stage and
423
+ // commitWikiChanges actually filter on. Re-check it FRESH on every call (it is
424
+ // cheap, no subprocess) so that if coverage is removed mid-session the guard
425
+ // flips closed immediately and can never leave logging armed against a
426
+ // now-committable file. Only the expensive git check-ignore probe is cached
427
+ // per session. Any error on either signal fails closed.
428
+ let hypoIgnored = false;
429
+ try {
430
+ const target = join(hypoDir, PAGE_USAGE_REL);
431
+ const patterns = loadHypoIgnore(hypoDir);
432
+ hypoIgnored = patterns.length > 0 && isIgnored(target, hypoDir, patterns);
433
+ } catch {
434
+ hypoIgnored = false;
435
+ }
436
+ if (!hypoIgnored) return false;
437
+
438
+ return gitIgnoresPageUsageCached(hypoDir, sessionId);
439
+ }
440
+
441
+ // git check-ignore is the belt signal (defends a manual `git add`); it spawns a
442
+ // subprocess, so cache its result per session. The verdict cached here is only
443
+ // the git signal, never the composite allow decision, so the fresh .hypoignore
444
+ // re-check above always still runs.
445
+ function gitIgnoresPageUsageCached(hypoDir, sessionId) {
446
+ const cachePath = pageUsageGuardCachePath(sessionId, hypoDir);
447
+ try {
448
+ if (existsSync(cachePath)) {
449
+ const cached = JSON.parse(readFileSync(cachePath, 'utf-8'));
450
+ if (typeof cached.gitIgnored === 'boolean') return cached.gitIgnored;
451
+ }
452
+ } catch {
453
+ // corrupt cache → recompute below
454
+ }
455
+
456
+ let gitIgnored = false;
457
+ try {
458
+ gitIgnored =
459
+ spawnSync('git', ['-C', hypoDir, 'check-ignore', '-q', '--', PAGE_USAGE_REL], {
460
+ timeout: 2000,
461
+ }).status === 0;
462
+ } catch {
463
+ gitIgnored = false;
464
+ }
465
+
466
+ try {
467
+ writeFileSync(cachePath, JSON.stringify({ gitIgnored }));
468
+ } catch {
469
+ // cache write failure is non-fatal; the git probe just reruns next prompt
470
+ }
471
+ return gitIgnored;
472
+ }
473
+
474
+ // Append one JSONL record per injected slug to .cache/page-usage.jsonl. Callers
475
+ // MUST gate this behind pageUsageLoggingAllowed first. Fully fail-open: any error
476
+ // (mkdir, disk, serialization) is swallowed so a logging failure never disturbs
477
+ // the lookup injection that already happened (mirrors hypo-session-record).
478
+ export function recordLookupUsage(hypoDir, { sessionId = null, slugs = [] } = {}) {
479
+ try {
480
+ if (!Array.isArray(slugs) || slugs.length === 0) return;
481
+ const target = join(hypoDir, PAGE_USAGE_REL);
482
+ mkdirSync(join(hypoDir, '.cache'), { recursive: true });
483
+ const ts = new Date().toISOString();
484
+ const lines = slugs
485
+ .map((slug) => JSON.stringify({ ts, session_id: sessionId ?? null, slug, source: 'lookup' }))
486
+ .join('\n');
487
+ appendFileSync(target, lines + '\n');
488
+ } catch {
489
+ // logging is observability; never let it break injection (fail-open)
490
+ }
491
+ }
492
+
355
493
  // ── cwd ↔ project matcher ────────────────────────────────────────────────────
356
494
  // Hand-synced with scripts/lib/wd-match.mjs: hooks deploy to ~/.claude/hooks/
357
495
  // without scripts/, so this cannot import the lib and must mirror it. Keep the
@@ -787,13 +925,20 @@ function closeCandidateSlugs(hypoDir, dates) {
787
925
  content = '';
788
926
  }
789
927
  for (const d of dates) {
790
- const re = new RegExp('^## \\[' + escapeRegExp(d) + '\\] session \\| (\\S+)', 'gm');
928
+ // Capture the slug up to the first whitespace OR colon so both canonical
929
+ // log delimiters resolve to the same bare slug: `session | beta — t` and
930
+ // `session | beta: t` both yield `beta`. This parser shares the
931
+ // colon-delimiter contract with hasLogEntry, so a colon-form entry for a
932
+ // real project is a close candidate, not a ghost. `[^\s:]+` cannot span
933
+ // the colon, so a stale/typo heading still yields a token that fails the
934
+ // on-disk directory gate below.
935
+ const re = new RegExp('^## \\[' + escapeRegExp(d) + '\\] session \\| ([^\\s:]+)', 'gm');
791
936
  for (const m of content.matchAll(re)) {
792
- // B-1: only real projects are close candidates. A malformed log heading
793
- // (`## [d] session | hypomnema:`) or a stale slug yields a ghost token
794
- // that no longer maps to a `projects/<slug>/` directory gating on disk
795
- // keeps it out of the dangling-close set. Directory (not bare-exists)
796
- // mirrors the apply-path project check (crystallize.mjs:193).
937
+ // B-1: only real projects are close candidates. A stale or misspelled
938
+ // slug yields a token that no longer maps to a `projects/<slug>/`
939
+ // directory gating on disk keeps it out of the dangling-close set.
940
+ // Directory (not bare-exists) mirrors the apply-path project check
941
+ // (crystallize.mjs:193).
797
942
  const dir = join(projectsDir, m[1]);
798
943
  if (existsSync(dir) && statSync(dir).isDirectory()) slugs.add(m[1]);
799
944
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hypomnema",
3
- "version": "1.5.0",
3
+ "version": "1.6.0",
4
4
  "description": "LLM-native personal wiki system for Claude Code",
5
5
  "type": "module",
6
6
  "license": "MIT",