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.
@@ -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
- const preCommitPath = join(hypoDir, '.git', 'hooks', 'pre-commit');
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
- const content = readFileSync(preCommitPath, 'utf-8');
456
- if (content.includes('# hypo-managed:pre-commit:start')) {
457
- pass('.git/hooks/pre-commit', 'Hypomnema .hypoignore guard installed');
458
- } else {
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('.git/hooks/pre-commit', 'Not installed — run /hypo:init to install .hypoignore guard');
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
- pass('Sync state', 'No unresolved sync failures');
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
- // A merge conflict needs a real manual merge, not a plain push/pull — give
662
- // the same explicit guidance session-start does instead of the generic hint.
663
- if (String(last.op || '').startsWith('conflict')) {
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 conflict). Your local work is committed; the other machine's version is on the remote. Resolve with \`git pull --no-rebase\`, fix conflicts, then push.`,
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);