hypomnema 1.4.1 → 1.5.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 +1 -1
- package/README.md +1 -1
- package/commands/audit.md +1 -1
- package/commands/crystallize.md +11 -9
- 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 +2 -2
- package/docs/CONTRIBUTING.md +20 -12
- package/hooks/hypo-auto-commit.mjs +2 -2
- package/hooks/hypo-auto-minimal-crystallize.mjs +30 -18
- package/hooks/hypo-compact-guard.mjs +2 -2
- package/hooks/hypo-cwd-change.mjs +27 -37
- 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 +34 -40
- package/hooks/hypo-shared.mjs +364 -82
- package/hooks/version-check.mjs +1 -1
- package/package.json +5 -1
- package/scripts/check-tracker-ids.mjs +69 -31
- package/scripts/crystallize.mjs +322 -70
- 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/project-create.mjs +25 -7
- package/scripts/lib/schema-vocab.mjs +105 -1
- 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 +14 -6
- 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 +17 -18
- package/skills/crystallize/SKILL.md +16 -8
- package/skills/graph/SKILL.md +2 -2
- package/skills/ingest/SKILL.md +4 -4
- package/skills/lint/SKILL.md +2 -2
- package/skills/query/SKILL.md +2 -2
- package/skills/verify/SKILL.md +2 -2
- package/templates/SCHEMA.md +12 -4
- package/templates/hypo-config.md +1 -1
- package/templates/hypo-guide.md +24 -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
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
readFileSync,
|
|
11
11
|
writeFileSync,
|
|
12
12
|
existsSync,
|
|
13
|
+
statSync,
|
|
13
14
|
appendFileSync,
|
|
14
15
|
mkdirSync,
|
|
15
16
|
rmSync,
|
|
@@ -128,7 +129,7 @@ export function lastSubstantialOpIsSession() {
|
|
|
128
129
|
return /^## \[\d{4}-\d{2}-\d{2}\] session/.test(substantial[substantial.length - 1]);
|
|
129
130
|
}
|
|
130
131
|
|
|
131
|
-
// 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:
|
|
132
133
|
// uncommitted — working-tree changes (real unsaved work; a human-fixable blocker)
|
|
133
134
|
// ahead — committed-but-unpushed commits (a soft, auto-synced state: the
|
|
134
135
|
// auto-commit Stop hook pushes, and push failures are non-fatal —
|
|
@@ -231,7 +232,7 @@ export function hasSessionLogHeading(content, date) {
|
|
|
231
232
|
|
|
232
233
|
/**
|
|
233
234
|
* Canonical session-log shard path (repo-relative POSIX) for a single day.
|
|
234
|
-
*
|
|
235
|
+
* Option D: the date IS the filename (`YYYY-MM-DD.md`), so no
|
|
235
236
|
* "current part" resolver is needed and the per-session write touches a small
|
|
236
237
|
* file instead of a multi-thousand-line monthly log. This is the WRITE target.
|
|
237
238
|
*/
|
|
@@ -351,41 +352,221 @@ function parseFrontmatterField(content, key) {
|
|
|
351
352
|
.replace(/^['"]|['"]$/g, '');
|
|
352
353
|
}
|
|
353
354
|
|
|
354
|
-
//
|
|
355
|
-
//
|
|
356
|
-
//
|
|
357
|
-
//
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
355
|
+
// ── cwd ↔ project matcher ────────────────────────────────────────────────────
|
|
356
|
+
// Hand-synced with scripts/lib/wd-match.mjs: hooks deploy to ~/.claude/hooks/
|
|
357
|
+
// without scripts/, so this cannot import the lib and must mirror it. Keep the
|
|
358
|
+
// two in step; the lib carries the unit tests.
|
|
359
|
+
|
|
360
|
+
// Expand a leading ~/ (or bare ~), strip trailing slashes. null for empty.
|
|
361
|
+
export function normalizeWorkingDir(p) {
|
|
362
|
+
if (!p) return null;
|
|
363
|
+
let s = String(p).trim();
|
|
364
|
+
if (s === '~') s = homedir();
|
|
365
|
+
else if (s.startsWith('~/')) s = `${homedir()}/${s.slice(2)}`;
|
|
366
|
+
s = s.replace(/\/+$/, '');
|
|
367
|
+
return s || null;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// macOS/Windows match paths case-insensitively; Linux does not. Fold only there.
|
|
371
|
+
export function isCaseInsensitiveFs(platform = process.platform) {
|
|
372
|
+
return platform === 'darwin' || platform === 'win32';
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function _fold(s, ci) {
|
|
376
|
+
return ci ? s.toLowerCase() : s;
|
|
377
|
+
}
|
|
378
|
+
function _lastSeg(p) {
|
|
379
|
+
const i = p.lastIndexOf('/');
|
|
380
|
+
return i < 0 ? p : p.slice(i + 1);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// Resolve which project owns cwd. Tier 1: longest absolute working_dir prefix
|
|
384
|
+
// (the original behavior). Tier 2 (cross-machine): when the synced vault holds
|
|
385
|
+
// another machine's absolute path, a cwd ancestor whose directory name is a
|
|
386
|
+
// GLOBALLY unique project basename identifies the project; a shared dirname
|
|
387
|
+
// declines (null) so the caller falls back to recency. `projects` is the whole
|
|
388
|
+
// universe (for the uniqueness gate); `eligible` restricts the answer.
|
|
389
|
+
export function pickProjectByCwd(projects, cwd, opts = {}) {
|
|
390
|
+
const { eligible = null, realpathCwd = null, caseInsensitive = isCaseInsensitiveFs() } = opts;
|
|
391
|
+
if (!cwd && !realpathCwd) return null;
|
|
392
|
+
const eligibleSet = eligible ? new Set(eligible) : null;
|
|
393
|
+
const isEligible = (slug) => !eligibleSet || eligibleSet.has(slug);
|
|
394
|
+
|
|
395
|
+
const entries = [];
|
|
396
|
+
for (const p of projects) {
|
|
397
|
+
const path = normalizeWorkingDir(p.workingDir);
|
|
398
|
+
if (path) entries.push({ slug: p.slug, path });
|
|
399
|
+
}
|
|
400
|
+
if (entries.length === 0) return null;
|
|
401
|
+
|
|
402
|
+
// raw cwd first, realpath only as a fallback (a raw match must not be
|
|
403
|
+
// overridden by a longer realpath match).
|
|
404
|
+
const cwds = [];
|
|
405
|
+
for (const c of [cwd, realpathCwd]) {
|
|
406
|
+
const n = normalizeWorkingDir(c);
|
|
407
|
+
if (n && !cwds.includes(n)) cwds.push(n);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// Tier 1: first cwd variant with any longest-prefix match wins.
|
|
411
|
+
for (const c of cwds) {
|
|
412
|
+
const cf = _fold(c, caseInsensitive);
|
|
413
|
+
let bestSlug = null;
|
|
414
|
+
let bestLen = -1;
|
|
415
|
+
for (const e of entries) {
|
|
416
|
+
if (!isEligible(e.slug)) continue;
|
|
417
|
+
const pf = _fold(e.path, caseInsensitive);
|
|
418
|
+
if ((cf === pf || cf.startsWith(`${pf}/`)) && e.path.length > bestLen) {
|
|
419
|
+
bestLen = e.path.length;
|
|
420
|
+
bestSlug = e.slug;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
if (bestSlug) return bestSlug;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// Tier 2: unique-basename ancestor, but only when the chain points at exactly
|
|
427
|
+
// ONE project (two distinct matches along the path → decline, fail closed).
|
|
428
|
+
const byBasename = new Map();
|
|
429
|
+
for (const e of entries) {
|
|
430
|
+
const b = _fold(_lastSeg(e.path), caseInsensitive);
|
|
431
|
+
if (!b) continue;
|
|
432
|
+
const hit = byBasename.get(b);
|
|
433
|
+
if (hit) hit.count += 1;
|
|
434
|
+
else byBasename.set(b, { slug: e.slug, count: 1 });
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
for (const c of cwds) {
|
|
438
|
+
const matched = new Set();
|
|
439
|
+
let cur = c;
|
|
440
|
+
while (cur && cur.includes('/')) {
|
|
441
|
+
const b = _fold(_lastSeg(cur), caseInsensitive);
|
|
442
|
+
const hit = b && byBasename.get(b);
|
|
443
|
+
if (hit && hit.count === 1 && isEligible(hit.slug)) matched.add(hit.slug);
|
|
444
|
+
cur = cur.slice(0, cur.lastIndexOf('/'));
|
|
445
|
+
}
|
|
446
|
+
if (matched.size === 1) return [...matched][0];
|
|
447
|
+
}
|
|
448
|
+
return null;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// Disk companion: [{slug, workingDir}] for every real project (skips _template
|
|
452
|
+
// and dirs without index.md). The full set is the tier-2 uniqueness universe.
|
|
453
|
+
// working_dir is cleaned exactly like scripts/lib/frontmatter.mjs parseFrontmatter
|
|
454
|
+
// (strip a trailing ` # comment`, then surrounding quotes) so this stays in step
|
|
455
|
+
// with the script-side collector — parseFrontmatterField alone skips the comment.
|
|
456
|
+
export function collectProjectWorkingDirs(hypoDir) {
|
|
457
|
+
const projectsDir = join(hypoDir, 'projects');
|
|
458
|
+
if (!existsSync(projectsDir)) return [];
|
|
459
|
+
const out = [];
|
|
460
|
+
for (const slug of readdirSync(projectsDir)) {
|
|
461
|
+
if (slug === '_template') continue;
|
|
462
|
+
const dir = join(projectsDir, slug);
|
|
463
|
+
try {
|
|
464
|
+
if (!statSync(dir).isDirectory()) continue;
|
|
465
|
+
} catch {
|
|
466
|
+
continue;
|
|
467
|
+
}
|
|
468
|
+
const indexPath = join(dir, 'index.md');
|
|
364
469
|
if (!existsSync(indexPath)) continue;
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
470
|
+
let workingDir = null;
|
|
471
|
+
try {
|
|
472
|
+
const fm = readFileSync(indexPath, 'utf-8').match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
473
|
+
const line = fm && fm[1].split(/\r?\n/).find((l) => /^working_dir:/.test(l));
|
|
474
|
+
if (line) {
|
|
475
|
+
workingDir = line
|
|
476
|
+
.slice('working_dir:'.length)
|
|
477
|
+
.trim()
|
|
478
|
+
.replace(/\s+#.*$/, '')
|
|
479
|
+
.replace(/^['"]|['"]$/g, '');
|
|
480
|
+
}
|
|
481
|
+
} catch {
|
|
482
|
+
workingDir = null;
|
|
372
483
|
}
|
|
484
|
+
out.push({ slug, workingDir: workingDir || null });
|
|
373
485
|
}
|
|
374
|
-
return
|
|
486
|
+
return out;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* When the session cwd is a project working_dir distinct from the vault root,
|
|
491
|
+
* the wiki/knowledge files live in the VAULT, not in this cwd. SessionStart and
|
|
492
|
+
* CwdChanged inject hot.md/session-state content but never the vault's absolute
|
|
493
|
+
* path, so the AI re-discovers it each session and can wrongly conclude a wiki
|
|
494
|
+
* file is missing after checking only the code repo (a real misjudgment seen in
|
|
495
|
+
* a dev-repo session, 2026-06-23).
|
|
496
|
+
*
|
|
497
|
+
* Returns a one-line "look in the vault, not here" orientation carrying the
|
|
498
|
+
* absolute vault path, or '' when cwd is anywhere inside the vault tree (the
|
|
499
|
+
* "wiki files live in the vault, not here" framing would be false there) or
|
|
500
|
+
* hypoDir is unset. The HIT matcher is prefix-based, so a project whose
|
|
501
|
+
* working_dir is the vault root can match a vault SUBDIRECTORY; checking only
|
|
502
|
+
* exact root-equality would wrongly fire for those. Compared via realpath so a
|
|
503
|
+
* symlinked cwd or vault still matches.
|
|
504
|
+
*
|
|
505
|
+
* Containment uses the SAME normalize + case-fold + prefix policy as
|
|
506
|
+
* pickProjectByCwd so the two never disagree: on a case-insensitive FS the
|
|
507
|
+
* matcher case-folds, so a cwd differing only in case is still a HIT and must be
|
|
508
|
+
* suppressed here too (otherwise a false orientation leaks).
|
|
509
|
+
*
|
|
510
|
+
* @param {string} cwd the session cwd (already known to be a project HIT)
|
|
511
|
+
* @param {string} [hypoDir=HYPO_DIR]
|
|
512
|
+
* @param {{caseInsensitive?: boolean}} [opts] override FS case policy (tests)
|
|
513
|
+
* @returns {string}
|
|
514
|
+
*/
|
|
515
|
+
export function buildVaultOrientation(cwd, hypoDir = HYPO_DIR, opts = {}) {
|
|
516
|
+
if (!cwd || !hypoDir) return '';
|
|
517
|
+
const { caseInsensitive = isCaseInsensitiveFs() } = opts;
|
|
518
|
+
let realCwd = cwd;
|
|
519
|
+
let realVault = hypoDir;
|
|
520
|
+
try {
|
|
521
|
+
realCwd = realpathSync(cwd);
|
|
522
|
+
} catch {
|
|
523
|
+
/* keep raw path when cwd is unreadable */
|
|
524
|
+
}
|
|
525
|
+
try {
|
|
526
|
+
realVault = realpathSync(hypoDir);
|
|
527
|
+
} catch {
|
|
528
|
+
/* keep raw path when vault is unreadable */
|
|
529
|
+
}
|
|
530
|
+
// Suppress when cwd is the vault root OR a descendant of it.
|
|
531
|
+
const c = _fold(normalizeWorkingDir(realCwd) || '', caseInsensitive);
|
|
532
|
+
const v = _fold(normalizeWorkingDir(realVault) || '', caseInsensitive);
|
|
533
|
+
if (!c || !v) return '';
|
|
534
|
+
if (c === v || c.startsWith(`${v}/`)) return '';
|
|
535
|
+
return (
|
|
536
|
+
`[WIKI VAULT: ${hypoDir}] 이 cwd는 작업/코드 레포이고 vault가 아니다. ` +
|
|
537
|
+
`wiki·knowledge·세션로그 파일은 여기가 아니라 vault(${hypoDir})에서 조회한다.`
|
|
538
|
+
);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// resume/close entry: match `slugs` against cwd via the two-tier matcher.
|
|
542
|
+
// Uniqueness is judged over EVERY project on disk, not just `slugs`. close
|
|
543
|
+
// callers pass no cwd, so it stays inert for them.
|
|
544
|
+
function pickByCwd(hypoDir, slugs, cwd) {
|
|
545
|
+
if (!cwd) return null;
|
|
546
|
+
let realpathCwd = null;
|
|
547
|
+
try {
|
|
548
|
+
realpathCwd = realpathSync(cwd);
|
|
549
|
+
} catch {
|
|
550
|
+
realpathCwd = null;
|
|
551
|
+
}
|
|
552
|
+
return pickProjectByCwd(collectProjectWorkingDirs(hypoDir), cwd, {
|
|
553
|
+
eligible: slugs,
|
|
554
|
+
realpathCwd,
|
|
555
|
+
});
|
|
375
556
|
}
|
|
376
557
|
|
|
377
558
|
/**
|
|
378
559
|
* Resolve the active project slug from root hot.md. With a cwd, a project whose
|
|
379
|
-
* working_dir contains it wins (cwd-first
|
|
560
|
+
* working_dir contains it wins (cwd-first); otherwise the
|
|
380
561
|
* most-recently-active row is returned.
|
|
381
562
|
* The cwd helpers (parseFrontmatterField / pickByCwd) and the cwd-first body
|
|
382
563
|
* are kept in sync with scripts/resume.mjs by hand; the surrounding wrapper
|
|
383
564
|
* intentionally differs (resume.mjs adds an mtime fallback, this does not).
|
|
384
|
-
* `cwd` is an optional cwd-first selector
|
|
565
|
+
* `cwd` is an optional cwd-first selector: a cwd↔working_dir match
|
|
385
566
|
* wins over recency. resume passes process.cwd(); session-close callers
|
|
386
567
|
* (sessionCloseFileStatus / closeFileTargets) intentionally pass null — close
|
|
387
|
-
* has a different authority (payload.project / freshness, the global invariant
|
|
388
|
-
*
|
|
568
|
+
* has a different authority (payload.project / freshness, the global invariant),
|
|
569
|
+
* so it never picks by cwd. When cwd is omitted, behavior is
|
|
389
570
|
* identical to the legacy recency version.
|
|
390
571
|
* @param {string} hypoDir
|
|
391
572
|
* @param {string|null} [cwd]
|
|
@@ -409,7 +590,7 @@ export function resolveActiveProject(hypoDir, cwd = null) {
|
|
|
409
590
|
),
|
|
410
591
|
].map((m) => ({ name: m[1].trim(), date: m[2] || '', slug: m[3] }));
|
|
411
592
|
if (wikiRows.length > 0) {
|
|
412
|
-
// cwd-first
|
|
593
|
+
// cwd-first: a cwd↔working_dir match wins over recency, across
|
|
413
594
|
// ALL rows. Kept in sync with scripts/resume.mjs. close callers pass null →
|
|
414
595
|
// recency path below (resume=cwd-positive / close=no-pick).
|
|
415
596
|
if (cwd) {
|
|
@@ -444,7 +625,7 @@ export function resolveActiveProject(hypoDir, cwd = null) {
|
|
|
444
625
|
* - projects/<project>/hot.md — frontmatter `updated:` is today
|
|
445
626
|
* - hot.md (root) — frontmatter `updated:` is today
|
|
446
627
|
* - projects/<project>/session-log/YYYY-MM-DD.md — has a `## [today]` heading
|
|
447
|
-
* (daily shard
|
|
628
|
+
* (daily shard; legacy YYYY-MM.md is still accepted as fallback)
|
|
448
629
|
* - log.md — has a `## [today] session | <project>` entry
|
|
449
630
|
* The log.md check is project-scoped so a session close left incomplete for
|
|
450
631
|
* project A can't be masked by a fresh close of project B (and vice versa).
|
|
@@ -498,7 +679,7 @@ export function sessionCloseFileStatus(hypoDir, { projectOverride = null } = {})
|
|
|
498
679
|
checkUpdated(join('projects', project, 'hot.md'));
|
|
499
680
|
checkUpdated('hot.md');
|
|
500
681
|
|
|
501
|
-
// session-log: daily shard
|
|
682
|
+
// session-log: daily shard, with legacy monthly fallback: must
|
|
502
683
|
// carry a today-dated heading in whichever file holds it. Daily-first read
|
|
503
684
|
// order short-circuits on the small shard. When no match is found, the gap is
|
|
504
685
|
// reported under the canonical daily shard for the local date (dates[0]).
|
|
@@ -543,19 +724,19 @@ export function sessionCloseFileStatus(hypoDir, { projectOverride = null } = {})
|
|
|
543
724
|
return { ok: stale.length === 0 && missing.length === 0, project, dates, stale, missing };
|
|
544
725
|
}
|
|
545
726
|
|
|
546
|
-
// ── global session-close gate
|
|
727
|
+
// ── global session-close gate ────────────────
|
|
547
728
|
// The no-payload close paths must NOT pick one project (recency / cwd) and check
|
|
548
729
|
// it — that re-derivation is the prior session-close false-block, and a cwd
|
|
549
730
|
// tie-break here would let a fresh cwd mask a DIFFERENT project's dangling
|
|
550
731
|
// close. Instead the gate enforces a global invariant: no project may end a
|
|
551
|
-
// session with a partial close. resume stays cwd-positive
|
|
732
|
+
// session with a partial close. resume stays cwd-positive; close
|
|
552
733
|
// never picks. The two copies of resolveActiveProject share the cwd-first body
|
|
553
734
|
// but the resume.mjs copy adds an mtime fallback this one omits — see resume.mjs.
|
|
554
735
|
|
|
555
736
|
// Root hot.md Active-Projects rows as {slug, date}. The per-row date column is
|
|
556
737
|
// project-scoped (unlike the shared frontmatter `updated:`). Used for candidate
|
|
557
|
-
// DISCOVERY in closeCandidateSlugs
|
|
558
|
-
// dropped
|
|
738
|
+
// DISCOVERY in closeCandidateSlugs, not as close-activity evidence: the row date
|
|
739
|
+
// was dropped as an activity signal because project-create and
|
|
559
740
|
// hypo-hot-rebuild both stamp rows today without a real session. Mirrors
|
|
560
741
|
// resolveActiveProject's regex.
|
|
561
742
|
function rootHotRows(hypoDir) {
|
|
@@ -607,7 +788,15 @@ function closeCandidateSlugs(hypoDir, dates) {
|
|
|
607
788
|
}
|
|
608
789
|
for (const d of dates) {
|
|
609
790
|
const re = new RegExp('^## \\[' + escapeRegExp(d) + '\\] session \\| (\\S+)', 'gm');
|
|
610
|
-
for (const m of content.matchAll(re))
|
|
791
|
+
for (const m of content.matchAll(re)) {
|
|
792
|
+
// B-1: only real projects are close candidates. A malformed log heading
|
|
793
|
+
// (`## [d] session | hypomnema:`) or a stale slug yields a ghost token
|
|
794
|
+
// that no longer maps to a `projects/<slug>/` directory — gating on disk
|
|
795
|
+
// keeps it out of the dangling-close set. Directory (not bare-exists)
|
|
796
|
+
// mirrors the apply-path project check (crystallize.mjs:193).
|
|
797
|
+
const dir = join(projectsDir, m[1]);
|
|
798
|
+
if (existsSync(dir) && statSync(dir).isDirectory()) slugs.add(m[1]);
|
|
799
|
+
}
|
|
611
800
|
}
|
|
612
801
|
}
|
|
613
802
|
return slugs;
|
|
@@ -616,9 +805,9 @@ function closeCandidateSlugs(hypoDir, dates) {
|
|
|
616
805
|
// True when project P shows an AUTHORITATIVE today close-activity signal: a
|
|
617
806
|
// today-dated session-log heading, or a today-dated `## [today] session | P`
|
|
618
807
|
// log.md entry. These are written ONLY by a real session close (apply, or its
|
|
619
|
-
// auto-derived root log
|
|
808
|
+
// auto-derived root log).
|
|
620
809
|
//
|
|
621
|
-
//
|
|
810
|
+
// Soft state files are EXCLUDED, because each is bumped to today by
|
|
622
811
|
// non-session tooling and is therefore indistinguishable from a real close:
|
|
623
812
|
// - session-state.md `updated:` — tracker bookkeeping mirrors a new item into
|
|
624
813
|
// the "next tasks" section (a cross-block incident: editing one project's
|
|
@@ -632,7 +821,7 @@ function closeCandidateSlugs(hypoDir, dates) {
|
|
|
632
821
|
// Tradeoff (documented, accepted): apply writes session-state.md FIRST, then the
|
|
633
822
|
// project files, then the session-log + log entry. A process crash before the
|
|
634
823
|
// session-log write leaves a torn close that this gate no longer flags. Accepted:
|
|
635
|
-
// a torn close never reached apply's ok=true so it wrote no marker
|
|
824
|
+
// a torn close never reached apply's ok=true so it wrote no marker;
|
|
636
825
|
// the surviving session-state is the resume pointer the next session overwrites;
|
|
637
826
|
// what is lost is a narrative log entry, not continuity.
|
|
638
827
|
function hasTodayCloseActivity(hypoDir, project, dates) {
|
|
@@ -660,7 +849,7 @@ function hasTodayCloseActivity(hypoDir, project, dates) {
|
|
|
660
849
|
}
|
|
661
850
|
|
|
662
851
|
/**
|
|
663
|
-
* Global session-close status for the no-payload close paths
|
|
852
|
+
* Global session-close status for the no-payload close paths.
|
|
664
853
|
* Checks EVERY project with today close-activity; ok only when all are complete.
|
|
665
854
|
* When no project has today activity, falls back to the legacy single recency
|
|
666
855
|
* project (preserves "force the initial close" behavior, byte-identical gate).
|
|
@@ -675,7 +864,29 @@ function hasTodayCloseActivity(hypoDir, project, dates) {
|
|
|
675
864
|
* dates: string[], fallback: boolean, primary: string|null,
|
|
676
865
|
* project: string|null, stale: string[], missing: string[]}}
|
|
677
866
|
*/
|
|
678
|
-
export function sessionCloseGlobalStatus(hypoDir) {
|
|
867
|
+
export function sessionCloseGlobalStatus(hypoDir, opts = {}) {
|
|
868
|
+
// projectOverride (check-only): the caller (`crystallize --check-session-close
|
|
869
|
+
// --project=<slug>`) wants THIS project's close status, not the recency pick —
|
|
870
|
+
// bypass discovery and report the single project, preserving the global return
|
|
871
|
+
// shape. NEVER threaded from a marker-writing path (--mark / apply auto-marker /
|
|
872
|
+
// PreCompact): those stay global so the marker == compact-ready invariant holds
|
|
873
|
+
// (codex design review). A green status here is a project-scoped
|
|
874
|
+
// diagnostic, not the global compact-readiness verdict.
|
|
875
|
+
if (opts.projectOverride) {
|
|
876
|
+
const s = sessionCloseFileStatus(hypoDir, { projectOverride: opts.projectOverride });
|
|
877
|
+
return {
|
|
878
|
+
ok: s.ok,
|
|
879
|
+
projects: s.project
|
|
880
|
+
? [{ project: s.project, ok: s.ok, stale: s.stale, missing: s.missing }]
|
|
881
|
+
: [],
|
|
882
|
+
dates: freshDates(),
|
|
883
|
+
fallback: false,
|
|
884
|
+
primary: s.project,
|
|
885
|
+
project: s.project,
|
|
886
|
+
stale: s.stale,
|
|
887
|
+
missing: s.missing,
|
|
888
|
+
};
|
|
889
|
+
}
|
|
679
890
|
const dates = freshDates();
|
|
680
891
|
const recency = resolveActiveProject(hypoDir); // no cwd — close never picks by cwd
|
|
681
892
|
const todayActive = [...closeCandidateSlugs(hypoDir, dates)].filter((p) =>
|
|
@@ -723,7 +934,7 @@ export function sessionCloseGlobalStatus(hypoDir) {
|
|
|
723
934
|
// for EVERY today-active project — cross-session, looking like a fresh bug each
|
|
724
935
|
// time. This derives the missing entry from the session-log heading so the gate
|
|
725
936
|
// never blocks on a purely-derivable gap. The session-closed MARKER is NOT derived
|
|
726
|
-
// here: it is a proof artifact the close gate actually ran
|
|
937
|
+
// here: it is a proof artifact the close gate actually ran.
|
|
727
938
|
//
|
|
728
939
|
// Safety guard (codex design review): derive ONLY for a project whose close is
|
|
729
940
|
// otherwise complete — i.e. its sole gate problem is log.md. If session-state /
|
|
@@ -748,6 +959,21 @@ function deriveLogTitle(tail) {
|
|
|
748
959
|
return t.replace(/^\s*[—:-]\s*/, '').trim(); // drop a leading separator
|
|
749
960
|
}
|
|
750
961
|
|
|
962
|
+
/**
|
|
963
|
+
* Build the canonical root log.md entry for ONE session-log heading. Single
|
|
964
|
+
* source of truth for the derived-entry format, shared by the global Stop-hook
|
|
965
|
+
* derive (deriveRootLogEntries) and apply's direct per-close write (B-1), so the
|
|
966
|
+
* two paths never drift on the `→ [[projects/<slug>/hot]]` pointer or spacing.
|
|
967
|
+
* `headingTail` is the text AFTER `## [date]` in the authored session-log heading.
|
|
968
|
+
* @returns {{ heading: string, block: string }} heading = the `## [date] session
|
|
969
|
+
* | <slug>` line used for exact-line dedup; block = the full two-line entry.
|
|
970
|
+
*/
|
|
971
|
+
export function rootLogEntry(slug, date, headingTail) {
|
|
972
|
+
const title = deriveLogTitle(headingTail);
|
|
973
|
+
const heading = `## [${date}] session | ${slug}` + (title ? ` — ${title}` : '');
|
|
974
|
+
return { heading, block: `${heading}\n→ [[projects/${slug}/hot]]` };
|
|
975
|
+
}
|
|
976
|
+
|
|
751
977
|
/**
|
|
752
978
|
* Append any missing root log.md `## [date] session | <slug>` entries derived from
|
|
753
979
|
* each today-active project's session-log heading(s). Idempotent: dedups on the
|
|
@@ -811,11 +1037,10 @@ export function deriveRootLogEntries(hypoDir) {
|
|
|
811
1037
|
const headingRe = new RegExp('^#{1,6} \\[' + escapeRegExp(date) + '\\]\\s*(.*)$', 'gm');
|
|
812
1038
|
let m;
|
|
813
1039
|
while ((m = headingRe.exec(slog)) !== null) {
|
|
814
|
-
const
|
|
815
|
-
const heading = `## [${date}] session | ${slug}` + (title ? ` — ${title}` : '');
|
|
1040
|
+
const { heading, block } = rootLogEntry(slug, date, m[1]);
|
|
816
1041
|
if (seenHeadings.has(heading)) continue; // exact-line dedup (log.md + queued)
|
|
817
1042
|
seenHeadings.add(heading);
|
|
818
|
-
additions.push(
|
|
1043
|
+
additions.push(block);
|
|
819
1044
|
}
|
|
820
1045
|
}
|
|
821
1046
|
}
|
|
@@ -878,7 +1103,7 @@ export function appendSyncFailure(hypoDir, op, error) {
|
|
|
878
1103
|
* never left half-merged. Called by the auto-commit Stop hook after a local
|
|
879
1104
|
* commit succeeds.
|
|
880
1105
|
*
|
|
881
|
-
* Failure policy (v1.4 "sync hardening"
|
|
1106
|
+
* Failure policy (v1.4 "sync hardening"):
|
|
882
1107
|
* - clean fast-forward / conflict-free merge → push.
|
|
883
1108
|
* - MERGE CONFLICT (`git pull --no-rebase` leaves unmerged paths): abort the
|
|
884
1109
|
* merge so the tree returns to the just-committed local state ("ours"),
|
|
@@ -887,7 +1112,7 @@ export function appendSyncFailure(hypoDir, op, error) {
|
|
|
887
1112
|
* is lost: ours stays committed locally, "theirs" stays on the remote, and
|
|
888
1113
|
* the divergence is surfaced by session-start + doctor until the user merges
|
|
889
1114
|
* manually. Inline auto-resolution (preserving the losing version as a
|
|
890
|
-
* `.conflict-*` sibling) is deferred
|
|
1115
|
+
* `.conflict-*` sibling) is deferred.
|
|
891
1116
|
* - non-conflict pull failure (network/auth: no unmerged paths) → record
|
|
892
1117
|
* op='pull', then still attempt push (a transient pull blip should not block
|
|
893
1118
|
* an otherwise-pushable commit); record op='push' if that also fails.
|
|
@@ -942,7 +1167,7 @@ export function syncRemote(hypoDir) {
|
|
|
942
1167
|
* remote sync stays in the auto-commit Stop hook (commit is local + cheap; sync is
|
|
943
1168
|
* network + soft-fail). Shared by hypo-auto-commit.mjs and crystallize.mjs's
|
|
944
1169
|
* --apply-session-close path so the .hypoignore staging filter cannot diverge
|
|
945
|
-
* between the two commit loci
|
|
1170
|
+
* between the two commit loci.
|
|
946
1171
|
*
|
|
947
1172
|
* "Nothing to commit" (clean tree, or only .hypoignore'd changes) is SUCCESS, not
|
|
948
1173
|
* failure — the caller's tree is already in the committed state it wanted.
|
|
@@ -1020,7 +1245,7 @@ export function clearSyncState(hypoDir) {
|
|
|
1020
1245
|
}
|
|
1021
1246
|
}
|
|
1022
1247
|
|
|
1023
|
-
// ── auto-project suggestion
|
|
1248
|
+
// ── auto-project suggestion ────────────────────────────────────────
|
|
1024
1249
|
// `.cache/project-suggestions.json` is a single JSON object:
|
|
1025
1250
|
// { "skips": [{cwd, declined_at, reason}], "cooldowns": {"<cwd>": "<iso>"} }
|
|
1026
1251
|
// `skips` is written by the LLM (Layer-1 behavioral rule) when the user answers
|
|
@@ -1094,7 +1319,7 @@ export function cwdHasProjectMarker(cwd) {
|
|
|
1094
1319
|
/**
|
|
1095
1320
|
* Decide whether SessionStart/CwdChanged should offer to create a project for
|
|
1096
1321
|
* `cwd`. The caller MUST have already confirmed `cwd` matches no project's
|
|
1097
|
-
* `working_dir` (the hook's MISS branch); this evaluates the remaining
|
|
1322
|
+
* `working_dir` (the hook's MISS branch); this evaluates the remaining
|
|
1098
1323
|
* trigger conditions: (a) cwd is a git repo, (b) carries a project marker
|
|
1099
1324
|
* (`.git` alone is a weak signal — §8.11), (c) not in cooldown, (d) not a cwd
|
|
1100
1325
|
* the user previously declined. A corrupt store stays silent (doctor surfaces).
|
|
@@ -1139,7 +1364,7 @@ export function buildProjectSuggestionLine(cwd) {
|
|
|
1139
1364
|
return `[WIKI: cwd '${safe}'에 매칭되는 프로젝트가 없습니다. 자동 생성할까요? (Y/n)]`;
|
|
1140
1365
|
}
|
|
1141
1366
|
|
|
1142
|
-
// ── clear-marker (
|
|
1367
|
+
// ── clear-marker (amendment 2026-05-14) ──────────────────────
|
|
1143
1368
|
// `/clear` cannot be blocked (no UserPromptSubmit fire). The only intervention
|
|
1144
1369
|
// point is the SessionEnd(reason='clear') → SessionStart(source='clear') pair:
|
|
1145
1370
|
// SessionEnd writes `.cache/clear-marker.json` with the dying session's id +
|
|
@@ -1157,7 +1382,7 @@ function clearMarkerPath(hypoDir) {
|
|
|
1157
1382
|
|
|
1158
1383
|
/**
|
|
1159
1384
|
* Persist the dying session's identity so the next SessionStart(source=clear)
|
|
1160
|
-
* can issue a recovery nudge. Single-file by design (see
|
|
1385
|
+
* can issue a recovery nudge. Single-file by design (see the amendment):
|
|
1161
1386
|
* /clear is a single-client UX action, multi-marker disambiguation buys no
|
|
1162
1387
|
* safety and breaks the 1-of-1 read-and-unlink contract.
|
|
1163
1388
|
*
|
|
@@ -1224,7 +1449,7 @@ export function clearClearMarker(hypoDir) {
|
|
|
1224
1449
|
}
|
|
1225
1450
|
}
|
|
1226
1451
|
|
|
1227
|
-
// ── session-closed marker (
|
|
1452
|
+
// ── session-closed marker (amendment 2026-05-19) ─────────────
|
|
1228
1453
|
// Per-session marker proving session-close completed. Stop hook
|
|
1229
1454
|
// (`hypo-auto-minimal-crystallize`) reads it; `scripts/crystallize.mjs` writes
|
|
1230
1455
|
// it after a verified close. Per-session (not per-day) precision resolves the
|
|
@@ -1232,7 +1457,7 @@ export function clearClearMarker(hypoDir) {
|
|
|
1232
1457
|
// later session reuses an earlier session's entry on the same day.
|
|
1233
1458
|
//
|
|
1234
1459
|
// Writer authority lives in crystallize, NOT this hook: the hook only checks
|
|
1235
|
-
// presence. See
|
|
1460
|
+
// presence. See amendment 2026-05-19 Q2 for the split rationale.
|
|
1236
1461
|
|
|
1237
1462
|
const SESSION_CLOSED_MARKER_STALE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
1238
1463
|
|
|
@@ -1327,7 +1552,7 @@ export function clearSessionClosedMarker(hypoDir, sessionId) {
|
|
|
1327
1552
|
}
|
|
1328
1553
|
}
|
|
1329
1554
|
|
|
1330
|
-
// ── transcript activity heuristic (
|
|
1555
|
+
// ── transcript activity heuristic (amendment 2026-05-19; 6a 2026-06-14) ─────────
|
|
1331
1556
|
// Substantial-session gate for the Stop hook: a session "worth" blocking on for
|
|
1332
1557
|
// session-close is either (a) any mutation (Edit/Write/MultiEdit/NotebookEdit)
|
|
1333
1558
|
// or (b) a high-volume read-only investigation (≥ READONLY_SUBSTANTIAL_THRESHOLD
|
|
@@ -1555,28 +1780,45 @@ export function closeFileTargets(hypoDir) {
|
|
|
1555
1780
|
|
|
1556
1781
|
/**
|
|
1557
1782
|
* Global variant of closeFileTargets for the no-payload lint-scope callers
|
|
1558
|
-
*
|
|
1783
|
+
* Union of the close files over every today-active project
|
|
1559
1784
|
* (fallback: the recency project when none is active). Includes the session-log
|
|
1560
1785
|
* evidence file for EVERY freshDate (not just dates[0]) so the lint scope matches
|
|
1561
1786
|
* what sessionCloseFileStatus actually checks across a local/UTC date boundary.
|
|
1562
1787
|
*/
|
|
1788
|
+
// The lint-scope target set for ONE project's close: the shared root files
|
|
1789
|
+
// (hot.md / log.md) plus that project's mandatory close files — session-state,
|
|
1790
|
+
// project hot, and each fresh date's session-log evidence file. Used both by
|
|
1791
|
+
// `--check-session-close --project=<slug>` (a project-scoped diagnostic — see
|
|
1792
|
+
// precompactGateStatus opts.projectOverride) and as the per-project building
|
|
1793
|
+
// block of closeFileTargetsGlobal, so the two scopes stay identical per project.
|
|
1794
|
+
export function closeFileTargetsForProject(hypoDir, slug) {
|
|
1795
|
+
const dates = freshDates();
|
|
1796
|
+
const out = new Set(['hot.md', 'log.md']);
|
|
1797
|
+
out.add(`projects/${slug}/session-state.md`);
|
|
1798
|
+
out.add(`projects/${slug}/hot.md`);
|
|
1799
|
+
// Scope to the file each date's freshness is PROVEN by (daily shard or, via
|
|
1800
|
+
// fallback, the legacy monthly), so a corrupt evidence file can't pass the
|
|
1801
|
+
// gate while its lint error is demoted to an out-of-scope notice.
|
|
1802
|
+
for (const d of dates) out.add(sessionLogScopePath(hypoDir, slug, d));
|
|
1803
|
+
return out;
|
|
1804
|
+
}
|
|
1805
|
+
|
|
1563
1806
|
export function closeFileTargetsGlobal(hypoDir) {
|
|
1564
1807
|
const dates = freshDates();
|
|
1565
1808
|
const out = new Set(['hot.md', 'log.md']);
|
|
1566
1809
|
let active = [...closeCandidateSlugs(hypoDir, dates)].filter((p) =>
|
|
1567
1810
|
hasTodayCloseActivity(hypoDir, p, dates),
|
|
1568
1811
|
);
|
|
1812
|
+
// No project closed today → fall back to the recency project (mirrors
|
|
1813
|
+
// sessionCloseGlobalStatus's own fallback at the top of this file), so the lint
|
|
1814
|
+
// scope never narrows below the close-status scope. Dropping this (root-only
|
|
1815
|
+
// when active=[]) would re-open the very gap closeFileTargetsForProject closes.
|
|
1569
1816
|
if (active.length === 0) {
|
|
1570
1817
|
const recency = resolveActiveProject(hypoDir);
|
|
1571
1818
|
if (recency) active = [recency];
|
|
1572
1819
|
}
|
|
1573
1820
|
for (const p of active) {
|
|
1574
|
-
out.add(
|
|
1575
|
-
out.add(`projects/${p}/hot.md`);
|
|
1576
|
-
// Scope to the file each date's freshness is PROVEN by (daily shard or, via
|
|
1577
|
-
// fallback, the legacy monthly), so a corrupt evidence file can't pass the
|
|
1578
|
-
// gate while its lint error is demoted to an out-of-scope notice.
|
|
1579
|
-
for (const d of dates) out.add(sessionLogScopePath(hypoDir, p, d));
|
|
1821
|
+
for (const f of closeFileTargetsForProject(hypoDir, p)) out.add(f);
|
|
1580
1822
|
}
|
|
1581
1823
|
return out;
|
|
1582
1824
|
}
|
|
@@ -1609,6 +1851,21 @@ export function partitionLintScope(findings, scope) {
|
|
|
1609
1851
|
return { blocking, notice };
|
|
1610
1852
|
}
|
|
1611
1853
|
|
|
1854
|
+
/**
|
|
1855
|
+
* True if a repo-relative file lives under any of the given project dirs
|
|
1856
|
+
* (`projects/<slug>/...`, or the dir itself). Both surfaces that surface
|
|
1857
|
+
* pre-existing lint-debt NOTICES use this to decide what to LIST vs fold: debt
|
|
1858
|
+
* under a close-target project is the close's own neighborhood and stays listed;
|
|
1859
|
+
* debt elsewhere (other projects, shared `pages/`, root `hot.md`/`log.md`) folds
|
|
1860
|
+
* to a count so the same untouched-file debt does not re-list its filenames on
|
|
1861
|
+
* every close. Same path policy as the lint-scope matcher above: separators
|
|
1862
|
+
* normalized to POSIX, exact prefix at a segment boundary.
|
|
1863
|
+
*/
|
|
1864
|
+
export function isUnderProjectDirs(file, slugs) {
|
|
1865
|
+
const f = posixPath(file);
|
|
1866
|
+
return (slugs || []).some((s) => s && (f === `projects/${s}` || f.startsWith(`projects/${s}/`)));
|
|
1867
|
+
}
|
|
1868
|
+
|
|
1612
1869
|
// ── PreCompact gate — single source of truth ────────────────────────────────
|
|
1613
1870
|
/**
|
|
1614
1871
|
* The full PreCompact gate decision as a READ-ONLY status. This is the single
|
|
@@ -1618,9 +1875,9 @@ export function partitionLintScope(findings, scope) {
|
|
|
1618
1875
|
*
|
|
1619
1876
|
* Read-only: feedback projection PURE drift is reported as a non-blocking notice
|
|
1620
1877
|
* with its targets in `driftTargets` (an "effect requirement"), NOT a blocker —
|
|
1621
|
-
* the hook self-heals it with `feedback-sync --write` before continuing
|
|
1622
|
-
*
|
|
1623
|
-
* DO block (
|
|
1878
|
+
* the hook self-heals it with `feedback-sync --write` before continuing,
|
|
1879
|
+
* and a verify caller needs no human action for it. over-cap and conflict
|
|
1880
|
+
* DO block (human demote/import required).
|
|
1624
1881
|
*
|
|
1625
1882
|
* Faithfulness caveats (why "compact-ready", not "guaranteed pass"): the hook
|
|
1626
1883
|
* has paths outside this status — a context-≥70% early block, HYPO_SKIP_GATE
|
|
@@ -1629,9 +1886,19 @@ export function partitionLintScope(findings, scope) {
|
|
|
1629
1886
|
* opts.transcriptPath to widen it to the session's edited files exactly as the
|
|
1630
1887
|
* hook does.
|
|
1631
1888
|
*
|
|
1889
|
+
* opts.projectOverride (CHECK-ONLY) narrows BOTH the close status and the lint
|
|
1890
|
+
* scope to a single project, for `--check-session-close --project=<slug>`. A
|
|
1891
|
+
* green result is then a project-scoped diagnostic, NOT the global compact-ready
|
|
1892
|
+
* verdict: the caller must surface the scope. It is NEVER passed from
|
|
1893
|
+
* a marker-writing path (--mark / apply auto-marker / PreCompact); those stay
|
|
1894
|
+
* global so a marker can't attest compact-ready while PreCompact re-checks red
|
|
1895
|
+
* (the marker == compact-ready invariant, codex design review). When a
|
|
1896
|
+
* log-only marker governs the session, log-only mode wins and projectOverride is
|
|
1897
|
+
* ignored.
|
|
1898
|
+
*
|
|
1632
1899
|
* @param {string} hypoDir
|
|
1633
|
-
* @param {{lintScope?: Iterable<string>, transcriptPath?: string|null, claudeHome?: string}} [opts]
|
|
1634
|
-
* @returns {{ok: boolean, close: object, blockers: {type:string,reason:string}[], notices: {type:string,reason:string}[], driftTargets: string[], skipped: {lint:boolean, feedback:boolean}}}
|
|
1900
|
+
* @param {{lintScope?: Iterable<string>, transcriptPath?: string|null, claudeHome?: string, projectOverride?: string|null}} [opts]
|
|
1901
|
+
* @returns {{ok: boolean, close: object, blockers: {type:string,reason:string}[], notices: {type:string,reason:string,file?:string}[], driftTargets: string[], skipped: {lint:boolean, feedback:boolean}}}
|
|
1635
1902
|
*/
|
|
1636
1903
|
export function precompactGateStatus(hypoDir, opts = {}) {
|
|
1637
1904
|
const blockers = [];
|
|
@@ -1652,11 +1919,11 @@ export function precompactGateStatus(hypoDir, opts = {}) {
|
|
|
1652
1919
|
const marker = opts.sessionId ? readSessionClosedMarker(hypoDir, opts.sessionId) : null;
|
|
1653
1920
|
const logOnly = opts.logOnly === true || marker?.scope === 'log-only';
|
|
1654
1921
|
|
|
1655
|
-
// 1. wiki git state
|
|
1922
|
+
// 1. wiki git state. Uncommitted changes (real unsaved work) BLOCK:
|
|
1656
1923
|
// they are human-fixable. Unpushed commits (ahead) DEMOTE to a notice: push is
|
|
1657
1924
|
// automatic (auto-commit Stop hook) and its failures are already non-fatal, so
|
|
1658
1925
|
// "ahead" is a transient sync state, not a human-fixable blocker. Demoting it
|
|
1659
|
-
// here (the shared gate) keeps the marker == compact-ready invariant
|
|
1926
|
+
// here (the shared gate) keeps the marker == compact-ready invariant:
|
|
1660
1927
|
// a committed-but-unpushed close marks AND compacts, instead of the close writer
|
|
1661
1928
|
// committing its own payload and then being blocked by its own (unpushed) commit.
|
|
1662
1929
|
const git = hypoIsClean(hypoDir);
|
|
@@ -1671,7 +1938,7 @@ export function precompactGateStatus(hypoDir, opts = {}) {
|
|
|
1671
1938
|
const hot = hotMdIsClean(hypoDir);
|
|
1672
1939
|
if (!hot.clean) blockers.push({ type: 'hot', reason: hot.reason });
|
|
1673
1940
|
|
|
1674
|
-
// 3. session-close files (global invariant
|
|
1941
|
+
// 3. session-close files (global invariant); in log-only mode,
|
|
1675
1942
|
// the minimum proof (a today log.md entry) with NO project attribution.
|
|
1676
1943
|
let close;
|
|
1677
1944
|
if (logOnly) {
|
|
@@ -1693,7 +1960,9 @@ export function precompactGateStatus(hypoDir, opts = {}) {
|
|
|
1693
1960
|
});
|
|
1694
1961
|
}
|
|
1695
1962
|
} else {
|
|
1696
|
-
close
|
|
1963
|
+
// projectOverride narrows the close status to one project (check-only); a
|
|
1964
|
+
// marker-writing caller never sets it, so the marker path stays global.
|
|
1965
|
+
close = sessionCloseGlobalStatus(hypoDir, { projectOverride: opts.projectOverride });
|
|
1697
1966
|
if (!close.ok) {
|
|
1698
1967
|
blockers.push({
|
|
1699
1968
|
type: 'close',
|
|
@@ -1744,8 +2013,16 @@ export function precompactGateStatus(hypoDir, opts = {}) {
|
|
|
1744
2013
|
// mandatory files in and re-introduce the cross-project attribution. The
|
|
1745
2014
|
// session's own transcript-touched files are still added below (a log-only
|
|
1746
2015
|
// session is accountable for the wiki files it actually edited).
|
|
2016
|
+
// Lint scope: explicit opts.lintScope wins; else log-only uses the shared
|
|
2017
|
+
// root files only; else projectOverride narrows to that one project's close
|
|
2018
|
+
// files (matching the narrowed close status above); else the global set.
|
|
1747
2019
|
const scope = new Set(
|
|
1748
|
-
opts.lintScope ||
|
|
2020
|
+
opts.lintScope ||
|
|
2021
|
+
(logOnly
|
|
2022
|
+
? ['hot.md', 'log.md']
|
|
2023
|
+
: opts.projectOverride
|
|
2024
|
+
? closeFileTargetsForProject(hypoDir, opts.projectOverride)
|
|
2025
|
+
: closeFileTargetsGlobal(hypoDir)),
|
|
1749
2026
|
);
|
|
1750
2027
|
if (opts.transcriptPath && existsSync(opts.transcriptPath)) {
|
|
1751
2028
|
for (const f of extractTouchedWikiFiles(opts.transcriptPath, hypoDir)) scope.add(f);
|
|
@@ -1758,9 +2035,13 @@ export function precompactGateStatus(hypoDir, opts = {}) {
|
|
|
1758
2035
|
});
|
|
1759
2036
|
}
|
|
1760
2037
|
for (const n of part.notice)
|
|
1761
|
-
notices.push({
|
|
2038
|
+
notices.push({
|
|
2039
|
+
type: 'lint',
|
|
2040
|
+
file: n.file,
|
|
2041
|
+
reason: `${n.file}${n.id ? ` (${n.id})` : ''}`,
|
|
2042
|
+
});
|
|
1762
2043
|
// W8 (design-history stale) is each today-active project's own close
|
|
1763
|
-
// responsibility; others' are non-blocking notices
|
|
2044
|
+
// responsibility; others' are non-blocking notices. In log-only
|
|
1764
2045
|
// mode there is NO project this session is accountable for, so every W8 is a
|
|
1765
2046
|
// notice — a non-project session must never be blocked by some project's
|
|
1766
2047
|
// stale design-history (codex design BLOCKER: the attribution leak this fix
|
|
@@ -1785,7 +2066,8 @@ export function precompactGateStatus(hypoDir, opts = {}) {
|
|
|
1785
2066
|
reason: `design-history stale: ${w8Blocking.map((w) => w.file.split('/')[1]).join(', ')}`,
|
|
1786
2067
|
});
|
|
1787
2068
|
}
|
|
1788
|
-
for (const w of w8Notice)
|
|
2069
|
+
for (const w of w8Notice)
|
|
2070
|
+
notices.push({ type: 'design-history', file: w.file, reason: w.file });
|
|
1789
2071
|
} catch (e) {
|
|
1790
2072
|
skipped.lint = true; // fail-open on tooling error
|
|
1791
2073
|
// Surface WHY the gate skipped lint (truncated stdout, timeout, spawn error)
|
|
@@ -1797,7 +2079,7 @@ export function precompactGateStatus(hypoDir, opts = {}) {
|
|
|
1797
2079
|
}
|
|
1798
2080
|
}
|
|
1799
2081
|
|
|
1800
|
-
// 5. feedback projection
|
|
2082
|
+
// 5. feedback projection. over-cap/conflict block; pure
|
|
1801
2083
|
// drift is a self-healable notice (driftTargets = effect requirement the
|
|
1802
2084
|
// hook runs as --write). Classification mirrors hypo-personal-check exactly.
|
|
1803
2085
|
const feedbackPath = PKG_ROOT ? join(PKG_ROOT, 'scripts', 'feedback-sync.mjs') : null;
|
|
@@ -1910,7 +2192,7 @@ export function isClearCommand(prompt) {
|
|
|
1910
2192
|
return prompt === '/clear' || /^\/clear(\s|$)/.test(prompt);
|
|
1911
2193
|
}
|
|
1912
2194
|
|
|
1913
|
-
/** Returns true if the prompt is either /compact or /clear (
|
|
2195
|
+
/** Returns true if the prompt is either /compact or /clear (Layer 2). */
|
|
1914
2196
|
export function isCompactOrClearCommand(prompt) {
|
|
1915
2197
|
return isCompactCommand(prompt) || isClearCommand(prompt);
|
|
1916
2198
|
}
|
|
@@ -1941,7 +2223,7 @@ export function extractUserMessages(transcriptPath, tailN = 30) {
|
|
|
1941
2223
|
.map((line) => {
|
|
1942
2224
|
try {
|
|
1943
2225
|
const obj = JSON.parse(line);
|
|
1944
|
-
// Skill-injection vector
|
|
2226
|
+
// Skill-injection vector: drop system-injected role:user
|
|
1945
2227
|
// messages before they pollute the close-intent signal.
|
|
1946
2228
|
// • isMeta:true — slash-command bodies, skill bodies, local-command
|
|
1947
2229
|
// caveats. Their text is docs/specs, often full of close vocabulary
|
|
@@ -1999,7 +2281,7 @@ export function isClosePattern(text) {
|
|
|
1999
2281
|
// 끝: bare terminal noun, boundary-guarded so "세션 끝내는 방법" /
|
|
2000
2282
|
// "세션 끝나면" don't trip.
|
|
2001
2283
|
/세션\s*끝(?![가-힣])/,
|
|
2002
|
-
// 세션
|
|
2284
|
+
// 세션 마무리/종료: the OLD pattern required a fixed verb suffix
|
|
2003
2285
|
// (하자/할게/했어) and missed the most common real phrasings — "세션 마무리
|
|
2004
2286
|
// 해줘" (imperative), bare "세션 마무리", "세션마무리" (no space). A
|
|
2005
2287
|
// blacklist lookahead (excluding 조건/로직/여부/…) is whack-a-mole because
|
|
@@ -2015,8 +2297,8 @@ export function isClosePattern(text) {
|
|
|
2015
2297
|
// the rare FN over the FP. The residual: connective forms that happen to put a
|
|
2016
2298
|
// space after a complete terminal can't be separated by regex without a
|
|
2017
2299
|
// morphological parser — that's bounded by the compound gate (precompact-green
|
|
2018
|
-
// + signal) and the unambiguous /compact and AskUserQuestion channels (
|
|
2019
|
-
//
|
|
2300
|
+
// + signal) and the unambiguous /compact and AskUserQuestion channels (threat
|
|
2301
|
+
// boundary), not chased further.
|
|
2020
2302
|
/세션\s*(?:마무리|종료)(?:\s*(?:해줘|해주세요|해요|해|하자|하죠|했어|했다|했음|했지|합시다|합니다|할게|할께|할래|할까|할까요|한\s?거(?:지|야|니)?|함)(?![가-힣])|\s*[)\].,!?~。…]|\s*$)/m,
|
|
2021
2303
|
/오늘\s*은?\s*(여기|작업|세션).*(끝|마치|마무리|종료)/,
|
|
2022
2304
|
// 여기까지: requires no continuation action word (e.g. 여기까지 구현해줘 is not a close signal)
|
|
@@ -2034,7 +2316,7 @@ export function isClosePattern(text) {
|
|
|
2034
2316
|
// objects. The review/analysis/debug/audit/investigation nouns were added
|
|
2035
2317
|
// for 6a — read-only review sessions are now "substantial", so "wrap up the
|
|
2036
2318
|
// review" must read as a task-level signal, not a session-close one.
|
|
2037
|
-
// Leading \b on each pattern
|
|
2319
|
+
// Leading \b on each pattern so an EN close phrase embedded as a
|
|
2038
2320
|
// substring of a longer token can't trip the gate (e.g. "designing off…").
|
|
2039
2321
|
/\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,
|
|
2040
2322
|
/\bdone\s+for\s+(?:today|now|the\s+day)\b/i,
|
|
@@ -2056,7 +2338,7 @@ export function isClosePattern(text) {
|
|
|
2056
2338
|
* slug would miss the file. The session id is a UUID, so the glob disambiguates
|
|
2057
2339
|
* without needing the slug — verified globally unique across all project dirs.
|
|
2058
2340
|
*
|
|
2059
|
-
* Fail-closed on ambiguity (
|
|
2341
|
+
* Fail-closed on ambiguity (codex review): returns the single resolved
|
|
2060
2342
|
* path, or null when ZERO or MORE-THAN-ONE distinct files match (realpath-deduped
|
|
2061
2343
|
* so a symlink to the same file is not "multiple"). The caller treats null as
|
|
2062
2344
|
* "refuse the marker".
|
|
@@ -2093,13 +2375,13 @@ export function resolveTranscriptBySessionId(
|
|
|
2093
2375
|
|
|
2094
2376
|
/**
|
|
2095
2377
|
* Returns true iff the transcript carries a genuine USER session-close signal —
|
|
2096
|
-
* the hard gate for the session-closed marker writers
|
|
2378
|
+
* the hard gate for the session-closed marker writers. Scans the FULL
|
|
2097
2379
|
* transcript: a close request can precede the marker write by the entire close
|
|
2098
2380
|
* checklist, so the Stop hook's 30-line tail would miss it.
|
|
2099
2381
|
*
|
|
2100
2382
|
* Evidence (any one is sufficient):
|
|
2101
2383
|
* 1. a de-polluted NL close phrase — isClosePattern over extractUserMessages,
|
|
2102
|
-
* which already drops injected / tool / hook-feedback text
|
|
2384
|
+
* which already drops injected / tool / hook-feedback text;
|
|
2103
2385
|
* 2. a `/compact` invocation (queue-operation). `/clear` is deliberately NOT
|
|
2104
2386
|
* counted: it abandons context, whereas a session-close PRESERVES the work
|
|
2105
2387
|
* to the wiki — a different intent;
|
|
@@ -2182,7 +2464,7 @@ export function buildOutput(context, extra = {}) {
|
|
|
2182
2464
|
// ── growth metrics (F2 + E4) ───────────────────────────────────────────────
|
|
2183
2465
|
// Single formatter used by Stop (hot-rebuild) and SessionStart hooks so the
|
|
2184
2466
|
// "[hypo] +N pages, ~M updated, K wikilinks" line stays consistent at both
|
|
2185
|
-
// ends of a session. See
|
|
2467
|
+
// ends of a session. See Lane B.
|
|
2186
2468
|
|
|
2187
2469
|
/**
|
|
2188
2470
|
* Format a growth-metrics one-liner. Returns '' when all counts are 0 so
|