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/hooks/hypo-shared.mjs
CHANGED
|
@@ -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 `(
|
|
288
|
-
* matches between word and non-word chars, so `\b` after "foo" still
|
|
289
|
-
* "foo-bar" (hyphen is non-word). The canonical log format
|
|
290
|
-
* project slug from anything that follows by whitespace
|
|
291
|
-
* lookahead correctly rejects "session | foo-bar" when
|
|
292
|
-
*
|
|
293
|
-
*
|
|
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) + '(
|
|
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
|
-
|
|
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
|
|
793
|
-
//
|
|
794
|
-
//
|
|
795
|
-
//
|
|
796
|
-
//
|
|
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
|
}
|