hypomnema 1.4.2 → 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 +2 -2
- package/commands/crystallize.md +22 -14
- package/commands/doctor.md +1 -1
- package/commands/feedback.md +2 -2
- package/commands/graph.md +1 -1
- package/commands/ingest.md +1 -1
- package/commands/init.md +2 -2
- package/commands/lint.md +1 -1
- package/commands/query.md +1 -1
- package/commands/rename.md +1 -1
- package/commands/resume.md +1 -1
- package/commands/stats.md +1 -1
- package/commands/uninstall.md +4 -2
- package/commands/upgrade.md +1 -1
- package/commands/verify.md +1 -1
- package/docs/ARCHITECTURE.md +4 -4
- package/docs/CONTRIBUTING.md +8 -7
- package/hooks/hypo-auto-commit.mjs +2 -2
- package/hooks/hypo-auto-minimal-crystallize.mjs +7 -7
- package/hooks/hypo-compact-guard.mjs +2 -2
- package/hooks/hypo-cwd-change.mjs +27 -37
- package/hooks/hypo-lookup.mjs +37 -2
- package/hooks/hypo-personal-check.mjs +37 -22
- package/hooks/hypo-session-end.mjs +1 -1
- package/hooks/hypo-session-record.mjs +2 -2
- package/hooks/hypo-session-start.mjs +62 -42
- package/hooks/hypo-shared.mjs +430 -85
- package/hooks/version-check.mjs +1 -1
- package/package.json +5 -1
- package/scripts/check-tracker-ids.mjs +69 -31
- package/scripts/crystallize.mjs +151 -53
- package/scripts/doctor.mjs +7 -7
- package/scripts/feedback-sync.mjs +5 -5
- package/scripts/feedback.mjs +9 -10
- package/scripts/init.mjs +7 -7
- package/scripts/lib/check-tracker-ids.mjs +90 -32
- package/scripts/lib/design-history-stale.mjs +1 -1
- package/scripts/lib/extensions.mjs +7 -7
- package/scripts/lib/failure-type.mjs +1 -1
- package/scripts/lib/feedback-scope.mjs +1 -1
- package/scripts/lib/page-usage.mjs +141 -0
- package/scripts/lib/project-create.mjs +2 -2
- package/scripts/lib/template-schema-version.mjs +1 -1
- package/scripts/lib/wd-match.mjs +181 -0
- package/scripts/lib/wikilink.mjs +1 -1
- package/scripts/lint.mjs +5 -5
- package/scripts/rename.mjs +1 -1
- package/scripts/resume.mjs +20 -22
- package/scripts/session-audit.mjs +1 -1
- package/scripts/stats.mjs +3 -3
- package/scripts/uninstall.mjs +3 -3
- package/scripts/upgrade.mjs +85 -18
- package/skills/crystallize/SKILL.md +17 -11
- package/skills/graph/SKILL.md +3 -3
- package/skills/ingest/SKILL.md +5 -5
- package/skills/lint/SKILL.md +3 -3
- package/skills/query/SKILL.md +3 -3
- package/skills/verify/SKILL.md +3 -3
- package/templates/SCHEMA.md +1 -1
- package/templates/hypo-config.md +1 -1
- package/templates/hypo-guide.md +21 -15
- package/templates/projects/_template/hot.md +1 -1
- package/scripts/fix-status-verify.mjs +0 -256
- package/scripts/lib/adr-corpus.mjs +0 -79
- package/scripts/lib/fix-manifest.mjs +0 -109
- package/scripts/lib/fix-status-verify.mjs +0 -439
package/hooks/hypo-shared.mjs
CHANGED
|
@@ -129,7 +129,7 @@ export function lastSubstantialOpIsSession() {
|
|
|
129
129
|
return /^## \[\d{4}-\d{2}-\d{2}\] session/.test(substantial[substantial.length - 1]);
|
|
130
130
|
}
|
|
131
131
|
|
|
132
|
-
// Returns the wiki's git state split into its two independent axes
|
|
132
|
+
// Returns the wiki's git state split into its two independent axes:
|
|
133
133
|
// uncommitted — working-tree changes (real unsaved work; a human-fixable blocker)
|
|
134
134
|
// ahead — committed-but-unpushed commits (a soft, auto-synced state: the
|
|
135
135
|
// auto-commit Stop hook pushes, and push failures are non-fatal —
|
|
@@ -232,7 +232,7 @@ export function hasSessionLogHeading(content, date) {
|
|
|
232
232
|
|
|
233
233
|
/**
|
|
234
234
|
* Canonical session-log shard path (repo-relative POSIX) for a single day.
|
|
235
|
-
*
|
|
235
|
+
* Option D: the date IS the filename (`YYYY-MM-DD.md`), so no
|
|
236
236
|
* "current part" resolver is needed and the per-session write touches a small
|
|
237
237
|
* file instead of a multi-thousand-line monthly log. This is the WRITE target.
|
|
238
238
|
*/
|
|
@@ -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,41 +357,354 @@ function parseFrontmatterField(content, key) {
|
|
|
352
357
|
.replace(/^['"]|['"]$/g, '');
|
|
353
358
|
}
|
|
354
359
|
|
|
355
|
-
//
|
|
356
|
-
// is
|
|
357
|
-
//
|
|
358
|
-
//
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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
|
+
|
|
493
|
+
// ── cwd ↔ project matcher ────────────────────────────────────────────────────
|
|
494
|
+
// Hand-synced with scripts/lib/wd-match.mjs: hooks deploy to ~/.claude/hooks/
|
|
495
|
+
// without scripts/, so this cannot import the lib and must mirror it. Keep the
|
|
496
|
+
// two in step; the lib carries the unit tests.
|
|
497
|
+
|
|
498
|
+
// Expand a leading ~/ (or bare ~), strip trailing slashes. null for empty.
|
|
499
|
+
export function normalizeWorkingDir(p) {
|
|
500
|
+
if (!p) return null;
|
|
501
|
+
let s = String(p).trim();
|
|
502
|
+
if (s === '~') s = homedir();
|
|
503
|
+
else if (s.startsWith('~/')) s = `${homedir()}/${s.slice(2)}`;
|
|
504
|
+
s = s.replace(/\/+$/, '');
|
|
505
|
+
return s || null;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// macOS/Windows match paths case-insensitively; Linux does not. Fold only there.
|
|
509
|
+
export function isCaseInsensitiveFs(platform = process.platform) {
|
|
510
|
+
return platform === 'darwin' || platform === 'win32';
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function _fold(s, ci) {
|
|
514
|
+
return ci ? s.toLowerCase() : s;
|
|
515
|
+
}
|
|
516
|
+
function _lastSeg(p) {
|
|
517
|
+
const i = p.lastIndexOf('/');
|
|
518
|
+
return i < 0 ? p : p.slice(i + 1);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// Resolve which project owns cwd. Tier 1: longest absolute working_dir prefix
|
|
522
|
+
// (the original behavior). Tier 2 (cross-machine): when the synced vault holds
|
|
523
|
+
// another machine's absolute path, a cwd ancestor whose directory name is a
|
|
524
|
+
// GLOBALLY unique project basename identifies the project; a shared dirname
|
|
525
|
+
// declines (null) so the caller falls back to recency. `projects` is the whole
|
|
526
|
+
// universe (for the uniqueness gate); `eligible` restricts the answer.
|
|
527
|
+
export function pickProjectByCwd(projects, cwd, opts = {}) {
|
|
528
|
+
const { eligible = null, realpathCwd = null, caseInsensitive = isCaseInsensitiveFs() } = opts;
|
|
529
|
+
if (!cwd && !realpathCwd) return null;
|
|
530
|
+
const eligibleSet = eligible ? new Set(eligible) : null;
|
|
531
|
+
const isEligible = (slug) => !eligibleSet || eligibleSet.has(slug);
|
|
532
|
+
|
|
533
|
+
const entries = [];
|
|
534
|
+
for (const p of projects) {
|
|
535
|
+
const path = normalizeWorkingDir(p.workingDir);
|
|
536
|
+
if (path) entries.push({ slug: p.slug, path });
|
|
537
|
+
}
|
|
538
|
+
if (entries.length === 0) return null;
|
|
539
|
+
|
|
540
|
+
// raw cwd first, realpath only as a fallback (a raw match must not be
|
|
541
|
+
// overridden by a longer realpath match).
|
|
542
|
+
const cwds = [];
|
|
543
|
+
for (const c of [cwd, realpathCwd]) {
|
|
544
|
+
const n = normalizeWorkingDir(c);
|
|
545
|
+
if (n && !cwds.includes(n)) cwds.push(n);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// Tier 1: first cwd variant with any longest-prefix match wins.
|
|
549
|
+
for (const c of cwds) {
|
|
550
|
+
const cf = _fold(c, caseInsensitive);
|
|
551
|
+
let bestSlug = null;
|
|
552
|
+
let bestLen = -1;
|
|
553
|
+
for (const e of entries) {
|
|
554
|
+
if (!isEligible(e.slug)) continue;
|
|
555
|
+
const pf = _fold(e.path, caseInsensitive);
|
|
556
|
+
if ((cf === pf || cf.startsWith(`${pf}/`)) && e.path.length > bestLen) {
|
|
557
|
+
bestLen = e.path.length;
|
|
558
|
+
bestSlug = e.slug;
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
if (bestSlug) return bestSlug;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// Tier 2: unique-basename ancestor, but only when the chain points at exactly
|
|
565
|
+
// ONE project (two distinct matches along the path → decline, fail closed).
|
|
566
|
+
const byBasename = new Map();
|
|
567
|
+
for (const e of entries) {
|
|
568
|
+
const b = _fold(_lastSeg(e.path), caseInsensitive);
|
|
569
|
+
if (!b) continue;
|
|
570
|
+
const hit = byBasename.get(b);
|
|
571
|
+
if (hit) hit.count += 1;
|
|
572
|
+
else byBasename.set(b, { slug: e.slug, count: 1 });
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
for (const c of cwds) {
|
|
576
|
+
const matched = new Set();
|
|
577
|
+
let cur = c;
|
|
578
|
+
while (cur && cur.includes('/')) {
|
|
579
|
+
const b = _fold(_lastSeg(cur), caseInsensitive);
|
|
580
|
+
const hit = b && byBasename.get(b);
|
|
581
|
+
if (hit && hit.count === 1 && isEligible(hit.slug)) matched.add(hit.slug);
|
|
582
|
+
cur = cur.slice(0, cur.lastIndexOf('/'));
|
|
583
|
+
}
|
|
584
|
+
if (matched.size === 1) return [...matched][0];
|
|
585
|
+
}
|
|
586
|
+
return null;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// Disk companion: [{slug, workingDir}] for every real project (skips _template
|
|
590
|
+
// and dirs without index.md). The full set is the tier-2 uniqueness universe.
|
|
591
|
+
// working_dir is cleaned exactly like scripts/lib/frontmatter.mjs parseFrontmatter
|
|
592
|
+
// (strip a trailing ` # comment`, then surrounding quotes) so this stays in step
|
|
593
|
+
// with the script-side collector — parseFrontmatterField alone skips the comment.
|
|
594
|
+
export function collectProjectWorkingDirs(hypoDir) {
|
|
595
|
+
const projectsDir = join(hypoDir, 'projects');
|
|
596
|
+
if (!existsSync(projectsDir)) return [];
|
|
597
|
+
const out = [];
|
|
598
|
+
for (const slug of readdirSync(projectsDir)) {
|
|
599
|
+
if (slug === '_template') continue;
|
|
600
|
+
const dir = join(projectsDir, slug);
|
|
601
|
+
try {
|
|
602
|
+
if (!statSync(dir).isDirectory()) continue;
|
|
603
|
+
} catch {
|
|
604
|
+
continue;
|
|
605
|
+
}
|
|
606
|
+
const indexPath = join(dir, 'index.md');
|
|
365
607
|
if (!existsSync(indexPath)) continue;
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
608
|
+
let workingDir = null;
|
|
609
|
+
try {
|
|
610
|
+
const fm = readFileSync(indexPath, 'utf-8').match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
611
|
+
const line = fm && fm[1].split(/\r?\n/).find((l) => /^working_dir:/.test(l));
|
|
612
|
+
if (line) {
|
|
613
|
+
workingDir = line
|
|
614
|
+
.slice('working_dir:'.length)
|
|
615
|
+
.trim()
|
|
616
|
+
.replace(/\s+#.*$/, '')
|
|
617
|
+
.replace(/^['"]|['"]$/g, '');
|
|
618
|
+
}
|
|
619
|
+
} catch {
|
|
620
|
+
workingDir = null;
|
|
373
621
|
}
|
|
622
|
+
out.push({ slug, workingDir: workingDir || null });
|
|
623
|
+
}
|
|
624
|
+
return out;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/**
|
|
628
|
+
* When the session cwd is a project working_dir distinct from the vault root,
|
|
629
|
+
* the wiki/knowledge files live in the VAULT, not in this cwd. SessionStart and
|
|
630
|
+
* CwdChanged inject hot.md/session-state content but never the vault's absolute
|
|
631
|
+
* path, so the AI re-discovers it each session and can wrongly conclude a wiki
|
|
632
|
+
* file is missing after checking only the code repo (a real misjudgment seen in
|
|
633
|
+
* a dev-repo session, 2026-06-23).
|
|
634
|
+
*
|
|
635
|
+
* Returns a one-line "look in the vault, not here" orientation carrying the
|
|
636
|
+
* absolute vault path, or '' when cwd is anywhere inside the vault tree (the
|
|
637
|
+
* "wiki files live in the vault, not here" framing would be false there) or
|
|
638
|
+
* hypoDir is unset. The HIT matcher is prefix-based, so a project whose
|
|
639
|
+
* working_dir is the vault root can match a vault SUBDIRECTORY; checking only
|
|
640
|
+
* exact root-equality would wrongly fire for those. Compared via realpath so a
|
|
641
|
+
* symlinked cwd or vault still matches.
|
|
642
|
+
*
|
|
643
|
+
* Containment uses the SAME normalize + case-fold + prefix policy as
|
|
644
|
+
* pickProjectByCwd so the two never disagree: on a case-insensitive FS the
|
|
645
|
+
* matcher case-folds, so a cwd differing only in case is still a HIT and must be
|
|
646
|
+
* suppressed here too (otherwise a false orientation leaks).
|
|
647
|
+
*
|
|
648
|
+
* @param {string} cwd the session cwd (already known to be a project HIT)
|
|
649
|
+
* @param {string} [hypoDir=HYPO_DIR]
|
|
650
|
+
* @param {{caseInsensitive?: boolean}} [opts] override FS case policy (tests)
|
|
651
|
+
* @returns {string}
|
|
652
|
+
*/
|
|
653
|
+
export function buildVaultOrientation(cwd, hypoDir = HYPO_DIR, opts = {}) {
|
|
654
|
+
if (!cwd || !hypoDir) return '';
|
|
655
|
+
const { caseInsensitive = isCaseInsensitiveFs() } = opts;
|
|
656
|
+
let realCwd = cwd;
|
|
657
|
+
let realVault = hypoDir;
|
|
658
|
+
try {
|
|
659
|
+
realCwd = realpathSync(cwd);
|
|
660
|
+
} catch {
|
|
661
|
+
/* keep raw path when cwd is unreadable */
|
|
662
|
+
}
|
|
663
|
+
try {
|
|
664
|
+
realVault = realpathSync(hypoDir);
|
|
665
|
+
} catch {
|
|
666
|
+
/* keep raw path when vault is unreadable */
|
|
667
|
+
}
|
|
668
|
+
// Suppress when cwd is the vault root OR a descendant of it.
|
|
669
|
+
const c = _fold(normalizeWorkingDir(realCwd) || '', caseInsensitive);
|
|
670
|
+
const v = _fold(normalizeWorkingDir(realVault) || '', caseInsensitive);
|
|
671
|
+
if (!c || !v) return '';
|
|
672
|
+
if (c === v || c.startsWith(`${v}/`)) return '';
|
|
673
|
+
return (
|
|
674
|
+
`[WIKI VAULT: ${hypoDir}] 이 cwd는 작업/코드 레포이고 vault가 아니다. ` +
|
|
675
|
+
`wiki·knowledge·세션로그 파일은 여기가 아니라 vault(${hypoDir})에서 조회한다.`
|
|
676
|
+
);
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
// resume/close entry: match `slugs` against cwd via the two-tier matcher.
|
|
680
|
+
// Uniqueness is judged over EVERY project on disk, not just `slugs`. close
|
|
681
|
+
// callers pass no cwd, so it stays inert for them.
|
|
682
|
+
function pickByCwd(hypoDir, slugs, cwd) {
|
|
683
|
+
if (!cwd) return null;
|
|
684
|
+
let realpathCwd = null;
|
|
685
|
+
try {
|
|
686
|
+
realpathCwd = realpathSync(cwd);
|
|
687
|
+
} catch {
|
|
688
|
+
realpathCwd = null;
|
|
374
689
|
}
|
|
375
|
-
return
|
|
690
|
+
return pickProjectByCwd(collectProjectWorkingDirs(hypoDir), cwd, {
|
|
691
|
+
eligible: slugs,
|
|
692
|
+
realpathCwd,
|
|
693
|
+
});
|
|
376
694
|
}
|
|
377
695
|
|
|
378
696
|
/**
|
|
379
697
|
* Resolve the active project slug from root hot.md. With a cwd, a project whose
|
|
380
|
-
* working_dir contains it wins (cwd-first
|
|
698
|
+
* working_dir contains it wins (cwd-first); otherwise the
|
|
381
699
|
* most-recently-active row is returned.
|
|
382
700
|
* The cwd helpers (parseFrontmatterField / pickByCwd) and the cwd-first body
|
|
383
701
|
* are kept in sync with scripts/resume.mjs by hand; the surrounding wrapper
|
|
384
702
|
* intentionally differs (resume.mjs adds an mtime fallback, this does not).
|
|
385
|
-
* `cwd` is an optional cwd-first selector
|
|
703
|
+
* `cwd` is an optional cwd-first selector: a cwd↔working_dir match
|
|
386
704
|
* wins over recency. resume passes process.cwd(); session-close callers
|
|
387
705
|
* (sessionCloseFileStatus / closeFileTargets) intentionally pass null — close
|
|
388
|
-
* has a different authority (payload.project / freshness, the global invariant
|
|
389
|
-
*
|
|
706
|
+
* has a different authority (payload.project / freshness, the global invariant),
|
|
707
|
+
* so it never picks by cwd. When cwd is omitted, behavior is
|
|
390
708
|
* identical to the legacy recency version.
|
|
391
709
|
* @param {string} hypoDir
|
|
392
710
|
* @param {string|null} [cwd]
|
|
@@ -410,7 +728,7 @@ export function resolveActiveProject(hypoDir, cwd = null) {
|
|
|
410
728
|
),
|
|
411
729
|
].map((m) => ({ name: m[1].trim(), date: m[2] || '', slug: m[3] }));
|
|
412
730
|
if (wikiRows.length > 0) {
|
|
413
|
-
// cwd-first
|
|
731
|
+
// cwd-first: a cwd↔working_dir match wins over recency, across
|
|
414
732
|
// ALL rows. Kept in sync with scripts/resume.mjs. close callers pass null →
|
|
415
733
|
// recency path below (resume=cwd-positive / close=no-pick).
|
|
416
734
|
if (cwd) {
|
|
@@ -445,7 +763,7 @@ export function resolveActiveProject(hypoDir, cwd = null) {
|
|
|
445
763
|
* - projects/<project>/hot.md — frontmatter `updated:` is today
|
|
446
764
|
* - hot.md (root) — frontmatter `updated:` is today
|
|
447
765
|
* - projects/<project>/session-log/YYYY-MM-DD.md — has a `## [today]` heading
|
|
448
|
-
* (daily shard
|
|
766
|
+
* (daily shard; legacy YYYY-MM.md is still accepted as fallback)
|
|
449
767
|
* - log.md — has a `## [today] session | <project>` entry
|
|
450
768
|
* The log.md check is project-scoped so a session close left incomplete for
|
|
451
769
|
* project A can't be masked by a fresh close of project B (and vice versa).
|
|
@@ -499,7 +817,7 @@ export function sessionCloseFileStatus(hypoDir, { projectOverride = null } = {})
|
|
|
499
817
|
checkUpdated(join('projects', project, 'hot.md'));
|
|
500
818
|
checkUpdated('hot.md');
|
|
501
819
|
|
|
502
|
-
// session-log: daily shard
|
|
820
|
+
// session-log: daily shard, with legacy monthly fallback: must
|
|
503
821
|
// carry a today-dated heading in whichever file holds it. Daily-first read
|
|
504
822
|
// order short-circuits on the small shard. When no match is found, the gap is
|
|
505
823
|
// reported under the canonical daily shard for the local date (dates[0]).
|
|
@@ -544,19 +862,19 @@ export function sessionCloseFileStatus(hypoDir, { projectOverride = null } = {})
|
|
|
544
862
|
return { ok: stale.length === 0 && missing.length === 0, project, dates, stale, missing };
|
|
545
863
|
}
|
|
546
864
|
|
|
547
|
-
// ── global session-close gate
|
|
865
|
+
// ── global session-close gate ────────────────
|
|
548
866
|
// The no-payload close paths must NOT pick one project (recency / cwd) and check
|
|
549
867
|
// it — that re-derivation is the prior session-close false-block, and a cwd
|
|
550
868
|
// tie-break here would let a fresh cwd mask a DIFFERENT project's dangling
|
|
551
869
|
// close. Instead the gate enforces a global invariant: no project may end a
|
|
552
|
-
// session with a partial close. resume stays cwd-positive
|
|
870
|
+
// session with a partial close. resume stays cwd-positive; close
|
|
553
871
|
// never picks. The two copies of resolveActiveProject share the cwd-first body
|
|
554
872
|
// but the resume.mjs copy adds an mtime fallback this one omits — see resume.mjs.
|
|
555
873
|
|
|
556
874
|
// Root hot.md Active-Projects rows as {slug, date}. The per-row date column is
|
|
557
875
|
// project-scoped (unlike the shared frontmatter `updated:`). Used for candidate
|
|
558
|
-
// DISCOVERY in closeCandidateSlugs
|
|
559
|
-
// dropped
|
|
876
|
+
// DISCOVERY in closeCandidateSlugs, not as close-activity evidence: the row date
|
|
877
|
+
// was dropped as an activity signal because project-create and
|
|
560
878
|
// hypo-hot-rebuild both stamp rows today without a real session. Mirrors
|
|
561
879
|
// resolveActiveProject's regex.
|
|
562
880
|
function rootHotRows(hypoDir) {
|
|
@@ -607,13 +925,20 @@ function closeCandidateSlugs(hypoDir, dates) {
|
|
|
607
925
|
content = '';
|
|
608
926
|
}
|
|
609
927
|
for (const d of dates) {
|
|
610
|
-
|
|
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');
|
|
611
936
|
for (const m of content.matchAll(re)) {
|
|
612
|
-
// B-1: only real projects are close candidates. A
|
|
613
|
-
//
|
|
614
|
-
//
|
|
615
|
-
//
|
|
616
|
-
//
|
|
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).
|
|
617
942
|
const dir = join(projectsDir, m[1]);
|
|
618
943
|
if (existsSync(dir) && statSync(dir).isDirectory()) slugs.add(m[1]);
|
|
619
944
|
}
|
|
@@ -625,9 +950,9 @@ function closeCandidateSlugs(hypoDir, dates) {
|
|
|
625
950
|
// True when project P shows an AUTHORITATIVE today close-activity signal: a
|
|
626
951
|
// today-dated session-log heading, or a today-dated `## [today] session | P`
|
|
627
952
|
// log.md entry. These are written ONLY by a real session close (apply, or its
|
|
628
|
-
// auto-derived root log
|
|
953
|
+
// auto-derived root log).
|
|
629
954
|
//
|
|
630
|
-
//
|
|
955
|
+
// Soft state files are EXCLUDED, because each is bumped to today by
|
|
631
956
|
// non-session tooling and is therefore indistinguishable from a real close:
|
|
632
957
|
// - session-state.md `updated:` — tracker bookkeeping mirrors a new item into
|
|
633
958
|
// the "next tasks" section (a cross-block incident: editing one project's
|
|
@@ -641,7 +966,7 @@ function closeCandidateSlugs(hypoDir, dates) {
|
|
|
641
966
|
// Tradeoff (documented, accepted): apply writes session-state.md FIRST, then the
|
|
642
967
|
// project files, then the session-log + log entry. A process crash before the
|
|
643
968
|
// session-log write leaves a torn close that this gate no longer flags. Accepted:
|
|
644
|
-
// a torn close never reached apply's ok=true so it wrote no marker
|
|
969
|
+
// a torn close never reached apply's ok=true so it wrote no marker;
|
|
645
970
|
// the surviving session-state is the resume pointer the next session overwrites;
|
|
646
971
|
// what is lost is a narrative log entry, not continuity.
|
|
647
972
|
function hasTodayCloseActivity(hypoDir, project, dates) {
|
|
@@ -669,7 +994,7 @@ function hasTodayCloseActivity(hypoDir, project, dates) {
|
|
|
669
994
|
}
|
|
670
995
|
|
|
671
996
|
/**
|
|
672
|
-
* Global session-close status for the no-payload close paths
|
|
997
|
+
* Global session-close status for the no-payload close paths.
|
|
673
998
|
* Checks EVERY project with today close-activity; ok only when all are complete.
|
|
674
999
|
* When no project has today activity, falls back to the legacy single recency
|
|
675
1000
|
* project (preserves "force the initial close" behavior, byte-identical gate).
|
|
@@ -690,7 +1015,7 @@ export function sessionCloseGlobalStatus(hypoDir, opts = {}) {
|
|
|
690
1015
|
// bypass discovery and report the single project, preserving the global return
|
|
691
1016
|
// shape. NEVER threaded from a marker-writing path (--mark / apply auto-marker /
|
|
692
1017
|
// PreCompact): those stay global so the marker == compact-ready invariant holds
|
|
693
|
-
// (
|
|
1018
|
+
// (codex design review). A green status here is a project-scoped
|
|
694
1019
|
// diagnostic, not the global compact-readiness verdict.
|
|
695
1020
|
if (opts.projectOverride) {
|
|
696
1021
|
const s = sessionCloseFileStatus(hypoDir, { projectOverride: opts.projectOverride });
|
|
@@ -754,7 +1079,7 @@ export function sessionCloseGlobalStatus(hypoDir, opts = {}) {
|
|
|
754
1079
|
// for EVERY today-active project — cross-session, looking like a fresh bug each
|
|
755
1080
|
// time. This derives the missing entry from the session-log heading so the gate
|
|
756
1081
|
// never blocks on a purely-derivable gap. The session-closed MARKER is NOT derived
|
|
757
|
-
// here: it is a proof artifact the close gate actually ran
|
|
1082
|
+
// here: it is a proof artifact the close gate actually ran.
|
|
758
1083
|
//
|
|
759
1084
|
// Safety guard (codex design review): derive ONLY for a project whose close is
|
|
760
1085
|
// otherwise complete — i.e. its sole gate problem is log.md. If session-state /
|
|
@@ -923,7 +1248,7 @@ export function appendSyncFailure(hypoDir, op, error) {
|
|
|
923
1248
|
* never left half-merged. Called by the auto-commit Stop hook after a local
|
|
924
1249
|
* commit succeeds.
|
|
925
1250
|
*
|
|
926
|
-
* Failure policy (v1.4 "sync hardening"
|
|
1251
|
+
* Failure policy (v1.4 "sync hardening"):
|
|
927
1252
|
* - clean fast-forward / conflict-free merge → push.
|
|
928
1253
|
* - MERGE CONFLICT (`git pull --no-rebase` leaves unmerged paths): abort the
|
|
929
1254
|
* merge so the tree returns to the just-committed local state ("ours"),
|
|
@@ -932,7 +1257,7 @@ export function appendSyncFailure(hypoDir, op, error) {
|
|
|
932
1257
|
* is lost: ours stays committed locally, "theirs" stays on the remote, and
|
|
933
1258
|
* the divergence is surfaced by session-start + doctor until the user merges
|
|
934
1259
|
* manually. Inline auto-resolution (preserving the losing version as a
|
|
935
|
-
* `.conflict-*` sibling) is deferred
|
|
1260
|
+
* `.conflict-*` sibling) is deferred.
|
|
936
1261
|
* - non-conflict pull failure (network/auth: no unmerged paths) → record
|
|
937
1262
|
* op='pull', then still attempt push (a transient pull blip should not block
|
|
938
1263
|
* an otherwise-pushable commit); record op='push' if that also fails.
|
|
@@ -987,7 +1312,7 @@ export function syncRemote(hypoDir) {
|
|
|
987
1312
|
* remote sync stays in the auto-commit Stop hook (commit is local + cheap; sync is
|
|
988
1313
|
* network + soft-fail). Shared by hypo-auto-commit.mjs and crystallize.mjs's
|
|
989
1314
|
* --apply-session-close path so the .hypoignore staging filter cannot diverge
|
|
990
|
-
* between the two commit loci
|
|
1315
|
+
* between the two commit loci.
|
|
991
1316
|
*
|
|
992
1317
|
* "Nothing to commit" (clean tree, or only .hypoignore'd changes) is SUCCESS, not
|
|
993
1318
|
* failure — the caller's tree is already in the committed state it wanted.
|
|
@@ -1065,7 +1390,7 @@ export function clearSyncState(hypoDir) {
|
|
|
1065
1390
|
}
|
|
1066
1391
|
}
|
|
1067
1392
|
|
|
1068
|
-
// ── auto-project suggestion
|
|
1393
|
+
// ── auto-project suggestion ────────────────────────────────────────
|
|
1069
1394
|
// `.cache/project-suggestions.json` is a single JSON object:
|
|
1070
1395
|
// { "skips": [{cwd, declined_at, reason}], "cooldowns": {"<cwd>": "<iso>"} }
|
|
1071
1396
|
// `skips` is written by the LLM (Layer-1 behavioral rule) when the user answers
|
|
@@ -1139,7 +1464,7 @@ export function cwdHasProjectMarker(cwd) {
|
|
|
1139
1464
|
/**
|
|
1140
1465
|
* Decide whether SessionStart/CwdChanged should offer to create a project for
|
|
1141
1466
|
* `cwd`. The caller MUST have already confirmed `cwd` matches no project's
|
|
1142
|
-
* `working_dir` (the hook's MISS branch); this evaluates the remaining
|
|
1467
|
+
* `working_dir` (the hook's MISS branch); this evaluates the remaining
|
|
1143
1468
|
* trigger conditions: (a) cwd is a git repo, (b) carries a project marker
|
|
1144
1469
|
* (`.git` alone is a weak signal — §8.11), (c) not in cooldown, (d) not a cwd
|
|
1145
1470
|
* the user previously declined. A corrupt store stays silent (doctor surfaces).
|
|
@@ -1184,7 +1509,7 @@ export function buildProjectSuggestionLine(cwd) {
|
|
|
1184
1509
|
return `[WIKI: cwd '${safe}'에 매칭되는 프로젝트가 없습니다. 자동 생성할까요? (Y/n)]`;
|
|
1185
1510
|
}
|
|
1186
1511
|
|
|
1187
|
-
// ── clear-marker (
|
|
1512
|
+
// ── clear-marker (amendment 2026-05-14) ──────────────────────
|
|
1188
1513
|
// `/clear` cannot be blocked (no UserPromptSubmit fire). The only intervention
|
|
1189
1514
|
// point is the SessionEnd(reason='clear') → SessionStart(source='clear') pair:
|
|
1190
1515
|
// SessionEnd writes `.cache/clear-marker.json` with the dying session's id +
|
|
@@ -1202,7 +1527,7 @@ function clearMarkerPath(hypoDir) {
|
|
|
1202
1527
|
|
|
1203
1528
|
/**
|
|
1204
1529
|
* Persist the dying session's identity so the next SessionStart(source=clear)
|
|
1205
|
-
* can issue a recovery nudge. Single-file by design (see
|
|
1530
|
+
* can issue a recovery nudge. Single-file by design (see the amendment):
|
|
1206
1531
|
* /clear is a single-client UX action, multi-marker disambiguation buys no
|
|
1207
1532
|
* safety and breaks the 1-of-1 read-and-unlink contract.
|
|
1208
1533
|
*
|
|
@@ -1269,7 +1594,7 @@ export function clearClearMarker(hypoDir) {
|
|
|
1269
1594
|
}
|
|
1270
1595
|
}
|
|
1271
1596
|
|
|
1272
|
-
// ── session-closed marker (
|
|
1597
|
+
// ── session-closed marker (amendment 2026-05-19) ─────────────
|
|
1273
1598
|
// Per-session marker proving session-close completed. Stop hook
|
|
1274
1599
|
// (`hypo-auto-minimal-crystallize`) reads it; `scripts/crystallize.mjs` writes
|
|
1275
1600
|
// it after a verified close. Per-session (not per-day) precision resolves the
|
|
@@ -1277,7 +1602,7 @@ export function clearClearMarker(hypoDir) {
|
|
|
1277
1602
|
// later session reuses an earlier session's entry on the same day.
|
|
1278
1603
|
//
|
|
1279
1604
|
// Writer authority lives in crystallize, NOT this hook: the hook only checks
|
|
1280
|
-
// presence. See
|
|
1605
|
+
// presence. See amendment 2026-05-19 Q2 for the split rationale.
|
|
1281
1606
|
|
|
1282
1607
|
const SESSION_CLOSED_MARKER_STALE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
1283
1608
|
|
|
@@ -1372,7 +1697,7 @@ export function clearSessionClosedMarker(hypoDir, sessionId) {
|
|
|
1372
1697
|
}
|
|
1373
1698
|
}
|
|
1374
1699
|
|
|
1375
|
-
// ── transcript activity heuristic (
|
|
1700
|
+
// ── transcript activity heuristic (amendment 2026-05-19; 6a 2026-06-14) ─────────
|
|
1376
1701
|
// Substantial-session gate for the Stop hook: a session "worth" blocking on for
|
|
1377
1702
|
// session-close is either (a) any mutation (Edit/Write/MultiEdit/NotebookEdit)
|
|
1378
1703
|
// or (b) a high-volume read-only investigation (≥ READONLY_SUBSTANTIAL_THRESHOLD
|
|
@@ -1600,7 +1925,7 @@ export function closeFileTargets(hypoDir) {
|
|
|
1600
1925
|
|
|
1601
1926
|
/**
|
|
1602
1927
|
* Global variant of closeFileTargets for the no-payload lint-scope callers
|
|
1603
|
-
*
|
|
1928
|
+
* Union of the close files over every today-active project
|
|
1604
1929
|
* (fallback: the recency project when none is active). Includes the session-log
|
|
1605
1930
|
* evidence file for EVERY freshDate (not just dates[0]) so the lint scope matches
|
|
1606
1931
|
* what sessionCloseFileStatus actually checks across a local/UTC date boundary.
|
|
@@ -1671,6 +1996,21 @@ export function partitionLintScope(findings, scope) {
|
|
|
1671
1996
|
return { blocking, notice };
|
|
1672
1997
|
}
|
|
1673
1998
|
|
|
1999
|
+
/**
|
|
2000
|
+
* True if a repo-relative file lives under any of the given project dirs
|
|
2001
|
+
* (`projects/<slug>/...`, or the dir itself). Both surfaces that surface
|
|
2002
|
+
* pre-existing lint-debt NOTICES use this to decide what to LIST vs fold: debt
|
|
2003
|
+
* under a close-target project is the close's own neighborhood and stays listed;
|
|
2004
|
+
* debt elsewhere (other projects, shared `pages/`, root `hot.md`/`log.md`) folds
|
|
2005
|
+
* to a count so the same untouched-file debt does not re-list its filenames on
|
|
2006
|
+
* every close. Same path policy as the lint-scope matcher above: separators
|
|
2007
|
+
* normalized to POSIX, exact prefix at a segment boundary.
|
|
2008
|
+
*/
|
|
2009
|
+
export function isUnderProjectDirs(file, slugs) {
|
|
2010
|
+
const f = posixPath(file);
|
|
2011
|
+
return (slugs || []).some((s) => s && (f === `projects/${s}` || f.startsWith(`projects/${s}/`)));
|
|
2012
|
+
}
|
|
2013
|
+
|
|
1674
2014
|
// ── PreCompact gate — single source of truth ────────────────────────────────
|
|
1675
2015
|
/**
|
|
1676
2016
|
* The full PreCompact gate decision as a READ-ONLY status. This is the single
|
|
@@ -1680,9 +2020,9 @@ export function partitionLintScope(findings, scope) {
|
|
|
1680
2020
|
*
|
|
1681
2021
|
* Read-only: feedback projection PURE drift is reported as a non-blocking notice
|
|
1682
2022
|
* with its targets in `driftTargets` (an "effect requirement"), NOT a blocker —
|
|
1683
|
-
* the hook self-heals it with `feedback-sync --write` before continuing
|
|
1684
|
-
*
|
|
1685
|
-
* DO block (
|
|
2023
|
+
* the hook self-heals it with `feedback-sync --write` before continuing,
|
|
2024
|
+
* and a verify caller needs no human action for it. over-cap and conflict
|
|
2025
|
+
* DO block (human demote/import required).
|
|
1686
2026
|
*
|
|
1687
2027
|
* Faithfulness caveats (why "compact-ready", not "guaranteed pass"): the hook
|
|
1688
2028
|
* has paths outside this status — a context-≥70% early block, HYPO_SKIP_GATE
|
|
@@ -1694,16 +2034,16 @@ export function partitionLintScope(findings, scope) {
|
|
|
1694
2034
|
* opts.projectOverride (CHECK-ONLY) narrows BOTH the close status and the lint
|
|
1695
2035
|
* scope to a single project, for `--check-session-close --project=<slug>`. A
|
|
1696
2036
|
* green result is then a project-scoped diagnostic, NOT the global compact-ready
|
|
1697
|
-
* verdict
|
|
2037
|
+
* verdict: the caller must surface the scope. It is NEVER passed from
|
|
1698
2038
|
* a marker-writing path (--mark / apply auto-marker / PreCompact); those stay
|
|
1699
2039
|
* global so a marker can't attest compact-ready while PreCompact re-checks red
|
|
1700
|
-
* (the marker == compact-ready invariant,
|
|
2040
|
+
* (the marker == compact-ready invariant, codex design review). When a
|
|
1701
2041
|
* log-only marker governs the session, log-only mode wins and projectOverride is
|
|
1702
2042
|
* ignored.
|
|
1703
2043
|
*
|
|
1704
2044
|
* @param {string} hypoDir
|
|
1705
2045
|
* @param {{lintScope?: Iterable<string>, transcriptPath?: string|null, claudeHome?: string, projectOverride?: string|null}} [opts]
|
|
1706
|
-
* @returns {{ok: boolean, close: object, blockers: {type:string,reason:string}[], notices: {type:string,reason:string}[], driftTargets: string[], skipped: {lint:boolean, feedback:boolean}}}
|
|
2046
|
+
* @returns {{ok: boolean, close: object, blockers: {type:string,reason:string}[], notices: {type:string,reason:string,file?:string}[], driftTargets: string[], skipped: {lint:boolean, feedback:boolean}}}
|
|
1707
2047
|
*/
|
|
1708
2048
|
export function precompactGateStatus(hypoDir, opts = {}) {
|
|
1709
2049
|
const blockers = [];
|
|
@@ -1724,11 +2064,11 @@ export function precompactGateStatus(hypoDir, opts = {}) {
|
|
|
1724
2064
|
const marker = opts.sessionId ? readSessionClosedMarker(hypoDir, opts.sessionId) : null;
|
|
1725
2065
|
const logOnly = opts.logOnly === true || marker?.scope === 'log-only';
|
|
1726
2066
|
|
|
1727
|
-
// 1. wiki git state
|
|
2067
|
+
// 1. wiki git state. Uncommitted changes (real unsaved work) BLOCK:
|
|
1728
2068
|
// they are human-fixable. Unpushed commits (ahead) DEMOTE to a notice: push is
|
|
1729
2069
|
// automatic (auto-commit Stop hook) and its failures are already non-fatal, so
|
|
1730
2070
|
// "ahead" is a transient sync state, not a human-fixable blocker. Demoting it
|
|
1731
|
-
// here (the shared gate) keeps the marker == compact-ready invariant
|
|
2071
|
+
// here (the shared gate) keeps the marker == compact-ready invariant:
|
|
1732
2072
|
// a committed-but-unpushed close marks AND compacts, instead of the close writer
|
|
1733
2073
|
// committing its own payload and then being blocked by its own (unpushed) commit.
|
|
1734
2074
|
const git = hypoIsClean(hypoDir);
|
|
@@ -1743,7 +2083,7 @@ export function precompactGateStatus(hypoDir, opts = {}) {
|
|
|
1743
2083
|
const hot = hotMdIsClean(hypoDir);
|
|
1744
2084
|
if (!hot.clean) blockers.push({ type: 'hot', reason: hot.reason });
|
|
1745
2085
|
|
|
1746
|
-
// 3. session-close files (global invariant
|
|
2086
|
+
// 3. session-close files (global invariant); in log-only mode,
|
|
1747
2087
|
// the minimum proof (a today log.md entry) with NO project attribution.
|
|
1748
2088
|
let close;
|
|
1749
2089
|
if (logOnly) {
|
|
@@ -1840,9 +2180,13 @@ export function precompactGateStatus(hypoDir, opts = {}) {
|
|
|
1840
2180
|
});
|
|
1841
2181
|
}
|
|
1842
2182
|
for (const n of part.notice)
|
|
1843
|
-
notices.push({
|
|
2183
|
+
notices.push({
|
|
2184
|
+
type: 'lint',
|
|
2185
|
+
file: n.file,
|
|
2186
|
+
reason: `${n.file}${n.id ? ` (${n.id})` : ''}`,
|
|
2187
|
+
});
|
|
1844
2188
|
// W8 (design-history stale) is each today-active project's own close
|
|
1845
|
-
// responsibility; others' are non-blocking notices
|
|
2189
|
+
// responsibility; others' are non-blocking notices. In log-only
|
|
1846
2190
|
// mode there is NO project this session is accountable for, so every W8 is a
|
|
1847
2191
|
// notice — a non-project session must never be blocked by some project's
|
|
1848
2192
|
// stale design-history (codex design BLOCKER: the attribution leak this fix
|
|
@@ -1867,7 +2211,8 @@ export function precompactGateStatus(hypoDir, opts = {}) {
|
|
|
1867
2211
|
reason: `design-history stale: ${w8Blocking.map((w) => w.file.split('/')[1]).join(', ')}`,
|
|
1868
2212
|
});
|
|
1869
2213
|
}
|
|
1870
|
-
for (const w of w8Notice)
|
|
2214
|
+
for (const w of w8Notice)
|
|
2215
|
+
notices.push({ type: 'design-history', file: w.file, reason: w.file });
|
|
1871
2216
|
} catch (e) {
|
|
1872
2217
|
skipped.lint = true; // fail-open on tooling error
|
|
1873
2218
|
// Surface WHY the gate skipped lint (truncated stdout, timeout, spawn error)
|
|
@@ -1879,7 +2224,7 @@ export function precompactGateStatus(hypoDir, opts = {}) {
|
|
|
1879
2224
|
}
|
|
1880
2225
|
}
|
|
1881
2226
|
|
|
1882
|
-
// 5. feedback projection
|
|
2227
|
+
// 5. feedback projection. over-cap/conflict block; pure
|
|
1883
2228
|
// drift is a self-healable notice (driftTargets = effect requirement the
|
|
1884
2229
|
// hook runs as --write). Classification mirrors hypo-personal-check exactly.
|
|
1885
2230
|
const feedbackPath = PKG_ROOT ? join(PKG_ROOT, 'scripts', 'feedback-sync.mjs') : null;
|
|
@@ -1992,7 +2337,7 @@ export function isClearCommand(prompt) {
|
|
|
1992
2337
|
return prompt === '/clear' || /^\/clear(\s|$)/.test(prompt);
|
|
1993
2338
|
}
|
|
1994
2339
|
|
|
1995
|
-
/** Returns true if the prompt is either /compact or /clear (
|
|
2340
|
+
/** Returns true if the prompt is either /compact or /clear (Layer 2). */
|
|
1996
2341
|
export function isCompactOrClearCommand(prompt) {
|
|
1997
2342
|
return isCompactCommand(prompt) || isClearCommand(prompt);
|
|
1998
2343
|
}
|
|
@@ -2023,7 +2368,7 @@ export function extractUserMessages(transcriptPath, tailN = 30) {
|
|
|
2023
2368
|
.map((line) => {
|
|
2024
2369
|
try {
|
|
2025
2370
|
const obj = JSON.parse(line);
|
|
2026
|
-
// Skill-injection vector
|
|
2371
|
+
// Skill-injection vector: drop system-injected role:user
|
|
2027
2372
|
// messages before they pollute the close-intent signal.
|
|
2028
2373
|
// • isMeta:true — slash-command bodies, skill bodies, local-command
|
|
2029
2374
|
// caveats. Their text is docs/specs, often full of close vocabulary
|
|
@@ -2081,7 +2426,7 @@ export function isClosePattern(text) {
|
|
|
2081
2426
|
// 끝: bare terminal noun, boundary-guarded so "세션 끝내는 방법" /
|
|
2082
2427
|
// "세션 끝나면" don't trip.
|
|
2083
2428
|
/세션\s*끝(?![가-힣])/,
|
|
2084
|
-
// 세션
|
|
2429
|
+
// 세션 마무리/종료: the OLD pattern required a fixed verb suffix
|
|
2085
2430
|
// (하자/할게/했어) and missed the most common real phrasings — "세션 마무리
|
|
2086
2431
|
// 해줘" (imperative), bare "세션 마무리", "세션마무리" (no space). A
|
|
2087
2432
|
// blacklist lookahead (excluding 조건/로직/여부/…) is whack-a-mole because
|
|
@@ -2097,8 +2442,8 @@ export function isClosePattern(text) {
|
|
|
2097
2442
|
// the rare FN over the FP. The residual: connective forms that happen to put a
|
|
2098
2443
|
// space after a complete terminal can't be separated by regex without a
|
|
2099
2444
|
// morphological parser — that's bounded by the compound gate (precompact-green
|
|
2100
|
-
// + signal) and the unambiguous /compact and AskUserQuestion channels (
|
|
2101
|
-
//
|
|
2445
|
+
// + signal) and the unambiguous /compact and AskUserQuestion channels (threat
|
|
2446
|
+
// boundary), not chased further.
|
|
2102
2447
|
/세션\s*(?:마무리|종료)(?:\s*(?:해줘|해주세요|해요|해|하자|하죠|했어|했다|했음|했지|합시다|합니다|할게|할께|할래|할까|할까요|한\s?거(?:지|야|니)?|함)(?![가-힣])|\s*[)\].,!?~。…]|\s*$)/m,
|
|
2103
2448
|
/오늘\s*은?\s*(여기|작업|세션).*(끝|마치|마무리|종료)/,
|
|
2104
2449
|
// 여기까지: requires no continuation action word (e.g. 여기까지 구현해줘 is not a close signal)
|
|
@@ -2116,7 +2461,7 @@ export function isClosePattern(text) {
|
|
|
2116
2461
|
// objects. The review/analysis/debug/audit/investigation nouns were added
|
|
2117
2462
|
// for 6a — read-only review sessions are now "substantial", so "wrap up the
|
|
2118
2463
|
// review" must read as a task-level signal, not a session-close one.
|
|
2119
|
-
// Leading \b on each pattern
|
|
2464
|
+
// Leading \b on each pattern so an EN close phrase embedded as a
|
|
2120
2465
|
// substring of a longer token can't trip the gate (e.g. "designing off…").
|
|
2121
2466
|
/\bwrap(?:ping)?\s+up(?!\s+(?:this|the)\s+(?:pr|issue|bug|task|function|component|module|feature|code|test|review|analysis|investigation|debugging|debug|audit|refactor)\b)/i,
|
|
2122
2467
|
/\bdone\s+for\s+(?:today|now|the\s+day)\b/i,
|
|
@@ -2138,7 +2483,7 @@ export function isClosePattern(text) {
|
|
|
2138
2483
|
* slug would miss the file. The session id is a UUID, so the glob disambiguates
|
|
2139
2484
|
* without needing the slug — verified globally unique across all project dirs.
|
|
2140
2485
|
*
|
|
2141
|
-
* Fail-closed on ambiguity (
|
|
2486
|
+
* Fail-closed on ambiguity (codex review): returns the single resolved
|
|
2142
2487
|
* path, or null when ZERO or MORE-THAN-ONE distinct files match (realpath-deduped
|
|
2143
2488
|
* so a symlink to the same file is not "multiple"). The caller treats null as
|
|
2144
2489
|
* "refuse the marker".
|
|
@@ -2175,13 +2520,13 @@ export function resolveTranscriptBySessionId(
|
|
|
2175
2520
|
|
|
2176
2521
|
/**
|
|
2177
2522
|
* Returns true iff the transcript carries a genuine USER session-close signal —
|
|
2178
|
-
* the hard gate for the session-closed marker writers
|
|
2523
|
+
* the hard gate for the session-closed marker writers. Scans the FULL
|
|
2179
2524
|
* transcript: a close request can precede the marker write by the entire close
|
|
2180
2525
|
* checklist, so the Stop hook's 30-line tail would miss it.
|
|
2181
2526
|
*
|
|
2182
2527
|
* Evidence (any one is sufficient):
|
|
2183
2528
|
* 1. a de-polluted NL close phrase — isClosePattern over extractUserMessages,
|
|
2184
|
-
* which already drops injected / tool / hook-feedback text
|
|
2529
|
+
* which already drops injected / tool / hook-feedback text;
|
|
2185
2530
|
* 2. a `/compact` invocation (queue-operation). `/clear` is deliberately NOT
|
|
2186
2531
|
* counted: it abandons context, whereas a session-close PRESERVES the work
|
|
2187
2532
|
* to the wiki — a different intent;
|
|
@@ -2264,7 +2609,7 @@ export function buildOutput(context, extra = {}) {
|
|
|
2264
2609
|
// ── growth metrics (F2 + E4) ───────────────────────────────────────────────
|
|
2265
2610
|
// Single formatter used by Stop (hot-rebuild) and SessionStart hooks so the
|
|
2266
2611
|
// "[hypo] +N pages, ~M updated, K wikilinks" line stays consistent at both
|
|
2267
|
-
// ends of a session. See
|
|
2612
|
+
// ends of a session. See Lane B.
|
|
2268
2613
|
|
|
2269
2614
|
/**
|
|
2270
2615
|
* Format a growth-metrics one-liner. Returns '' when all counts are 0 so
|