hypomnema 1.7.0 → 1.7.2
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 +79 -50
- package/README.md +63 -34
- package/hooks/hooks.json +11 -0
- package/hooks/hypo-auto-commit.mjs +92 -15
- package/hooks/hypo-auto-stage.mjs +28 -18
- package/hooks/hypo-close-guard.mjs +246 -0
- package/hooks/hypo-hot-rebuild.mjs +43 -5
- package/hooks/hypo-session-start.mjs +166 -13
- package/hooks/hypo-shared.mjs +1358 -130
- package/hooks/version-check.mjs +92 -0
- package/package.json +4 -1
- package/scripts/crystallize.mjs +112 -2
- package/scripts/doctor.mjs +627 -17
- package/scripts/graph.mjs +27 -12
- package/scripts/init.mjs +66 -7
- package/scripts/lib/git-hooks-dir.mjs +229 -0
- package/scripts/lib/pkg-provenance.mjs +166 -0
- package/scripts/lib/project-create.mjs +5 -1
- package/scripts/lib/rename-marker.mjs +39 -0
- package/scripts/lint.mjs +84 -2
- package/scripts/rename.mjs +223 -18
- package/scripts/stats.mjs +14 -2
- package/scripts/uninstall.mjs +12 -0
- package/scripts/upgrade.mjs +8 -0
- package/templates/gitignore +4 -0
- package/templates/hypo-config.md +1 -1
package/scripts/doctor.mjs
CHANGED
|
@@ -13,17 +13,26 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import { existsSync, readFileSync, readdirSync, statSync } from 'fs';
|
|
16
|
-
import { join, relative, extname } from 'path';
|
|
16
|
+
import { join, relative, extname, dirname } from 'path';
|
|
17
17
|
import { homedir } from 'os';
|
|
18
18
|
import { spawnSync } from 'child_process';
|
|
19
19
|
import { fileURLToPath } from 'url';
|
|
20
20
|
import { resolveHypoRoot, expandHome } from './lib/hypo-root.mjs';
|
|
21
21
|
import { loadHypoIgnore, isScanIgnored } from './lib/hypo-ignore.mjs';
|
|
22
|
+
import { readRenameMarker, renameMarkerPath, RENAME_MARKER_REL } from './lib/rename-marker.mjs';
|
|
23
|
+
import { resolveGitHooksDir } from './lib/git-hooks-dir.mjs';
|
|
22
24
|
import { parseFrontmatter } from './lib/frontmatter.mjs';
|
|
23
25
|
import {
|
|
24
26
|
readSyncState,
|
|
27
|
+
readSyncLastSuccess,
|
|
28
|
+
classifySyncOp,
|
|
25
29
|
projectSuggestionsPath,
|
|
26
30
|
collectProjectWorkingDirs,
|
|
31
|
+
detectSessionCloseArtifact,
|
|
32
|
+
localAndUtcDates,
|
|
33
|
+
SESSION_CLOSED_MARKER_STALE_MS,
|
|
34
|
+
isUsablePkgRootLocal,
|
|
35
|
+
selfLocationPkgRootFrom,
|
|
27
36
|
} from '../hooks/hypo-shared.mjs';
|
|
28
37
|
import { listProposals } from '../hooks/proposal-store.mjs';
|
|
29
38
|
import {
|
|
@@ -43,12 +52,19 @@ import {
|
|
|
43
52
|
CODEX_TYPES,
|
|
44
53
|
} from './lib/extensions.mjs';
|
|
45
54
|
import { sha256, readFileIfRegular, readPkgJson } from './lib/pkg-json.mjs';
|
|
55
|
+
import {
|
|
56
|
+
provenancePath,
|
|
57
|
+
EXPECTED_PKG_NAME,
|
|
58
|
+
HOOKS_DIGEST_FIELD,
|
|
59
|
+
computeHooksDigest,
|
|
60
|
+
} from './lib/pkg-provenance.mjs';
|
|
46
61
|
import { resolveCliOnPath, classifyInstall } from '../hooks/version-check.mjs';
|
|
47
62
|
import { isHypomnemaPluginEnabled } from './lib/plugin-detect.mjs';
|
|
48
63
|
|
|
49
64
|
const HOME = homedir();
|
|
50
65
|
const SCRIPT_DIR = fileURLToPath(new URL('.', import.meta.url));
|
|
51
66
|
const PKG_ROOT = join(SCRIPT_DIR, '..');
|
|
67
|
+
const HOOKS_SRC = join(PKG_ROOT, 'hooks');
|
|
52
68
|
|
|
53
69
|
// ── install channel ───────────────────────────────────────────────────────────
|
|
54
70
|
//
|
|
@@ -313,6 +329,8 @@ function checkHooks(coreManagedByPlugin) {
|
|
|
313
329
|
} else {
|
|
314
330
|
fail('Hook files installed', `No hook files found in ${claudeHooks} — run /hypo:init`);
|
|
315
331
|
}
|
|
332
|
+
|
|
333
|
+
checkProvenanceSidecar(claudeHooks, 'hooks/.hypo-provenance.json');
|
|
316
334
|
}
|
|
317
335
|
|
|
318
336
|
function checkSettingsJson(coreManagedByPlugin) {
|
|
@@ -450,20 +468,168 @@ function checkGit(hypoDir) {
|
|
|
450
468
|
warn('Git remote origin', 'No remote configured — wiki will not sync/backup automatically');
|
|
451
469
|
}
|
|
452
470
|
|
|
453
|
-
|
|
471
|
+
// Ask git for the ACTIVE hooks dir rather than assuming <repo>/.git/hooks —
|
|
472
|
+
// that guess is wrong in a linked worktree (`.git` is a file) and whenever
|
|
473
|
+
// core.hooksPath is set, and reporting "not installed" for either would be a
|
|
474
|
+
// false negative. Read-side policy differs from the installer's: doctor
|
|
475
|
+
// reports a hooks dir it would refuse to WRITE into, so the user can see it.
|
|
476
|
+
const resolved = resolveGitHooksDir(hypoDir);
|
|
477
|
+
if (!resolved.ok) {
|
|
478
|
+
const detail =
|
|
479
|
+
resolved.reason === 'hooks-disabled'
|
|
480
|
+
? `Hooks path is not a directory (${resolved.path}) — git runs no hooks, so the .hypoignore guard cannot run`
|
|
481
|
+
: `Could not resolve the git hooks directory (${resolved.detail || resolved.reason})`;
|
|
482
|
+
warn('git hooks/pre-commit', detail);
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
const preCommitPath = join(resolved.path, 'pre-commit');
|
|
487
|
+
const label = resolved.owned ? 'git hooks/pre-commit' : `pre-commit (${resolved.path})`;
|
|
488
|
+
let content = null;
|
|
489
|
+
let unreadable = null;
|
|
454
490
|
if (existsSync(preCommitPath)) {
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
warn(
|
|
460
|
-
'.git/hooks/pre-commit',
|
|
461
|
-
'Exists but not managed by Hypomnema — manual git add can bypass .hypoignore',
|
|
462
|
-
);
|
|
491
|
+
try {
|
|
492
|
+
content = readFileSync(preCommitPath, 'utf-8');
|
|
493
|
+
} catch (e) {
|
|
494
|
+
unreadable = e.code || e.message;
|
|
463
495
|
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
if (unreadable) {
|
|
499
|
+
warn(label, `Exists but could not be read (${unreadable})`);
|
|
500
|
+
} else if (content === null) {
|
|
501
|
+
// Telling the user to run /hypo:init here would contradict the ownership
|
|
502
|
+
// warning below, which says it will refuse this very path.
|
|
503
|
+
warn(
|
|
504
|
+
label,
|
|
505
|
+
resolved.owned
|
|
506
|
+
? 'Not installed — run /hypo:init to install .hypoignore guard'
|
|
507
|
+
: 'Not installed, and /hypo:init will not install into this path — point core.hooksPath back inside the repository, or install the guard yourself',
|
|
508
|
+
);
|
|
509
|
+
} else if (content.includes('# hypo-managed:pre-commit:start')) {
|
|
510
|
+
pass(label, 'Hypomnema .hypoignore guard installed');
|
|
464
511
|
} else {
|
|
465
|
-
warn(
|
|
512
|
+
warn(label, 'Exists but not managed by Hypomnema — manual git add can bypass .hypoignore');
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// Reported even when the hook above was unreadable — an unreadable hook is
|
|
516
|
+
// exactly when knowing WHERE git looks matters most.
|
|
517
|
+
if (!resolved.owned) {
|
|
518
|
+
warn(
|
|
519
|
+
'core.hooksPath',
|
|
520
|
+
`Points outside this repository (${resolved.path}) — /hypo:init will not install there`,
|
|
521
|
+
);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// rename.mjs --apply writes .cache/rename-in-progress.json before its first
|
|
526
|
+
// inbound-link rewrite and removes it only after the terminal move lands (see
|
|
527
|
+
// rename.mjs's writeRenameMarker/clearRenameMarker). A marker still present
|
|
528
|
+
// means the process died mid-run: some inbound wikilinks may already point at
|
|
529
|
+
// `to` while the page/directory itself still sits at `from`. checkBrokenLinks
|
|
530
|
+
// below would just report those as generic broken links (W4's doctor
|
|
531
|
+
// counterpart) with no clue why — this names the cause and the exact fix.
|
|
532
|
+
// Re-running the SAME command converges on its own (the module docstring's
|
|
533
|
+
// move-last invariant): nothing to merge or roll back by hand.
|
|
534
|
+
// Best-effort: does this vault's git repo leave `markerPath` un-ignored (either
|
|
535
|
+
// already tracked, or plain untracked-and-stageable)? `git check-ignore` answers
|
|
536
|
+
// both at once — a tracked file is also reported "not ignored" by it, since
|
|
537
|
+
// .gitignore rules don't retroactively apply to tracked paths. Fails CLOSED to
|
|
538
|
+
// "don't know, say nothing": no `.git`, no git on PATH, or any non-0/1 exit
|
|
539
|
+
// (128 = not a repo / path outside it / other error) all return false, because
|
|
540
|
+
// this is advisory-only and a wrong guess is worse than silence (codex CONCERN 3
|
|
541
|
+
// — never turn an inconclusive git query into an error of its own).
|
|
542
|
+
function markerNotGitIgnored(hypoDir, markerPath) {
|
|
543
|
+
if (!existsSync(join(hypoDir, '.git'))) return false;
|
|
544
|
+
const rel = relative(hypoDir, markerPath);
|
|
545
|
+
const r = spawnSync('git', ['-C', hypoDir, 'check-ignore', '-q', rel], { encoding: 'utf-8' });
|
|
546
|
+
if (r.error || r.status === null) return false;
|
|
547
|
+
if (r.status !== 0 && r.status !== 1) return false; // 128 etc — inconclusive
|
|
548
|
+
return r.status === 1; // check-ignore's "not ignored" exit code
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function checkIncompleteRename(hypoDir) {
|
|
552
|
+
// "no marker" and "a marker we cannot read" are different answers and must not
|
|
553
|
+
// collapse. readRenameMarker returns null for both, so ask the filesystem
|
|
554
|
+
// which one it is: a present-but-unreadable marker still means a rename did
|
|
555
|
+
// not finish, and reporting that as a pass would make the detector fail open
|
|
556
|
+
// on exactly the case it exists for.
|
|
557
|
+
const markerPath = renameMarkerPath(hypoDir);
|
|
558
|
+
const present = existsSync(markerPath);
|
|
559
|
+
if (!present) {
|
|
560
|
+
pass('Incomplete rename', 'No rename-in-progress marker found');
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// codex CONCERN 3: a legacy/custom vault whose .gitignore predates
|
|
565
|
+
// templates/gitignore's `.cache/` entry (init never edits an existing
|
|
566
|
+
// .gitignore) can let `git add -A` commit this marker, producing a ghost
|
|
567
|
+
// warning on another machine that never ran a rename at all. Advisory only —
|
|
568
|
+
// never turns the rename itself into an error — appended to every branch
|
|
569
|
+
// below that reports on a present marker.
|
|
570
|
+
const gitAdvisory = markerNotGitIgnored(hypoDir, markerPath)
|
|
571
|
+
? ` This vault's git repo does not ignore ${dirname(RENAME_MARKER_REL)}/ — \`git add -A\` could commit this marker and produce a ghost warning on another machine. Add \`.cache/\` to .gitignore.`
|
|
572
|
+
: '';
|
|
573
|
+
|
|
574
|
+
const marker = readRenameMarker(hypoDir);
|
|
575
|
+
if (!marker || !marker.from || !marker.to) {
|
|
576
|
+
warn(
|
|
577
|
+
'Incomplete rename',
|
|
578
|
+
`${markerPath} exists but is unreadable or missing its from/to fields, so a rename did not finish and this cannot say which one. Inspect the file, re-run that rename to converge, then delete the marker.${gitAdvisory}`,
|
|
579
|
+
);
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// codex BLOCKER 2: the marker alone cannot distinguish a genuinely stuck
|
|
584
|
+
// rename from one that finished right before the process died — rename.mjs's
|
|
585
|
+
// move (renameSync) is the terminal step, and the marker is only cleared
|
|
586
|
+
// AFTER it. Cross-check `from`/`to` (vault-root-relative) against the
|
|
587
|
+
// filesystem to tell the three reachable states apart.
|
|
588
|
+
const { mode, from, to, started_at } = marker;
|
|
589
|
+
const label = mode === 'directory' ? 'directory' : 'page';
|
|
590
|
+
const fromExists = existsSync(join(hypoDir, from));
|
|
591
|
+
const toExists = existsSync(join(hypoDir, to));
|
|
592
|
+
const cmd = `node ${join(PKG_ROOT, 'scripts', 'rename.mjs')} --hypo-dir=${hypoDir} --from=${from} --to=${to} --apply`;
|
|
593
|
+
|
|
594
|
+
if (fromExists && !toExists) {
|
|
595
|
+
// The real in-progress case: --from still resolves, so re-running the
|
|
596
|
+
// IDENTICAL command converges on its own (rename.mjs's move-last invariant).
|
|
597
|
+
warn(
|
|
598
|
+
'Incomplete rename',
|
|
599
|
+
`a ${label} rename from '${from}' to '${to}' did not finish` +
|
|
600
|
+
(started_at ? ` (started ${started_at})` : '') +
|
|
601
|
+
` — some inbound links may already point at the new name while the ${label} itself has not moved. Re-run the identical rename to converge: ${cmd}${gitAdvisory}`,
|
|
602
|
+
);
|
|
603
|
+
return;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
if (!fromExists && toExists) {
|
|
607
|
+
// The move already landed — renameSync is one syscall, so this can only mean
|
|
608
|
+
// the process died AFTER it and BEFORE clearRenameMarker ran. Re-running the
|
|
609
|
+
// same command here would just fail (`--from` no longer resolves — see
|
|
610
|
+
// rename.mjs's own "did not resolve to a unique existing page" refusal), so
|
|
611
|
+
// suggesting a re-run (the prior behavior) handed the user a command that
|
|
612
|
+
// errors. Nothing to converge: the marker is pure leftover.
|
|
613
|
+
warn(
|
|
614
|
+
'Incomplete rename',
|
|
615
|
+
`a ${label} rename from '${from}' to '${to}' already finished (the ${label} moved) but its ` +
|
|
616
|
+
`in-progress marker was never cleared — the process likely died right after the move landed. ` +
|
|
617
|
+
`Nothing left to redo; just delete the stale marker: rm ${markerPath}${gitAdvisory}`,
|
|
618
|
+
);
|
|
619
|
+
return;
|
|
466
620
|
}
|
|
621
|
+
|
|
622
|
+
// Both paths exist, or neither does — the marker's from/to no longer map onto
|
|
623
|
+
// a determinable state (e.g. something else since recreated one of the two
|
|
624
|
+
// paths). Refuse to guess in either direction; a human needs to look.
|
|
625
|
+
warn(
|
|
626
|
+
'Incomplete rename',
|
|
627
|
+
`${markerPath} names a ${label} rename from '${from}' to '${to}', but ${
|
|
628
|
+
fromExists && toExists
|
|
629
|
+
? 'BOTH the from and to paths currently exist'
|
|
630
|
+
: 'NEITHER the from nor the to path currently exists'
|
|
631
|
+
} — cannot tell whether the rename finished. Inspect both paths by hand, then either re-run the rename or delete the marker: ${markerPath}${gitAdvisory}`,
|
|
632
|
+
);
|
|
467
633
|
}
|
|
468
634
|
|
|
469
635
|
function checkBrokenLinks(hypoDir, ignorePatterns = []) {
|
|
@@ -644,6 +810,237 @@ function checkProjectIndexAnchors(hypoDir) {
|
|
|
644
810
|
}
|
|
645
811
|
}
|
|
646
812
|
|
|
813
|
+
// The project(s) a commit touched, by which projects/<slug>/ paths it
|
|
814
|
+
// changed — a SET, because a merge or a multi-project commit can touch more
|
|
815
|
+
// than one, and `close the session` in that commit's message must then be
|
|
816
|
+
// verified against ALL of them, not silently attributed to just one.
|
|
817
|
+
// Empty when the commit touches no project path, or `git show` itself fails
|
|
818
|
+
// (a corrupt or unreachable object) — both are "can't determine scope", which
|
|
819
|
+
// this returns as *no scope at all*, not "any scope"; see markerCoversArtifact
|
|
820
|
+
// for why that direction matters.
|
|
821
|
+
function deriveCommitProjects(hypoDir, hash) {
|
|
822
|
+
// --name-status -M (not --name-only): a rename prints ONLY the destination
|
|
823
|
+
// path under --name-only ("R100 old\tnew" collapses to just "new"), so a
|
|
824
|
+
// projects/a/hot.md → projects/b/hot.md rename would silently drop project
|
|
825
|
+
// a from scope — a marker naming only "b" would then wrongly cover the
|
|
826
|
+
// whole commit. -M's tab-separated `status\told\tnew` line carries both.
|
|
827
|
+
const show = spawnSync('git', ['-C', hypoDir, 'show', '--name-status', '-M', '--format=', hash], {
|
|
828
|
+
encoding: 'utf-8',
|
|
829
|
+
});
|
|
830
|
+
if (show.status !== 0 || !show.stdout) return [];
|
|
831
|
+
const projects = new Set();
|
|
832
|
+
for (const line of show.stdout.split('\n')) {
|
|
833
|
+
if (!line.trim()) continue;
|
|
834
|
+
// status\tpath (add/modify/delete) or status\told\tnew (rename/copy) —
|
|
835
|
+
// every field after the status column is a path this commit touched.
|
|
836
|
+
const [, ...paths] = line.split('\t');
|
|
837
|
+
for (const path of paths) {
|
|
838
|
+
const m = /^projects\/([^/]+)\//.exec(path.trim());
|
|
839
|
+
// _template is the scaffold, never a real project — same exclusion the
|
|
840
|
+
// file-scan loop above applies, so a commit that happens to touch it
|
|
841
|
+
// (e.g. an init/upgrade run) doesn't manufacture a phantom project a
|
|
842
|
+
// marker can never cover.
|
|
843
|
+
if (m && m[1] !== '_template') projects.add(m[1]);
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
return [...projects];
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
// A marker only vouches for the project(s) it names, PLUS its own calendar
|
|
850
|
+
// day. Two ways that can go wrong if handled loosely, both fixed here:
|
|
851
|
+
//
|
|
852
|
+
// 1. Scope creep: a same-day close in a DIFFERENT project must not silence
|
|
853
|
+
// this one (the marker was introduced for per-session precision
|
|
854
|
+
// specifically to prevent one close from vouching for another —
|
|
855
|
+
// hooks/hypo-shared.mjs SESSION_CLOSED_MARKER_STALE_MS comment).
|
|
856
|
+
// 2. Timezone artifact: closed_at is UTC; a file-content artifact's date is
|
|
857
|
+
// local (hooks/hypo-shared.mjs localAndUtcDates() accepts both for
|
|
858
|
+
// exactly this reason).
|
|
859
|
+
//
|
|
860
|
+
// `artifact.scope.kind` is a closed set, not a free-form nullable project —
|
|
861
|
+
// an UNKNOWN scope must never default to "any marker on that date covers
|
|
862
|
+
// it": this is a warning-only, best-effort check, so the cost of a false
|
|
863
|
+
// PASS (the exact failure mode it exists to catch) outweighs the cost of a
|
|
864
|
+
// false WARN (mild noise). Concretely:
|
|
865
|
+
// • 'root-universal' — root hot.md. The real close procedure updates it on
|
|
866
|
+
// EVERY close regardless of project (closeFileTargets in
|
|
867
|
+
// hooks/hypo-shared.mjs), so date-only correlation is legitimate here,
|
|
868
|
+
// not a gap.
|
|
869
|
+
// • 'unscoped' — root session-state.md (NOT part of the real close file
|
|
870
|
+
// set — the same closeFileTargets list proves it), or a commit whose
|
|
871
|
+
// touched-project set came back empty/ambiguous. No marker can vouch for
|
|
872
|
+
// an artifact with no verifiable scope, so this never matches, ever.
|
|
873
|
+
// • 'projects' — a project file, or a commit that touched one or more
|
|
874
|
+
// identifiable projects/<slug>/ paths. EVERY named project must appear
|
|
875
|
+
// in the marker's own `projects` list.
|
|
876
|
+
function markerCoversArtifact(marker, artifact) {
|
|
877
|
+
if (!marker.dates.includes(artifact.date)) return false;
|
|
878
|
+
switch (artifact.scope.kind) {
|
|
879
|
+
case 'root-universal':
|
|
880
|
+
return true;
|
|
881
|
+
case 'projects':
|
|
882
|
+
return artifact.scope.projects.every((p) => marker.projects.includes(p));
|
|
883
|
+
default:
|
|
884
|
+
return false; // 'unscoped'
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
// Post-hoc surface for a session closed BY HAND, without the session-closed
|
|
889
|
+
// marker hard gate ever firing (the gate lives on the marker writer, and a
|
|
890
|
+
// close done by editing files directly never calls it). Warning-only: this
|
|
891
|
+
// cannot tell an actually-approved-but-ungated close from an over-close, it
|
|
892
|
+
// can only say the two signals disagree, so it never blocks.
|
|
893
|
+
//
|
|
894
|
+
// Window: 7 days for the git-log scan, matching SESSION_CLOSED_MARKER_STALE_MS
|
|
895
|
+
// in hooks/hypo-shared.mjs — a marker older than that is already treated as
|
|
896
|
+
// expired everywhere else (enforced below too, when reading the raw marker
|
|
897
|
+
// files), so correlating against a longer window would compare a close
|
|
898
|
+
// artifact against a marker doctor itself considers stale.
|
|
899
|
+
function checkSessionCloseArtifacts(hypoDir) {
|
|
900
|
+
const artifacts = [];
|
|
901
|
+
|
|
902
|
+
const scanFile = (path, scope) => {
|
|
903
|
+
if (!existsSync(path)) return;
|
|
904
|
+
let content;
|
|
905
|
+
try {
|
|
906
|
+
content = readFileSync(path, 'utf-8');
|
|
907
|
+
} catch {
|
|
908
|
+
return;
|
|
909
|
+
}
|
|
910
|
+
const result = detectSessionCloseArtifact({ path, content });
|
|
911
|
+
if (result.matched) {
|
|
912
|
+
artifacts.push({ date: result.date, scope, where: relative(hypoDir, path) });
|
|
913
|
+
}
|
|
914
|
+
};
|
|
915
|
+
|
|
916
|
+
// Root hot.md is a real close artifact of every close, project or not — see
|
|
917
|
+
// markerCoversArtifact's 'root-universal' case. Root session-state.md is
|
|
918
|
+
// NOT: the real close procedure never touches it (closeFileTargets in
|
|
919
|
+
// hooks/hypo-shared.mjs lists hot.md/log.md + the ACTIVE project's files
|
|
920
|
+
// only), so a 마감 heading appearing there has no legitimate close to
|
|
921
|
+
// attribute it to — 'unscoped' means no marker, however fresh, ever covers it.
|
|
922
|
+
scanFile(join(hypoDir, 'hot.md'), { kind: 'root-universal' });
|
|
923
|
+
scanFile(join(hypoDir, 'session-state.md'), { kind: 'unscoped' });
|
|
924
|
+
const projectsDir = join(hypoDir, 'projects');
|
|
925
|
+
if (existsSync(projectsDir)) {
|
|
926
|
+
for (const slug of readdirSync(projectsDir)) {
|
|
927
|
+
if (slug === '_template') continue;
|
|
928
|
+
const dir = join(projectsDir, slug);
|
|
929
|
+
try {
|
|
930
|
+
if (!statSync(dir).isDirectory()) continue;
|
|
931
|
+
} catch {
|
|
932
|
+
continue;
|
|
933
|
+
}
|
|
934
|
+
scanFile(join(dir, 'session-state.md'), { kind: 'projects', projects: [slug] });
|
|
935
|
+
scanFile(join(dir, 'hot.md'), { kind: 'projects', projects: [slug] });
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
if (existsSync(join(hypoDir, '.git'))) {
|
|
940
|
+
const log = spawnSync(
|
|
941
|
+
'git',
|
|
942
|
+
['-C', hypoDir, 'log', '--since=7.days.ago', '--format=%H%x1f%ad%x1f%s', '--date=short'],
|
|
943
|
+
{ encoding: 'utf-8' },
|
|
944
|
+
);
|
|
945
|
+
if (log.status === 0 && log.stdout) {
|
|
946
|
+
for (const line of log.stdout.split('\n')) {
|
|
947
|
+
if (!line.trim()) continue;
|
|
948
|
+
const [hash, date, subject] = line.split('\x1f');
|
|
949
|
+
if (detectSessionCloseArtifact({ commitMessage: subject }).matched) {
|
|
950
|
+
const projects = deriveCommitProjects(hypoDir, hash);
|
|
951
|
+
artifacts.push({
|
|
952
|
+
date,
|
|
953
|
+
scope: projects.length > 0 ? { kind: 'projects', projects } : { kind: 'unscoped' },
|
|
954
|
+
where: `commit "${subject}"`,
|
|
955
|
+
});
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
if (artifacts.length === 0) {
|
|
962
|
+
pass('Session-close artifacts', 'No close artifacts found');
|
|
963
|
+
return;
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
// Read the raw marker files directly rather than readSessionClosedMarker,
|
|
967
|
+
// which also DELETES an expired one as a side effect; a health check
|
|
968
|
+
// should not mutate state to produce a diagnosis. Staleness is still
|
|
969
|
+
// enforced here (same SESSION_CLOSED_MARKER_STALE_MS window) so an
|
|
970
|
+
// already-expired marker can't vouch for anything.
|
|
971
|
+
const cacheDir = join(hypoDir, '.cache');
|
|
972
|
+
const markers = [];
|
|
973
|
+
let cacheEntries = [];
|
|
974
|
+
if (existsSync(cacheDir)) {
|
|
975
|
+
try {
|
|
976
|
+
cacheEntries = readdirSync(cacheDir);
|
|
977
|
+
} catch {
|
|
978
|
+
// unreadable .cache/ — not this check's job to diagnose that
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
for (const file of cacheEntries) {
|
|
982
|
+
if (!file.startsWith('session-closed-') || !file.endsWith('.marker')) continue;
|
|
983
|
+
try {
|
|
984
|
+
const data = JSON.parse(readFileSync(join(cacheDir, file), 'utf-8'));
|
|
985
|
+
const ts = Date.parse(data?.closed_at || '');
|
|
986
|
+
if (!Number.isFinite(ts) || Date.now() - ts > SESSION_CLOSED_MARKER_STALE_MS) continue;
|
|
987
|
+
// ONLY the v4 `projects` array counts as project-scope evidence. A
|
|
988
|
+
// legacy flat `project` field can be a recency-derived misattribution
|
|
989
|
+
// (the P1 bug resolveCloseScope's own doc comment describes) — the
|
|
990
|
+
// runtime scope resolver already refuses to trust it uncorroborated,
|
|
991
|
+
// and doctor has no corroborating signal of its own to add, so it
|
|
992
|
+
// must refuse it too rather than re-opening the same hole standalone.
|
|
993
|
+
const projects = Array.isArray(data?.projects) ? data.projects.filter(Boolean) : [];
|
|
994
|
+
markers.push({ projects: [...new Set(projects)], dates: localAndUtcDates(new Date(ts)) });
|
|
995
|
+
} catch {
|
|
996
|
+
// corrupt marker — not this check's job to clean up
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
const unmatched = artifacts.filter(
|
|
1001
|
+
(a) => !a.date || !markers.some((m) => markerCoversArtifact(m, a)),
|
|
1002
|
+
);
|
|
1003
|
+
if (unmatched.length === 0) {
|
|
1004
|
+
pass(
|
|
1005
|
+
'Session-close artifacts',
|
|
1006
|
+
`${artifacts.length} close artifact(s), all covered by a session-closed marker`,
|
|
1007
|
+
);
|
|
1008
|
+
return;
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
const sample = unmatched
|
|
1012
|
+
.slice(0, 5)
|
|
1013
|
+
.map((a) => `${a.where}${a.date ? ` (${a.date})` : ''}`)
|
|
1014
|
+
.join(', ');
|
|
1015
|
+
const extra = unmatched.length > 5 ? ` (+${unmatched.length - 5} more)` : '';
|
|
1016
|
+
// Not necessarily UNAPPROVED — an approved log-only marker (deliberately
|
|
1017
|
+
// empty `projects`) or one scoped to a different project would ALSO land
|
|
1018
|
+
// here, since neither covers a specific project's artifact. Both readings
|
|
1019
|
+
// are worth a human look, so the wording covers both instead of asserting
|
|
1020
|
+
// the stronger, sometimes-wrong one.
|
|
1021
|
+
warn(
|
|
1022
|
+
'Session-close artifacts',
|
|
1023
|
+
`${unmatched.length} close artifact(s) with no session-closed marker covering their ` +
|
|
1024
|
+
`date and project scope — may be an unapproved hand-made close, or an approved close ` +
|
|
1025
|
+
`whose marker doesn't attribute this project: ${sample}${extra}`,
|
|
1026
|
+
);
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
/**
|
|
1030
|
+
* Render a `.cache/sync-last-success.json` record as an absolute-time note.
|
|
1031
|
+
* Deliberately no "recent"/staleness verdict — the failure-state check above
|
|
1032
|
+
* already carries the health signal; this only says what and when.
|
|
1033
|
+
*/
|
|
1034
|
+
function formatLastSuccess(lastSuccess) {
|
|
1035
|
+
const pull = lastSuccess.pull
|
|
1036
|
+
? `pull ${lastSuccess.pull.timestamp} (${lastSuccess.pull.host})`
|
|
1037
|
+
: 'pull: none recorded';
|
|
1038
|
+
const push = lastSuccess.push
|
|
1039
|
+
? `push ${lastSuccess.push.timestamp} (${lastSuccess.push.host})`
|
|
1040
|
+
: 'push: none recorded';
|
|
1041
|
+
return `${pull}; ${push}`;
|
|
1042
|
+
}
|
|
1043
|
+
|
|
647
1044
|
function checkSyncState(hypoDir) {
|
|
648
1045
|
// "open" = file exists with ≥1 entries; session-start clears once
|
|
649
1046
|
// sync is healthy again. Schema + parsing live in hooks/hypo-shared.mjs.
|
|
@@ -655,20 +1052,96 @@ function checkSyncState(hypoDir) {
|
|
|
655
1052
|
}
|
|
656
1053
|
|
|
657
1054
|
if (entries.length === 0) {
|
|
658
|
-
|
|
1055
|
+
// No unresolved failure — but that alone does not mean sync ever ran.
|
|
1056
|
+
// Read the separate, additive last-success record to tell "never synced"
|
|
1057
|
+
// apart from "healthy" and, when present, from a pull-only/push-only
|
|
1058
|
+
// history. A corrupt success file warns rather than crashing, same as the
|
|
1059
|
+
// sync-state parse-error handling above.
|
|
1060
|
+
const { data: lastSuccess, parseError: successParseError } = readSyncLastSuccess(hypoDir);
|
|
1061
|
+
if (successParseError) {
|
|
1062
|
+
warn('Sync state', 'Cannot parse .cache/sync-last-success.json — inspect manually');
|
|
1063
|
+
} else if (!lastSuccess.pull && !lastSuccess.push) {
|
|
1064
|
+
pass(
|
|
1065
|
+
'Sync state',
|
|
1066
|
+
'No unresolved sync failures — never synced (no recorded pull or push yet)',
|
|
1067
|
+
);
|
|
1068
|
+
} else {
|
|
1069
|
+
// Distinguishes pull-only / push-only / both, since formatLastSuccess
|
|
1070
|
+
// reports "none recorded" for whichever op is absent.
|
|
1071
|
+
pass(
|
|
1072
|
+
'Sync state',
|
|
1073
|
+
`No unresolved sync failures. Last success — ${formatLastSuccess(lastSuccess)}`,
|
|
1074
|
+
);
|
|
1075
|
+
}
|
|
659
1076
|
} else {
|
|
660
1077
|
const last = entries[entries.length - 1];
|
|
661
|
-
//
|
|
662
|
-
//
|
|
663
|
-
|
|
1078
|
+
// An unresolved failure does not mean the OTHER operation never
|
|
1079
|
+
// succeeded — a push failure can sit right next to a healthy pull, and
|
|
1080
|
+
// the last-success record above used to only wire into the healthy/
|
|
1081
|
+
// never-synced branch, leaving this branch silent about it. Append it
|
|
1082
|
+
// here too, best-effort, same as the healthy branch — a corrupt success
|
|
1083
|
+
// file still warns, never crashes.
|
|
1084
|
+
const { data: lastSuccess, parseError: successParseError } = readSyncLastSuccess(hypoDir);
|
|
1085
|
+
// Not "unreadable": readSyncLastSuccess also sets parseError for a file
|
|
1086
|
+
// that reads fine and parses as valid JSON but has the wrong shape (a
|
|
1087
|
+
// malformed pull/push field, or an unrecognized key) — "unreadable" would
|
|
1088
|
+
// misdescribe that case.
|
|
1089
|
+
const successSuffix = successParseError
|
|
1090
|
+
? ' (.cache/sync-last-success.json is invalid or unreadable — inspect manually)'
|
|
1091
|
+
: lastSuccess.pull || lastSuccess.push
|
|
1092
|
+
? ` Last success — ${formatLastSuccess(lastSuccess)}.`
|
|
1093
|
+
: '';
|
|
1094
|
+
// More than one open entry means the vault has been failing across
|
|
1095
|
+
// multiple sync attempts without clearing — name each one (op@timestamp),
|
|
1096
|
+
// not just the last, so the user isn't left to open sync-state.json
|
|
1097
|
+
// themselves to see the pattern. Capped like checkBrokenLinks/checkVerifyBy.
|
|
1098
|
+
const unresolvedList =
|
|
1099
|
+
entries.length > 1
|
|
1100
|
+
? ` Unresolved: ${entries
|
|
1101
|
+
.slice(-5)
|
|
1102
|
+
.map((e) => `${e.op || '?'}@${e.timestamp || '?'}`)
|
|
1103
|
+
.join(', ')}${entries.length > 5 ? ` (+${entries.length - 5} more)` : ''}.`
|
|
1104
|
+
: '';
|
|
1105
|
+
// classifySyncOp (hooks/hypo-shared.mjs) is the single judgment both this
|
|
1106
|
+
// check and hypo-session-start.mjs's syncStateNotice branch on, so the
|
|
1107
|
+
// two surfaces cannot silently diverge on WHICH op gets which treatment.
|
|
1108
|
+
// 'conflict-unresolved' — the merge --abort itself failed — is the more
|
|
1109
|
+
// dangerous of the two conflict ops: the tree may still be
|
|
1110
|
+
// half-merged (unmerged index entries, or an in-progress MERGE_HEAD), so
|
|
1111
|
+
// it gets dedicated guidance rather than the plain-conflict branch below,
|
|
1112
|
+
// which claims "your local work is committed" (not true when the abort
|
|
1113
|
+
// itself failed) and advises `git pull --no-rebase` (git would simply
|
|
1114
|
+
// refuse that mid-merge). Wording mirrors hypo-session-start.mjs's
|
|
1115
|
+
// syncStateNotice so the two surfaces never contradict each other.
|
|
1116
|
+
const cls = classifySyncOp(last.op);
|
|
1117
|
+
if (cls === 'conflict-unresolved') {
|
|
664
1118
|
warn(
|
|
665
1119
|
'Sync state',
|
|
666
|
-
`${entries.length} unresolved sync issue(s) — last: remote diverged (merge
|
|
1120
|
+
`${entries.length} unresolved sync issue(s) — last: remote diverged AND the automatic merge-abort failed; the working tree may still be half-merged (unmerged paths or an in-progress merge). Do NOT commit or push yet. Run \`git status\` first: if a merge is in progress, resolve the conflicts, then \`git add <resolved paths>\` and \`git commit\` (git refuses a commit while unmerged entries remain staged) — or run \`git merge --abort\` to discard it instead, before continuing.${unresolvedList}${successSuffix}`,
|
|
1121
|
+
);
|
|
1122
|
+
} else if (cls === 'conflict') {
|
|
1123
|
+
// A merge conflict needs a real manual merge, not a plain push/pull —
|
|
1124
|
+
// give the same explicit guidance session-start does instead of the
|
|
1125
|
+
// generic hint. This branch is unmerged-index-free by construction (the
|
|
1126
|
+
// abort succeeded), so "committed and safe" is an accurate claim here.
|
|
1127
|
+
warn(
|
|
1128
|
+
'Sync state',
|
|
1129
|
+
`${entries.length} unresolved sync issue(s) — last: remote diverged (merge conflict). Your local work is committed; the other machine's version is on the remote. Resolve with \`git pull --no-rebase\`, fix conflicts, \`git add\` them, then commit and push.${unresolvedList}${successSuffix}`,
|
|
1130
|
+
);
|
|
1131
|
+
} else if (cls === 'unknown-conflict') {
|
|
1132
|
+
// An unrecognized `conflict*` op — a future syncRemote failure mode
|
|
1133
|
+
// this check has no dedicated branch for. Neither the 'conflict'
|
|
1134
|
+
// branch's "committed" claim nor the 'conflict-unresolved' branch's
|
|
1135
|
+
// "abort failed" claim is known to hold here, so assert neither:
|
|
1136
|
+
// report the state as unknown and treat it as unresolved.
|
|
1137
|
+
warn(
|
|
1138
|
+
'Sync state',
|
|
1139
|
+
`${entries.length} unresolved sync issue(s) — last: remote diverged — an unrecognized conflict-related sync failure ('${last.op}') was recorded. Its resolution state cannot be confirmed automatically — treat it as unresolved. Run \`git status\` first to check for unmerged paths or an in-progress merge before committing or pushing.${unresolvedList}${successSuffix}`,
|
|
667
1140
|
);
|
|
668
1141
|
} else {
|
|
669
1142
|
warn(
|
|
670
1143
|
'Sync state',
|
|
671
|
-
`${entries.length} unresolved failure(s) — last: ${last.op || '?'} at ${last.timestamp || '?'}. Inspect .cache/sync-state.json or push/pull manually to clear
|
|
1144
|
+
`${entries.length} unresolved failure(s) — last: ${last.op || '?'} at ${last.timestamp || '?'}. Inspect .cache/sync-state.json or push/pull manually to clear.${unresolvedList}${successSuffix}`,
|
|
672
1145
|
);
|
|
673
1146
|
}
|
|
674
1147
|
}
|
|
@@ -766,6 +1239,8 @@ function checkCodexPaths() {
|
|
|
766
1239
|
);
|
|
767
1240
|
}
|
|
768
1241
|
|
|
1242
|
+
checkProvenanceSidecar(codexHooks, 'Codex hooks/.hypo-provenance.json');
|
|
1243
|
+
|
|
769
1244
|
const settingsPath = join(HOME, '.codex', 'settings.json');
|
|
770
1245
|
if (!existsSync(settingsPath)) {
|
|
771
1246
|
warn(
|
|
@@ -1364,6 +1839,139 @@ function checkStaleSibling() {
|
|
|
1364
1839
|
}
|
|
1365
1840
|
}
|
|
1366
1841
|
|
|
1842
|
+
// ── provenance sidecar (manual/npm channel) ────────────────────────────────────
|
|
1843
|
+
//
|
|
1844
|
+
// `.hypo-provenance.json` lives next to a standalone-copied hooks/ dir
|
|
1845
|
+
// (installHooks/applyHookFiles — scripts/lib/pkg-provenance.mjs) and is what
|
|
1846
|
+
// hooks/hypo-shared.mjs's resolvePkgRoot() falls back to when self-location
|
|
1847
|
+
// can't resolve. Absent is a normal state whenever there is no INSTALLED
|
|
1848
|
+
// hooks/hypo-shared.mjs at `hooksDir` to worry about at all (plugin channel:
|
|
1849
|
+
// hooks run straight from CLAUDE_PLUGIN_ROOT, nothing is ever copied here;
|
|
1850
|
+
// a truly fresh, never-inited home) — that case stays silent. It is also
|
|
1851
|
+
// normal, and stays silent, when hypo-shared.mjs IS installed here but
|
|
1852
|
+
// self-locates on its own (a dev checkout whose "hooksDir" happens to be the
|
|
1853
|
+
// package's own hooks/, not a copy). The one PRESENT-but-broken and the one
|
|
1854
|
+
// ABSENT-but-should-not-be-silent case are both actionable (CONCERN 4):
|
|
1855
|
+
// - present sidecar that fails to verify → WARN, same three-way shape as
|
|
1856
|
+
// checkPkgIntegrity below, never FAIL (a broken sidecar just degrades
|
|
1857
|
+
// PKG_ROOT to null, surfaced live by hypo-session-start.mjs's
|
|
1858
|
+
// PKG_ROOT-null banner, not corrupting anything on disk)
|
|
1859
|
+
// - installed hypo-shared.mjs, self-location fails for it, AND no sidecar
|
|
1860
|
+
// at all → that combination IS "PKG_ROOT is null for every hook running
|
|
1861
|
+
// from here" (resolvePkgRoot() has nothing left to try), so silence here
|
|
1862
|
+
// would hide exactly the state the live banner already warns about
|
|
1863
|
+
function checkProvenanceSidecar(hooksDir, label) {
|
|
1864
|
+
const sidecarPath = provenancePath(hooksDir);
|
|
1865
|
+
if (!existsSync(sidecarPath)) {
|
|
1866
|
+
if (existsSync(join(hooksDir, 'hypo-shared.mjs')) && !selfLocationPkgRootFrom(hooksDir)) {
|
|
1867
|
+
warn(
|
|
1868
|
+
label,
|
|
1869
|
+
`no provenance sidecar, and this install's hooks cannot self-locate their ` +
|
|
1870
|
+
`package — PKG_ROOT resolves to null for every hook running from ${hooksDir}; ` +
|
|
1871
|
+
`run \`hypomnema upgrade --apply\` to write one`,
|
|
1872
|
+
);
|
|
1873
|
+
}
|
|
1874
|
+
return;
|
|
1875
|
+
}
|
|
1876
|
+
|
|
1877
|
+
let sidecar;
|
|
1878
|
+
try {
|
|
1879
|
+
sidecar = JSON.parse(readFileSync(sidecarPath, 'utf-8'));
|
|
1880
|
+
} catch {
|
|
1881
|
+
warn(
|
|
1882
|
+
label,
|
|
1883
|
+
`${sidecarPath} is not valid JSON — run \`hypomnema upgrade --apply\` to rewrite it`,
|
|
1884
|
+
);
|
|
1885
|
+
return;
|
|
1886
|
+
}
|
|
1887
|
+
|
|
1888
|
+
const { pkgRoot, hypoSharedSha256, [HOOKS_DIGEST_FIELD]: hooksDigest } = sidecar || {};
|
|
1889
|
+
|
|
1890
|
+
// Same predicate the runtime resolver requires (isUsablePkgRootLocal, used
|
|
1891
|
+
// by readVerifiedProvenancePkgRoot in hooks/hypo-shared.mjs) — imported,
|
|
1892
|
+
// not hand-rolled again here, so a version-less "name":"hypomnema" root can
|
|
1893
|
+
// no longer PASS this check while resolvePkgRoot() treats it as null.
|
|
1894
|
+
const usableOk = isUsablePkgRootLocal(pkgRoot);
|
|
1895
|
+
|
|
1896
|
+
let producerName = null;
|
|
1897
|
+
try {
|
|
1898
|
+
producerName = JSON.parse(readFileSync(join(pkgRoot, 'package.json'), 'utf-8')).name;
|
|
1899
|
+
} catch {
|
|
1900
|
+
/* pkgRoot missing/unreadable — nameOk stays false below */
|
|
1901
|
+
}
|
|
1902
|
+
const nameOk = producerName === EXPECTED_PKG_NAME;
|
|
1903
|
+
|
|
1904
|
+
let hashOk = false;
|
|
1905
|
+
try {
|
|
1906
|
+
hashOk = sha256(readFileSync(join(hooksDir, 'hypo-shared.mjs'))) === hypoSharedSha256;
|
|
1907
|
+
} catch {
|
|
1908
|
+
/* hooks/hypo-shared.mjs unreadable in its own hooks dir — hashOk stays false */
|
|
1909
|
+
}
|
|
1910
|
+
|
|
1911
|
+
if (usableOk && nameOk && hashOk) {
|
|
1912
|
+
// hooksDigest (BLOCKER A item 3) can still turn this into a warn: it is
|
|
1913
|
+
// folded into the SAME label/check rather than pushed as a second entry,
|
|
1914
|
+
// so a caller reading checks by label (every other check in this file,
|
|
1915
|
+
// and every test asserting on one) sees exactly one verdict per sidecar.
|
|
1916
|
+
const digestIssue = hooksDigestMismatch(hooksDir, hooksDigest);
|
|
1917
|
+
if (!digestIssue) {
|
|
1918
|
+
pass(label, `verified — resolves to ${pkgRoot}`);
|
|
1919
|
+
} else {
|
|
1920
|
+
warn(label, `verified (pkgRoot/hash), but ${digestIssue}`);
|
|
1921
|
+
}
|
|
1922
|
+
return;
|
|
1923
|
+
}
|
|
1924
|
+
const reasons = [];
|
|
1925
|
+
if (!usableOk) {
|
|
1926
|
+
reasons.push(
|
|
1927
|
+
`recorded pkgRoot (${pkgRoot}) is not a usable package root (must be an absolute ` +
|
|
1928
|
+
`path to a directory whose package.json carries a version)`,
|
|
1929
|
+
);
|
|
1930
|
+
}
|
|
1931
|
+
if (!nameOk) reasons.push(`recorded pkgRoot (${pkgRoot}) is not this package`);
|
|
1932
|
+
if (!hashOk)
|
|
1933
|
+
reasons.push('recorded hypoSharedSha256 does not match the installed hypo-shared.mjs');
|
|
1934
|
+
warn(
|
|
1935
|
+
label,
|
|
1936
|
+
`${sidecarPath} does not verify (${reasons.join('; ')}) — resolvePkgRoot() will treat ` +
|
|
1937
|
+
`PKG_ROOT as unresolved this session; run \`hypomnema upgrade --apply\` to refresh it`,
|
|
1938
|
+
);
|
|
1939
|
+
}
|
|
1940
|
+
|
|
1941
|
+
// BLOCKER A item 3: the runtime SHA check above (hashOk) pins ONE file,
|
|
1942
|
+
// hypo-shared.mjs — see that function's own hooks/hypo-shared.mjs-side
|
|
1943
|
+
// comment for why. This pins every OTHER file hooks.json wires up too, once
|
|
1944
|
+
// per `hypomnema doctor` run rather than once per hook load (too expensive
|
|
1945
|
+
// to do there — see computeHooksDigest's doc comment in
|
|
1946
|
+
// scripts/lib/pkg-provenance.mjs). Skips silently when the sidecar predates
|
|
1947
|
+
// this field (an older init/upgrade wrote it before BLOCKER A existed) —
|
|
1948
|
+
// there is nothing recorded to compare against, and the runtime never reads
|
|
1949
|
+
// this field either, so staying quiet hides nothing the runtime already
|
|
1950
|
+
// trusts.
|
|
1951
|
+
//
|
|
1952
|
+
// Returns null when there is nothing to report (field absent, or digests
|
|
1953
|
+
// match), or a ready-to-append reason string naming a few diverged files.
|
|
1954
|
+
function hooksDigestMismatch(hooksDir, recordedDigest) {
|
|
1955
|
+
if (typeof recordedDigest !== 'string' || !recordedDigest) return null;
|
|
1956
|
+
const actualDigest = computeHooksDigest(PKG_ROOT, hooksDir);
|
|
1957
|
+
if (actualDigest === null || actualDigest === recordedDigest) return null;
|
|
1958
|
+
|
|
1959
|
+
const allFiles = [...Object.values(HOOK_MAP).flat(), ...SHARED_FILES];
|
|
1960
|
+
const diverged = allFiles.filter((file) => {
|
|
1961
|
+
try {
|
|
1962
|
+
return !readFileSync(join(hooksDir, file)).equals(readFileSync(join(HOOKS_SRC, file)));
|
|
1963
|
+
} catch {
|
|
1964
|
+
return true; // unreadable on either side counts as diverged
|
|
1965
|
+
}
|
|
1966
|
+
});
|
|
1967
|
+
const examples = diverged.slice(0, 3).join(', ');
|
|
1968
|
+
return (
|
|
1969
|
+
`hooksDigest mismatch: ${diverged.length}/${allFiles.length} hook file(s) differ from ` +
|
|
1970
|
+
`the current package source${examples ? ` (e.g. ${examples})` : ''} — run ` +
|
|
1971
|
+
`\`hypomnema upgrade --apply\``
|
|
1972
|
+
);
|
|
1973
|
+
}
|
|
1974
|
+
|
|
1367
1975
|
// ── package integrity ─────────────────────────────────────────────────────────
|
|
1368
1976
|
//
|
|
1369
1977
|
// ~/.claude/hypo-pkg.json is a snapshot written once by init/upgrade and never
|
|
@@ -1491,6 +2099,7 @@ if (rootOk) {
|
|
|
1491
2099
|
checkFiles(args.hypoDir);
|
|
1492
2100
|
checkScanIgnoreFile(args.hypoDir);
|
|
1493
2101
|
checkBrokenLinks(args.hypoDir, ignorePatterns);
|
|
2102
|
+
checkIncompleteRename(args.hypoDir);
|
|
1494
2103
|
checkVerifyBy(args.hypoDir, ignorePatterns);
|
|
1495
2104
|
}
|
|
1496
2105
|
checkHooks(coreManagedByPlugin);
|
|
@@ -1501,6 +2110,7 @@ if (args.codex) checkCodexPaths();
|
|
|
1501
2110
|
if (rootOk) checkExtensions(args.hypoDir, args.claudeHome, 'claude');
|
|
1502
2111
|
if (rootOk && args.codex) checkExtensions(args.hypoDir, args.claudeHome, 'codex');
|
|
1503
2112
|
if (rootOk) checkProjectIndexAnchors(args.hypoDir);
|
|
2113
|
+
if (rootOk) checkSessionCloseArtifacts(args.hypoDir);
|
|
1504
2114
|
if (rootOk) checkSyncState(args.hypoDir);
|
|
1505
2115
|
if (rootOk) checkProjectSuggestions(args.hypoDir);
|
|
1506
2116
|
if (rootOk) checkProposals(args.hypoDir);
|