hypomnema 1.5.0 → 1.5.1
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 +35 -18
- package/README.md +25 -8
- package/commands/audit.md +1 -1
- package/commands/crystallize.md +15 -7
- package/docs/ARCHITECTURE.md +2 -2
- 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/crystallize.mjs +69 -15
- package/scripts/lib/page-usage.mjs +141 -0
- 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
|
}
|
package/package.json
CHANGED
package/scripts/crystallize.mjs
CHANGED
|
@@ -73,6 +73,7 @@ import { fileURLToPath } from 'url';
|
|
|
73
73
|
import { resolveHypoRoot, expandHome } from './lib/hypo-root.mjs';
|
|
74
74
|
import { loadHypoIgnore } from './lib/hypo-ignore.mjs';
|
|
75
75
|
import { collectPagesCrystallize, extractWikilinks } from './lib/wikilink.mjs';
|
|
76
|
+
import { aggregateColdCandidates } from './lib/page-usage.mjs';
|
|
76
77
|
import { isValidProjectName } from './lib/project-create.mjs';
|
|
77
78
|
import { appendPendingTags, checkForbidden } from './lib/schema-vocab.mjs';
|
|
78
79
|
import {
|
|
@@ -88,6 +89,8 @@ import {
|
|
|
88
89
|
sessionLogReadCandidates,
|
|
89
90
|
sessionLogScopePath,
|
|
90
91
|
rootLogEntry,
|
|
92
|
+
hasSessionLogHeading,
|
|
93
|
+
hasLogEntry,
|
|
91
94
|
resolveTranscriptBySessionId,
|
|
92
95
|
hasUserCloseSignal,
|
|
93
96
|
commitWikiChanges,
|
|
@@ -753,21 +756,49 @@ function applySessionClose(args) {
|
|
|
753
756
|
}
|
|
754
757
|
const date = payload.date || todayLocal();
|
|
755
758
|
|
|
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}`);
|
|
759
|
+
// Pre-apply freshness-contract gate: the post-apply verification holds
|
|
760
|
+
// sessionCloseFileStatus's hasSessionLogHeading / hasLogEntry as the
|
|
761
|
+
// definition of "closed today". Enforce that SAME contract on the payload
|
|
762
|
+
// BEFORE writing a byte, so a heading the gate won't recognize is rejected
|
|
763
|
+
// here as a format mismatch — not written and then misdiagnosed downstream as
|
|
764
|
+
// "stale" (the "not updated" vs "format mismatch" conflation). All checks
|
|
765
|
+
// exit 1 with stage='pre-apply-verification' and leave the tree untouched.
|
|
766
|
+
const failPreApply = (msg) => {
|
|
767
|
+
console.log(
|
|
768
|
+
args.json
|
|
769
|
+
? JSON.stringify({ ok: false, stage: 'pre-apply-verification', error: msg }, null, 2)
|
|
770
|
+
: `✗ ${msg}`,
|
|
771
|
+
);
|
|
770
772
|
process.exit(1);
|
|
773
|
+
};
|
|
774
|
+
// (a) The session-log entry must carry a dated `## [<date>] …` ATX heading. The
|
|
775
|
+
// post-apply gate checks the session-log file for exactly this heading, so a
|
|
776
|
+
// headingless entry would write then false-fail as "stale". This also doubles
|
|
777
|
+
// as the B-1 derive precondition: when `log` is omitted the root log.md entry
|
|
778
|
+
// is reconstructed from THIS heading, and on a same-day SECOND close the
|
|
779
|
+
// date-level verifier would still pass on the earlier entry, so a no-derive
|
|
780
|
+
// would slip through as ok:true. The `!payload.log` branch keeps the original
|
|
781
|
+
// derive-specific wording (a test asserts it).
|
|
782
|
+
if (!hasSessionLogHeading(payload.sessionLog.entry || '', date)) {
|
|
783
|
+
failPreApply(
|
|
784
|
+
!payload.log
|
|
785
|
+
? `payload.sessionLog.entry has no "## [${date}] …" heading to derive the log.md ` +
|
|
786
|
+
`entry from. Give it a dated heading, or supply payload.log explicitly.`
|
|
787
|
+
: `payload.sessionLog.entry has no "## [${date}] …" heading. The close gate ` +
|
|
788
|
+
`identifies a session-log by its dated ATX heading; give the entry a ` +
|
|
789
|
+
`"## [${date}] <title>" heading (the brackets are required).`,
|
|
790
|
+
);
|
|
791
|
+
}
|
|
792
|
+
// (b) An explicit payload.log entry must match the canonical
|
|
793
|
+
// `## [<date>] session | <project>` line the gate looks for (colon or space
|
|
794
|
+
// delimiter after the slug). Otherwise the write lands but post-apply
|
|
795
|
+
// verification reports log.md as stale. When `log` is omitted the line is
|
|
796
|
+
// derived canonically (rootLogEntry) so this cannot mismatch.
|
|
797
|
+
if (payload.log && !hasLogEntry(payload.log.entry || '', date, project)) {
|
|
798
|
+
failPreApply(
|
|
799
|
+
`payload.log.entry has no "## [${date}] session | ${project}" heading that the ` +
|
|
800
|
+
`close gate recognizes. Fix the entry heading, or omit payload.log to derive it.`,
|
|
801
|
+
);
|
|
771
802
|
}
|
|
772
803
|
|
|
773
804
|
// Preflight: lint the wiki BEFORE writing any payload bytes. If lint
|
|
@@ -1315,8 +1346,12 @@ const synthesisGroups = Object.entries(tagGroups)
|
|
|
1315
1346
|
.sort((a, b) => b[1].length - a[1].length)
|
|
1316
1347
|
.map(([tag, pages]) => ({ tag, pages }));
|
|
1317
1348
|
|
|
1349
|
+
// Lookup-cold candidates (B): pages with inbound wikilinks that lookup has not
|
|
1350
|
+
// injected within the recency window. Advisory only, never gates, never mutates.
|
|
1351
|
+
const coldCandidates = aggregateColdCandidates(args.hypoDir, { ignorePatterns });
|
|
1352
|
+
|
|
1318
1353
|
if (args.json) {
|
|
1319
|
-
console.log(JSON.stringify({ synthesisGroups, unlinked, drafts }, null, 2));
|
|
1354
|
+
console.log(JSON.stringify({ synthesisGroups, unlinked, drafts, coldCandidates }, null, 2));
|
|
1320
1355
|
process.exit(0);
|
|
1321
1356
|
}
|
|
1322
1357
|
|
|
@@ -1346,6 +1381,25 @@ if (drafts.length > 0) {
|
|
|
1346
1381
|
console.log('');
|
|
1347
1382
|
}
|
|
1348
1383
|
|
|
1384
|
+
// Advisory (non-gating): pages the graph treats as live but lookup has not
|
|
1385
|
+
// injected recently. Held until enough page-usage history accrues.
|
|
1386
|
+
if (coldCandidates.status === 'ok' && coldCandidates.candidates.length > 0) {
|
|
1387
|
+
found = true;
|
|
1388
|
+
console.log(
|
|
1389
|
+
`Lookup-cold pages (${coldCandidates.candidates.length}), inbound links but not injected recently:`,
|
|
1390
|
+
);
|
|
1391
|
+
for (const p of coldCandidates.candidates) console.log(` [[${p.slug}]] (${p.title})`);
|
|
1392
|
+
console.log('');
|
|
1393
|
+
} else if (
|
|
1394
|
+
coldCandidates.status === 'insufficient-data' &&
|
|
1395
|
+
coldCandidates.reason === 'span-too-short'
|
|
1396
|
+
) {
|
|
1397
|
+
// Only surface the "held" notice once a log is actually accruing (span under
|
|
1398
|
+
// the cold-start window). A vault with no log at all stays silent so this
|
|
1399
|
+
// advisory never becomes permanent noise on every crystallize run.
|
|
1400
|
+
console.log('Lookup-cold scan held: not enough page-usage history yet (advisory).\n');
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1349
1403
|
if (!found) {
|
|
1350
1404
|
console.log('✓ No crystallization candidates found — Hypomnema looks well-connected.');
|
|
1351
1405
|
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// page-usage.mjs: read-only aggregation over .cache/page-usage.jsonl (B3)
|
|
2
|
+
//
|
|
3
|
+
// The lookup hook appends one JSONL record per injected page slug (see
|
|
4
|
+
// hooks/hypo-shared.mjs recordLookupUsage). This lib reads that log and derives
|
|
5
|
+
// "lookup-cold candidates": pages that have inbound wikilinks (so the graph
|
|
6
|
+
// treats them as live) but have not been injected by lookup within a recency
|
|
7
|
+
// window. It writes nothing and is only invoked from scripts (crystallize), so
|
|
8
|
+
// it never touches the per-prompt hook hot path.
|
|
9
|
+
|
|
10
|
+
import { readFileSync, existsSync } from 'fs';
|
|
11
|
+
import { join } from 'path';
|
|
12
|
+
import { collectPagesCrystallize, extractWikilinks, slugForms } from './wikilink.mjs';
|
|
13
|
+
import { parseFrontmatter } from './frontmatter.mjs';
|
|
14
|
+
|
|
15
|
+
const PAGE_USAGE_REL = '.cache/page-usage.jsonl';
|
|
16
|
+
const DAY_MS = 86400000;
|
|
17
|
+
|
|
18
|
+
// The distinct link forms a slug may appear as in a [[wikilink]] or in the log:
|
|
19
|
+
// its full path, its basename, and (when nested) the path minus its first
|
|
20
|
+
// segment. Matching by form-set intersection bridges the gap between the log's
|
|
21
|
+
// index-matched slug form and the reverse index's [[wikilink]] form.
|
|
22
|
+
function formSet(slug) {
|
|
23
|
+
const f = slugForms(slug);
|
|
24
|
+
const s = new Set([f.full, f.bare]);
|
|
25
|
+
if (f.dirRel) s.add(f.dirRel);
|
|
26
|
+
return s;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function intersects(a, b) {
|
|
30
|
+
for (const x of a) if (b.has(x)) return true;
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Read every record from the usage log, skipping malformed lines. Returns [] if
|
|
35
|
+
// the log is absent or unreadable (fail-soft: aggregation is advisory).
|
|
36
|
+
export function readPageUsage(hypoDir) {
|
|
37
|
+
const path = join(hypoDir, PAGE_USAGE_REL);
|
|
38
|
+
if (!existsSync(path)) return [];
|
|
39
|
+
let text;
|
|
40
|
+
try {
|
|
41
|
+
text = readFileSync(path, 'utf-8');
|
|
42
|
+
} catch {
|
|
43
|
+
return [];
|
|
44
|
+
}
|
|
45
|
+
const records = [];
|
|
46
|
+
for (const line of text.split('\n')) {
|
|
47
|
+
const trimmed = line.trim();
|
|
48
|
+
if (!trimmed) continue;
|
|
49
|
+
try {
|
|
50
|
+
const parsed = JSON.parse(trimmed);
|
|
51
|
+
// Only keep plain objects. A bare `null`, number, string, or array is
|
|
52
|
+
// valid JSON but not a record; keeping it would crash the field access in
|
|
53
|
+
// aggregateColdCandidates (and crystallize calls that outside a try).
|
|
54
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
55
|
+
records.push(parsed);
|
|
56
|
+
}
|
|
57
|
+
} catch {
|
|
58
|
+
// malformed line → skip (log is append-only and may be partially written)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return records;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Derive lookup-cold candidates. Guards against cold-start: if there are no
|
|
65
|
+
// records, or the observed log span is shorter than minLogSpanDays, returns
|
|
66
|
+
// { status: 'insufficient-data' } so a fresh log doesn't flag the whole vault.
|
|
67
|
+
// Otherwise returns { status: 'ok', candidates: [{ slug, title }] } for pages
|
|
68
|
+
// that have >= 1 inbound wikilink from another page yet have not been logged
|
|
69
|
+
// within the last thresholdDays.
|
|
70
|
+
export function aggregateColdCandidates(
|
|
71
|
+
hypoDir,
|
|
72
|
+
{ thresholdDays = 90, minLogSpanDays = 14, now = Date.now(), ignorePatterns = [] } = {},
|
|
73
|
+
) {
|
|
74
|
+
const records = readPageUsage(hypoDir);
|
|
75
|
+
if (records.length === 0) return { status: 'insufficient-data', reason: 'no-records' };
|
|
76
|
+
|
|
77
|
+
const times = records.map((r) => Date.parse(r.ts)).filter((t) => Number.isFinite(t));
|
|
78
|
+
if (times.length === 0) return { status: 'insufficient-data', reason: 'no-timestamps' };
|
|
79
|
+
const span = Math.max(...times) - Math.min(...times);
|
|
80
|
+
if (span < minLogSpanDays * DAY_MS) {
|
|
81
|
+
return { status: 'insufficient-data', reason: 'span-too-short' };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Forms of every slug logged within the recency window.
|
|
85
|
+
const recentCutoff = now - thresholdDays * DAY_MS;
|
|
86
|
+
const recentForms = new Set();
|
|
87
|
+
for (const r of records) {
|
|
88
|
+
const t = Date.parse(r.ts);
|
|
89
|
+
if (!Number.isFinite(t) || t < recentCutoff) continue;
|
|
90
|
+
if (typeof r.slug !== 'string' || !r.slug) continue;
|
|
91
|
+
for (const form of formSet(r.slug)) recentForms.add(form);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Build the page list with slug, title, forms, and outbound links.
|
|
95
|
+
const pagesDir = join(hypoDir, 'pages');
|
|
96
|
+
const pageList = [];
|
|
97
|
+
for (const { path, rel } of collectPagesCrystallize(pagesDir, hypoDir, ignorePatterns)) {
|
|
98
|
+
let content;
|
|
99
|
+
try {
|
|
100
|
+
content = readFileSync(path, 'utf-8');
|
|
101
|
+
} catch {
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
const fm = parseFrontmatter(content);
|
|
105
|
+
if (!fm) continue;
|
|
106
|
+
const slug = rel.replace(/\.md$/, '');
|
|
107
|
+
const body = content.replace(/^---[\s\S]*?---/, '');
|
|
108
|
+
pageList.push({
|
|
109
|
+
slug,
|
|
110
|
+
title: fm.title || slug,
|
|
111
|
+
forms: formSet(slug),
|
|
112
|
+
links: extractWikilinks(body),
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Reverse index: which page slugs receive >= 1 inbound link from another page.
|
|
117
|
+
const formOwners = new Map(); // form → Set<page slug>
|
|
118
|
+
for (const p of pageList) {
|
|
119
|
+
for (const form of p.forms) {
|
|
120
|
+
if (!formOwners.has(form)) formOwners.set(form, new Set());
|
|
121
|
+
formOwners.get(form).add(p.slug);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
const hasInbound = new Set();
|
|
125
|
+
for (const p of pageList) {
|
|
126
|
+
for (const link of p.links) {
|
|
127
|
+
const targets = new Set();
|
|
128
|
+
for (const form of formSet(link)) {
|
|
129
|
+
const owners = formOwners.get(form);
|
|
130
|
+
if (owners) for (const o of owners) targets.add(o);
|
|
131
|
+
}
|
|
132
|
+
for (const t of targets) if (t !== p.slug) hasInbound.add(t);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const candidates = pageList
|
|
137
|
+
.filter((p) => hasInbound.has(p.slug) && !intersects(p.forms, recentForms))
|
|
138
|
+
.map((p) => ({ slug: p.slug, title: p.title }));
|
|
139
|
+
|
|
140
|
+
return { status: 'ok', candidates };
|
|
141
|
+
}
|
package/scripts/upgrade.mjs
CHANGED
|
@@ -465,6 +465,49 @@ function applyHypoignoreMigration(result) {
|
|
|
465
465
|
return appended;
|
|
466
466
|
}
|
|
467
467
|
|
|
468
|
+
// ── .gitignore migration: mirror .cache/ into .gitignore (page-usage privacy) ─
|
|
469
|
+
//
|
|
470
|
+
// Legacy vaults may have a .gitignore that predates .cache/ (init now scaffolds
|
|
471
|
+
// it, but old vaults were migrated only in .hypoignore). Without .cache/ in
|
|
472
|
+
// .gitignore, the page-usage log could be committed. This mirrors the hypoignore
|
|
473
|
+
// migration: idempotent append of the missing entry, no-op when already present
|
|
474
|
+
// or when there is no .gitignore (the runtime logging guard fails closed for the
|
|
475
|
+
// no-file case, so nothing leaks in the meantime).
|
|
476
|
+
|
|
477
|
+
const GITIGNORE_REQUIRED_ENTRIES = [
|
|
478
|
+
{
|
|
479
|
+
pattern: '.cache/',
|
|
480
|
+
comment: '# Hypomnema runtime cache (page-usage log, session growth metrics)',
|
|
481
|
+
},
|
|
482
|
+
];
|
|
483
|
+
|
|
484
|
+
function checkGitignore(hypoDir) {
|
|
485
|
+
const path = join(hypoDir, '.gitignore');
|
|
486
|
+
if (!existsSync(path)) return { status: 'no-file', missing: [], path };
|
|
487
|
+
const content = readFileSync(path, 'utf-8');
|
|
488
|
+
const entries = new Set(
|
|
489
|
+
content
|
|
490
|
+
.split('\n')
|
|
491
|
+
.map((l) => l.trim())
|
|
492
|
+
.filter((l) => l && !l.startsWith('#')),
|
|
493
|
+
);
|
|
494
|
+
const missing = GITIGNORE_REQUIRED_ENTRIES.filter((e) => !entries.has(e.pattern));
|
|
495
|
+
return { status: missing.length === 0 ? 'up-to-date' : 'needs-migration', missing, path };
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function applyGitignoreMigration(result) {
|
|
499
|
+
if (result.status !== 'needs-migration') return [];
|
|
500
|
+
let content = readFileSync(result.path, 'utf-8');
|
|
501
|
+
if (!content.endsWith('\n')) content += '\n';
|
|
502
|
+
const appended = [];
|
|
503
|
+
for (const entry of result.missing) {
|
|
504
|
+
content += `\n${entry.comment}\n${entry.pattern}\n`;
|
|
505
|
+
appended.push(entry.pattern);
|
|
506
|
+
}
|
|
507
|
+
writeFileSync(result.path, content);
|
|
508
|
+
return appended;
|
|
509
|
+
}
|
|
510
|
+
|
|
468
511
|
function writeMigrationReport(hypoDir, fromVersion, toVersion, { pluginMode = false } = {}) {
|
|
469
512
|
const today = new Date().toISOString().slice(0, 10);
|
|
470
513
|
const filename = `MIGRATION-v${toVersion}.md`;
|
|
@@ -849,6 +892,7 @@ const pkgJson = checkPkgJson();
|
|
|
849
892
|
const commands = checkCommands();
|
|
850
893
|
const oldHookRefs = checkOldHookNames(claudeSettingsPath);
|
|
851
894
|
const hypoignore = checkHypoignore(args.hypoDir);
|
|
895
|
+
const gitignore = checkGitignore(args.hypoDir);
|
|
852
896
|
|
|
853
897
|
// when --codex is set, mirror the same core-hook checks against ~/.codex/
|
|
854
898
|
// so `hypomnema upgrade --codex` reports drift symmetrically and `--apply --codex`
|
|
@@ -930,6 +974,7 @@ let appliedSettingsCodex = [];
|
|
|
930
974
|
let appliedHookNameRenamesCodex = [];
|
|
931
975
|
let appliedCommands = [];
|
|
932
976
|
let appliedHypoignore = [];
|
|
977
|
+
let appliedGitignore = [];
|
|
933
978
|
let appliedExtensions = null;
|
|
934
979
|
let appliedExtensionsCodex = null;
|
|
935
980
|
|
|
@@ -1014,6 +1059,7 @@ if (args.apply) {
|
|
|
1014
1059
|
}
|
|
1015
1060
|
}
|
|
1016
1061
|
appliedHypoignore = applyHypoignoreMigration(hypoignore);
|
|
1062
|
+
appliedGitignore = applyGitignoreMigration(gitignore);
|
|
1017
1063
|
// codex core hooks + settings + wiki-*→hypo-* rename mirror. Same order
|
|
1018
1064
|
// as the claude side (rename first so subsequent hook copy can find renamed targets).
|
|
1019
1065
|
if (args.codex) {
|
|
@@ -1089,6 +1135,7 @@ const hasDrift =
|
|
|
1089
1135
|
pkgJsonDrift ||
|
|
1090
1136
|
schemaDrift ||
|
|
1091
1137
|
hypoignore.status === 'needs-migration' ||
|
|
1138
|
+
gitignore.status === 'needs-migration' ||
|
|
1092
1139
|
extDrift ||
|
|
1093
1140
|
codexCoreDrift;
|
|
1094
1141
|
|
|
@@ -1109,6 +1156,7 @@ if (args.json) {
|
|
|
1109
1156
|
commands,
|
|
1110
1157
|
oldHookRefs,
|
|
1111
1158
|
hypoignore,
|
|
1159
|
+
gitignore,
|
|
1112
1160
|
extensions: extCheck,
|
|
1113
1161
|
extensionsCodex: extCheckCodex,
|
|
1114
1162
|
// codex core mirror (null when --codex absent).
|
|
@@ -1122,6 +1170,7 @@ if (args.json) {
|
|
|
1122
1170
|
hookNameRenames: appliedHookNameRenames,
|
|
1123
1171
|
commands: appliedCommands,
|
|
1124
1172
|
hypoignore: appliedHypoignore,
|
|
1173
|
+
gitignore: appliedGitignore,
|
|
1125
1174
|
extensions: appliedExtensions,
|
|
1126
1175
|
extensionsCodex: appliedExtensionsCodex,
|
|
1127
1176
|
hooksCodex: appliedHooksCodex,
|
|
@@ -1369,6 +1418,18 @@ if (hypoignore.status === 'no-file') {
|
|
|
1369
1418
|
for (const e of hypoignore.missing) lines.push(` + ${e.pattern}`);
|
|
1370
1419
|
}
|
|
1371
1420
|
|
|
1421
|
+
// .gitignore migration (page-usage privacy: mirror .cache/ into .gitignore)
|
|
1422
|
+
if (gitignore.status === 'no-file') {
|
|
1423
|
+
lines.push(`⚠ .gitignore not found at ${gitignore.path} (init.mjs scaffolds this)`);
|
|
1424
|
+
} else if (gitignore.status === 'up-to-date') {
|
|
1425
|
+
lines.push(`✓ .gitignore required entries present`);
|
|
1426
|
+
} else {
|
|
1427
|
+
lines.push(
|
|
1428
|
+
`⚠ .gitignore ${gitignore.missing.length} missing entry(s), run --apply to append:`,
|
|
1429
|
+
);
|
|
1430
|
+
for (const e of gitignore.missing) lines.push(` + ${e.pattern}`);
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1372
1433
|
// Extensions companion (conflict/drift gating E3, #31). Shared by the
|
|
1373
1434
|
// claude target and, under --codex, the codex target (E4, #32) — the label keeps
|
|
1374
1435
|
// the two blocks distinguishable in the report.
|
|
@@ -1424,6 +1485,7 @@ if (
|
|
|
1424
1485
|
appliedHookNameRenames.length > 0 ||
|
|
1425
1486
|
appliedCommands.length > 0 ||
|
|
1426
1487
|
appliedHypoignore.length > 0 ||
|
|
1488
|
+
appliedGitignore.length > 0 ||
|
|
1427
1489
|
appliedHooksCodex.length > 0 ||
|
|
1428
1490
|
appliedSettingsCodex.length > 0 ||
|
|
1429
1491
|
appliedHookNameRenamesCodex.length > 0
|
|
@@ -1452,6 +1514,10 @@ if (
|
|
|
1452
1514
|
lines.push(`✓ Appended .hypoignore entries (${appliedHypoignore.length}):`);
|
|
1453
1515
|
for (const e of appliedHypoignore) lines.push(` → ${e}`);
|
|
1454
1516
|
}
|
|
1517
|
+
if (appliedGitignore.length > 0) {
|
|
1518
|
+
lines.push(`✓ Appended .gitignore entries (${appliedGitignore.length}):`);
|
|
1519
|
+
for (const e of appliedGitignore) lines.push(` → ${e}`);
|
|
1520
|
+
}
|
|
1455
1521
|
// codex-target applied actions (mirrors claude blocks above).
|
|
1456
1522
|
if (appliedHookNameRenamesCodex.length > 0) {
|
|
1457
1523
|
lines.push(`✓ Renamed legacy hook references (codex) (${appliedHookNameRenamesCodex.length}):`);
|
|
@@ -1507,6 +1573,7 @@ const totalDrift =
|
|
|
1507
1573
|
(pkgJsonDrift ? 1 : 0) +
|
|
1508
1574
|
(schemaDrift ? 1 : 0) +
|
|
1509
1575
|
(hypoignore.status === 'needs-migration' ? hypoignore.missing.length : 0) +
|
|
1576
|
+
(gitignore.status === 'needs-migration' ? gitignore.missing.length : 0) +
|
|
1510
1577
|
extCheck.actions.filter(
|
|
1511
1578
|
(a) => a.action === 'create' || a.action === 'update' || a.action === 'force-update',
|
|
1512
1579
|
).length +
|
|
@@ -1544,6 +1611,7 @@ if (totalDrift === 0) {
|
|
|
1544
1611
|
(appliedPkgJson ? 1 : 0) +
|
|
1545
1612
|
appliedHookNameRenames.length +
|
|
1546
1613
|
appliedHypoignore.length +
|
|
1614
|
+
appliedGitignore.length +
|
|
1547
1615
|
appliedExtCount +
|
|
1548
1616
|
appliedHooksCodex.length +
|
|
1549
1617
|
appliedSettingsCodex.length +
|
|
@@ -92,7 +92,9 @@ main conversation id. Passing the wrong id causes `markerWritten: false` with
|
|
|
92
92
|
`markerSkipReason: "transcript-unresolved"` or `"no-user-close-signal"`: the
|
|
93
93
|
5 mandatory files are written (ok: true) but the Stop-chain marker is withheld,
|
|
94
94
|
so the session is not actually closed and the Stop hook re-prompts. The correct
|
|
95
|
-
id comes from the `[WIKI_AUTOCLOSE]` block reason or
|
|
95
|
+
id comes from the `[WIKI_AUTOCLOSE]` block reason or the injected
|
|
96
|
+
`$CLAUDE_CODE_SESSION_ID` (accept the legacy spelling via
|
|
97
|
+
`${CLAUDE_CODE_SESSION_ID:-$CLAUDE_SESSION_ID}`).
|
|
96
98
|
|
|
97
99
|
Once it passes, report each item with ✓:
|
|
98
100
|
- ✓ session-state.md
|
|
@@ -100,11 +102,15 @@ Once it passes, report each item with ✓:
|
|
|
100
102
|
- ✓ session-log entry
|
|
101
103
|
- ✓ open-questions (or skipped if unchanged)
|
|
102
104
|
- ✓ log.md entry
|
|
103
|
-
- **marker written?** (required, if `--apply-session-close --session-id` was used): check `markerWritten` in the JSON output. If `true`, report "session-close marker written." If `false`, do NOT declare the session closed or complete
|
|
105
|
+
- **marker written?** (required, if `--apply-session-close --session-id` was used): check `markerWritten` in the JSON output. If `true`, report "session-close marker written." If `false`, do NOT declare the session closed or complete; recover per the `markerSkipReason` branch below.
|
|
104
106
|
|
|
105
107
|
If `markerWritten: true` (or `--session-id` was not passed), ask: "Session closed. Would you like to also run knowledge synthesis now, or stop here?"
|
|
106
108
|
|
|
107
|
-
If `markerWritten: false`, do NOT say "session closed."
|
|
109
|
+
If `markerWritten: false`, do NOT say "session closed." Branch on `markerSkipReason`:
|
|
110
|
+
|
|
111
|
+
- `no-user-close-signal`: files applied cleanly and the transcript resolved, but it carries no close phrase the gate recognizes (the user's close wording fell outside the close-signal set, e.g. "세션 마무리까지 진행해줘" / "세션 마무리 진행"). Re-running the same id will not help. Confirm intent once with `AskUserQuestion` (header "세션", one option **세션 마무리**); if the user picks it, that answer is a recognized close signal, so re-run the same `--apply-session-close … --session-id` command (idempotent no-op writes; the marker lands). If the user declines, leave the session unmarked. Do NOT change the close-signal matcher.
|
|
112
|
+
- `transcript-unresolved` (wrong / background id): report "Files applied and verified (ok: true), but the marker was not written (reason: `<markerSkipReason>`). The Stop-chain is still active. Re-run with the correct main-conversation `--session-id`."
|
|
113
|
+
- any other reason (`compact-gate-not-ok`, `commit-failed: …`, `marker-did-not-land`): surface it verbatim and resolve the underlying blocker before re-running.
|
|
108
114
|
|
|
109
115
|
---
|
|
110
116
|
|
|
@@ -141,4 +147,4 @@ evidence_strength: inferred
|
|
|
141
147
|
|
|
142
148
|
---
|
|
143
149
|
|
|
144
|
-
> **Citation convention.** When you reference a wiki page in your response, link it as `[[page-slug]]
|
|
150
|
+
> **Citation convention.** When you reference a wiki page in your response, link it as `[[page-slug]]` so it stays connected in the graph. The observability audit scores sessions on search / ingest / feedback activity (recorded by `hypo-session-record`), not on these inline citations; run `/hypo:audit` to inspect and see [[pages/observability/_index]].
|
package/skills/graph/SKILL.md
CHANGED
|
@@ -55,4 +55,4 @@ If the graph has 0 edges, note that no `[[wikilinks]]` were found between pages.
|
|
|
55
55
|
|
|
56
56
|
---
|
|
57
57
|
|
|
58
|
-
> **Citation convention.** When you reference a wiki page in your response, link it as `[[page-slug]]
|
|
58
|
+
> **Citation convention.** When you reference a wiki page in your response, link it as `[[page-slug]]` so it stays connected in the graph. The observability audit scores sessions on search / ingest / feedback activity (recorded by `hypo-session-record`), not on these inline citations; run `/hypo:audit` to inspect and see [[pages/observability/_index]].
|