hypomnema 1.6.2 → 1.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.ko.md +39 -14
  4. package/README.md +39 -14
  5. package/commands/capture.md +8 -6
  6. package/commands/crystallize.md +39 -20
  7. package/docs/ARCHITECTURE.md +49 -14
  8. package/docs/CONTRIBUTING.md +31 -29
  9. package/hooks/base-store.mjs +265 -0
  10. package/hooks/hooks.json +18 -1
  11. package/hooks/hypo-auto-commit.mjs +92 -15
  12. package/hooks/hypo-auto-minimal-crystallize.mjs +63 -20
  13. package/hooks/hypo-auto-stage.mjs +41 -1
  14. package/hooks/hypo-close-guard.mjs +246 -0
  15. package/hooks/hypo-cwd-change.mjs +31 -2
  16. package/hooks/hypo-file-watch.mjs +21 -2
  17. package/hooks/hypo-first-prompt.mjs +19 -3
  18. package/hooks/hypo-hot-rebuild.mjs +43 -5
  19. package/hooks/hypo-lookup.mjs +86 -29
  20. package/hooks/hypo-personal-check.mjs +24 -3
  21. package/hooks/hypo-session-record.mjs +2 -3
  22. package/hooks/hypo-session-start.mjs +199 -20
  23. package/hooks/hypo-shared.mjs +2080 -176
  24. package/hooks/proposal-store.mjs +513 -0
  25. package/hooks/version-check.mjs +45 -0
  26. package/package.json +42 -14
  27. package/scripts/capture.mjs +556 -37
  28. package/scripts/crystallize.mjs +751 -110
  29. package/scripts/doctor.mjs +787 -26
  30. package/scripts/feedback-sync.mjs +515 -44
  31. package/scripts/graph.mjs +35 -13
  32. package/scripts/init.mjs +287 -41
  33. package/scripts/lib/extensions.mjs +656 -1
  34. package/scripts/lib/git-hooks-dir.mjs +229 -0
  35. package/scripts/lib/hypo-ignore.mjs +54 -6
  36. package/scripts/lib/hypo-root.mjs +56 -6
  37. package/scripts/lib/page-usage.mjs +15 -2
  38. package/scripts/lib/pkg-json.mjs +40 -0
  39. package/scripts/lib/plugin-detect.mjs +96 -6
  40. package/scripts/lib/project-create.mjs +5 -1
  41. package/scripts/lib/rename-marker.mjs +39 -0
  42. package/scripts/lib/wd-match.mjs +23 -5
  43. package/scripts/lib/wikilink.mjs +32 -6
  44. package/scripts/lint.mjs +103 -5
  45. package/scripts/proposal.mjs +1032 -0
  46. package/scripts/query.mjs +25 -4
  47. package/scripts/rename.mjs +223 -18
  48. package/scripts/resume.mjs +34 -12
  49. package/scripts/stats.mjs +41 -9
  50. package/scripts/uninstall.mjs +141 -6
  51. package/scripts/upgrade.mjs +197 -15
  52. package/skills/crystallize/SKILL.md +44 -7
  53. package/skills/debate/SKILL.md +88 -0
  54. package/skills/debate/references/orchestration-patterns.md +83 -0
  55. package/templates/.hyposcanignore +10 -0
  56. package/templates/SCHEMA.md +12 -0
  57. package/templates/gitignore +9 -0
  58. package/templates/hypo-config.md +1 -1
  59. package/templates/hypo-guide.md +6 -0
  60. package/scripts/.gitkeep +0 -0
  61. package/scripts/check-bilingual.mjs +0 -153
  62. package/scripts/check-readme-version.mjs +0 -126
  63. package/scripts/check-tracker-ids.mjs +0 -426
  64. package/scripts/check-versions.mjs +0 -171
  65. package/scripts/install-git-hooks.mjs +0 -293
  66. package/scripts/lib/changelog-classify.mjs +0 -216
  67. package/scripts/lib/check-bilingual.mjs +0 -244
  68. package/scripts/lib/check-tracker-ids.mjs +0 -217
  69. package/scripts/lib/pre-commit-format.mjs +0 -251
  70. package/scripts/pre-commit-format.mjs +0 -198
@@ -13,18 +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,
27
34
  } from '../hooks/hypo-shared.mjs';
35
+ import { listProposals } from '../hooks/proposal-store.mjs';
28
36
  import {
29
37
  discoverExtensions,
30
38
  parseManifest,
@@ -35,16 +43,40 @@ import {
35
43
  resolveInstallFile,
36
44
  buildHookCommand,
37
45
  parseExtKey,
46
+ parseSkillKey,
47
+ parseSkillShaValue,
48
+ isFlatShaValue,
38
49
  EXT_TYPES,
39
50
  CODEX_TYPES,
40
51
  } from './lib/extensions.mjs';
41
52
  import { sha256, readFileIfRegular, readPkgJson } from './lib/pkg-json.mjs';
42
53
  import { resolveCliOnPath, classifyInstall } from '../hooks/version-check.mjs';
54
+ import { isHypomnemaPluginEnabled } from './lib/plugin-detect.mjs';
43
55
 
44
56
  const HOME = homedir();
45
57
  const SCRIPT_DIR = fileURLToPath(new URL('.', import.meta.url));
46
58
  const PKG_ROOT = join(SCRIPT_DIR, '..');
47
59
 
60
+ // ── install channel ───────────────────────────────────────────────────────────
61
+ //
62
+ // A plugin-channel install registers its 14 core hooks straight out of the
63
+ // package's own hooks/hooks.json (CLAUDE_PLUGIN_ROOT) — Claude Code auto-wires
64
+ // them, they are never copied into ~/.claude/hooks and never listed in
65
+ // ~/.claude/settings.json. checkHooks/checkSettingsJson used to treat that
66
+ // empty state as "missing" and prescribe `/hypo:init`, which then copied +
67
+ // registered the very same hooks a second time (every hook firing twice).
68
+ //
69
+ // `pluginMode` mirrors upgrade.mjs's own detector: this doctor.mjs is itself
70
+ // running from a `.claude/plugins/…` root. `hypomnemaPluginEnabled` catches the
71
+ // dual-install variant — a manual/npm doctor.mjs run while the plugin is ALSO
72
+ // enabled in settings.json (see lib/plugin-detect.mjs; fails open on any
73
+ // uncertainty). Either signal means Claude's core hook surface is
74
+ // plugin-managed, not missing.
75
+ const pluginMode = PKG_ROOT.replace(/\\/g, '/').includes('/.claude/plugins/');
76
+ const hypomnemaPluginEnabled =
77
+ !pluginMode && isHypomnemaPluginEnabled(join(HOME, '.claude', 'settings.json'));
78
+ const coreManagedByPlugin = pluginMode || hypomnemaPluginEnabled;
79
+
48
80
  // Shown after every fatal package-integrity error. These conditions mean the
49
81
  // shipped hooks/hooks.json is missing or malformed — never a user mistake —
50
82
  // so the only useful next step is a re-install of the package.
@@ -255,7 +287,17 @@ function checkFiles(hypoDir) {
255
287
  }
256
288
  }
257
289
 
258
- function checkHooks() {
290
+ // .hyposcanignore is optional (scan-only exclusions, not a privacy boundary),
291
+ // so its absence is info-level — pass either way, never warn/fail.
292
+ function checkScanIgnoreFile(hypoDir) {
293
+ const present = existsSync(join(hypoDir, '.hyposcanignore'));
294
+ pass(
295
+ 'File: .hyposcanignore',
296
+ present ? 'present' : 'optional — not present, no scan-only exclusions configured',
297
+ );
298
+ }
299
+
300
+ function checkHooks(coreManagedByPlugin) {
259
301
  const claudeHooks = join(HOME, '.claude', 'hooks');
260
302
  const allFiles = [...Object.values(HOOK_MAP).flat(), ...SHARED_FILES];
261
303
 
@@ -266,6 +308,13 @@ function checkHooks() {
266
308
 
267
309
  if (missing === 0) {
268
310
  pass('Hook files installed', claudeHooks);
311
+ } else if (coreManagedByPlugin && missing === allFiles.length) {
312
+ // Plugin channel: an empty ~/.claude/hooks is the expected, healthy state
313
+ // (the plugin loader provides the hooks), not a missing install.
314
+ pass(
315
+ 'Hook files installed',
316
+ `provided by the plugin loader (hooks/hooks.json) — none copied to ${claudeHooks} (expected)`,
317
+ );
269
318
  } else if (missing < allFiles.length) {
270
319
  warn('Hook files installed', `${missing}/${allFiles.length} missing in ${claudeHooks}`);
271
320
  } else {
@@ -273,10 +322,17 @@ function checkHooks() {
273
322
  }
274
323
  }
275
324
 
276
- function checkSettingsJson() {
325
+ function checkSettingsJson(coreManagedByPlugin) {
277
326
  const settingsPath = join(HOME, '.claude', 'settings.json');
278
327
  if (!existsSync(settingsPath)) {
279
- warn('settings.json hook registrations', 'settings.json not found');
328
+ if (coreManagedByPlugin) {
329
+ pass(
330
+ 'settings.json hook registrations',
331
+ 'provided by the plugin loader (hooks/hooks.json) — settings.json entries not required',
332
+ );
333
+ } else {
334
+ warn('settings.json hook registrations', 'settings.json not found');
335
+ }
280
336
  return;
281
337
  }
282
338
 
@@ -310,6 +366,13 @@ function checkSettingsJson() {
310
366
  'settings.json hook registrations',
311
367
  `${registered}/${total} registered — run /hypo:init to merge missing entries`,
312
368
  );
369
+ } else if (coreManagedByPlugin) {
370
+ // Same reasoning as checkHooks: the plugin loader never touches settings.json,
371
+ // so 0/total here is the expected plugin-channel state, not a missing install.
372
+ pass(
373
+ 'settings.json hook registrations',
374
+ `0/${total} registered in settings.json — provided by the plugin loader (hooks/hooks.json) instead (expected)`,
375
+ );
313
376
  } else {
314
377
  fail('settings.json hook registrations', `0/${total} registered — run /hypo:init`);
315
378
  }
@@ -394,20 +457,168 @@ function checkGit(hypoDir) {
394
457
  warn('Git remote origin', 'No remote configured — wiki will not sync/backup automatically');
395
458
  }
396
459
 
397
- const preCommitPath = join(hypoDir, '.git', 'hooks', 'pre-commit');
460
+ // Ask git for the ACTIVE hooks dir rather than assuming <repo>/.git/hooks —
461
+ // that guess is wrong in a linked worktree (`.git` is a file) and whenever
462
+ // core.hooksPath is set, and reporting "not installed" for either would be a
463
+ // false negative. Read-side policy differs from the installer's: doctor
464
+ // reports a hooks dir it would refuse to WRITE into, so the user can see it.
465
+ const resolved = resolveGitHooksDir(hypoDir);
466
+ if (!resolved.ok) {
467
+ const detail =
468
+ resolved.reason === 'hooks-disabled'
469
+ ? `Hooks path is not a directory (${resolved.path}) — git runs no hooks, so the .hypoignore guard cannot run`
470
+ : `Could not resolve the git hooks directory (${resolved.detail || resolved.reason})`;
471
+ warn('git hooks/pre-commit', detail);
472
+ return;
473
+ }
474
+
475
+ const preCommitPath = join(resolved.path, 'pre-commit');
476
+ const label = resolved.owned ? 'git hooks/pre-commit' : `pre-commit (${resolved.path})`;
477
+ let content = null;
478
+ let unreadable = null;
398
479
  if (existsSync(preCommitPath)) {
399
- const content = readFileSync(preCommitPath, 'utf-8');
400
- if (content.includes('# hypo-managed:pre-commit:start')) {
401
- pass('.git/hooks/pre-commit', 'Hypomnema .hypoignore guard installed');
402
- } else {
403
- warn(
404
- '.git/hooks/pre-commit',
405
- 'Exists but not managed by Hypomnema — manual git add can bypass .hypoignore',
406
- );
480
+ try {
481
+ content = readFileSync(preCommitPath, 'utf-8');
482
+ } catch (e) {
483
+ unreadable = e.code || e.message;
407
484
  }
485
+ }
486
+
487
+ if (unreadable) {
488
+ warn(label, `Exists but could not be read (${unreadable})`);
489
+ } else if (content === null) {
490
+ // Telling the user to run /hypo:init here would contradict the ownership
491
+ // warning below, which says it will refuse this very path.
492
+ warn(
493
+ label,
494
+ resolved.owned
495
+ ? 'Not installed — run /hypo:init to install .hypoignore guard'
496
+ : 'Not installed, and /hypo:init will not install into this path — point core.hooksPath back inside the repository, or install the guard yourself',
497
+ );
498
+ } else if (content.includes('# hypo-managed:pre-commit:start')) {
499
+ pass(label, 'Hypomnema .hypoignore guard installed');
408
500
  } else {
409
- warn('.git/hooks/pre-commit', 'Not installed — run /hypo:init to install .hypoignore guard');
501
+ warn(label, 'Exists but not managed by Hypomnema — manual git add can bypass .hypoignore');
502
+ }
503
+
504
+ // Reported even when the hook above was unreadable — an unreadable hook is
505
+ // exactly when knowing WHERE git looks matters most.
506
+ if (!resolved.owned) {
507
+ warn(
508
+ 'core.hooksPath',
509
+ `Points outside this repository (${resolved.path}) — /hypo:init will not install there`,
510
+ );
511
+ }
512
+ }
513
+
514
+ // rename.mjs --apply writes .cache/rename-in-progress.json before its first
515
+ // inbound-link rewrite and removes it only after the terminal move lands (see
516
+ // rename.mjs's writeRenameMarker/clearRenameMarker). A marker still present
517
+ // means the process died mid-run: some inbound wikilinks may already point at
518
+ // `to` while the page/directory itself still sits at `from`. checkBrokenLinks
519
+ // below would just report those as generic broken links (W4's doctor
520
+ // counterpart) with no clue why — this names the cause and the exact fix.
521
+ // Re-running the SAME command converges on its own (the module docstring's
522
+ // move-last invariant): nothing to merge or roll back by hand.
523
+ // Best-effort: does this vault's git repo leave `markerPath` un-ignored (either
524
+ // already tracked, or plain untracked-and-stageable)? `git check-ignore` answers
525
+ // both at once — a tracked file is also reported "not ignored" by it, since
526
+ // .gitignore rules don't retroactively apply to tracked paths. Fails CLOSED to
527
+ // "don't know, say nothing": no `.git`, no git on PATH, or any non-0/1 exit
528
+ // (128 = not a repo / path outside it / other error) all return false, because
529
+ // this is advisory-only and a wrong guess is worse than silence (codex CONCERN 3
530
+ // — never turn an inconclusive git query into an error of its own).
531
+ function markerNotGitIgnored(hypoDir, markerPath) {
532
+ if (!existsSync(join(hypoDir, '.git'))) return false;
533
+ const rel = relative(hypoDir, markerPath);
534
+ const r = spawnSync('git', ['-C', hypoDir, 'check-ignore', '-q', rel], { encoding: 'utf-8' });
535
+ if (r.error || r.status === null) return false;
536
+ if (r.status !== 0 && r.status !== 1) return false; // 128 etc — inconclusive
537
+ return r.status === 1; // check-ignore's "not ignored" exit code
538
+ }
539
+
540
+ function checkIncompleteRename(hypoDir) {
541
+ // "no marker" and "a marker we cannot read" are different answers and must not
542
+ // collapse. readRenameMarker returns null for both, so ask the filesystem
543
+ // which one it is: a present-but-unreadable marker still means a rename did
544
+ // not finish, and reporting that as a pass would make the detector fail open
545
+ // on exactly the case it exists for.
546
+ const markerPath = renameMarkerPath(hypoDir);
547
+ const present = existsSync(markerPath);
548
+ if (!present) {
549
+ pass('Incomplete rename', 'No rename-in-progress marker found');
550
+ return;
551
+ }
552
+
553
+ // codex CONCERN 3: a legacy/custom vault whose .gitignore predates
554
+ // templates/gitignore's `.cache/` entry (init never edits an existing
555
+ // .gitignore) can let `git add -A` commit this marker, producing a ghost
556
+ // warning on another machine that never ran a rename at all. Advisory only —
557
+ // never turns the rename itself into an error — appended to every branch
558
+ // below that reports on a present marker.
559
+ const gitAdvisory = markerNotGitIgnored(hypoDir, markerPath)
560
+ ? ` 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.`
561
+ : '';
562
+
563
+ const marker = readRenameMarker(hypoDir);
564
+ if (!marker || !marker.from || !marker.to) {
565
+ warn(
566
+ 'Incomplete rename',
567
+ `${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}`,
568
+ );
569
+ return;
570
+ }
571
+
572
+ // codex BLOCKER 2: the marker alone cannot distinguish a genuinely stuck
573
+ // rename from one that finished right before the process died — rename.mjs's
574
+ // move (renameSync) is the terminal step, and the marker is only cleared
575
+ // AFTER it. Cross-check `from`/`to` (vault-root-relative) against the
576
+ // filesystem to tell the three reachable states apart.
577
+ const { mode, from, to, started_at } = marker;
578
+ const label = mode === 'directory' ? 'directory' : 'page';
579
+ const fromExists = existsSync(join(hypoDir, from));
580
+ const toExists = existsSync(join(hypoDir, to));
581
+ const cmd = `node ${join(PKG_ROOT, 'scripts', 'rename.mjs')} --hypo-dir=${hypoDir} --from=${from} --to=${to} --apply`;
582
+
583
+ if (fromExists && !toExists) {
584
+ // The real in-progress case: --from still resolves, so re-running the
585
+ // IDENTICAL command converges on its own (rename.mjs's move-last invariant).
586
+ warn(
587
+ 'Incomplete rename',
588
+ `a ${label} rename from '${from}' to '${to}' did not finish` +
589
+ (started_at ? ` (started ${started_at})` : '') +
590
+ ` — 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}`,
591
+ );
592
+ return;
593
+ }
594
+
595
+ if (!fromExists && toExists) {
596
+ // The move already landed — renameSync is one syscall, so this can only mean
597
+ // the process died AFTER it and BEFORE clearRenameMarker ran. Re-running the
598
+ // same command here would just fail (`--from` no longer resolves — see
599
+ // rename.mjs's own "did not resolve to a unique existing page" refusal), so
600
+ // suggesting a re-run (the prior behavior) handed the user a command that
601
+ // errors. Nothing to converge: the marker is pure leftover.
602
+ warn(
603
+ 'Incomplete rename',
604
+ `a ${label} rename from '${from}' to '${to}' already finished (the ${label} moved) but its ` +
605
+ `in-progress marker was never cleared — the process likely died right after the move landed. ` +
606
+ `Nothing left to redo; just delete the stale marker: rm ${markerPath}${gitAdvisory}`,
607
+ );
608
+ return;
410
609
  }
610
+
611
+ // Both paths exist, or neither does — the marker's from/to no longer map onto
612
+ // a determinable state (e.g. something else since recreated one of the two
613
+ // paths). Refuse to guess in either direction; a human needs to look.
614
+ warn(
615
+ 'Incomplete rename',
616
+ `${markerPath} names a ${label} rename from '${from}' to '${to}', but ${
617
+ fromExists && toExists
618
+ ? 'BOTH the from and to paths currently exist'
619
+ : 'NEITHER the from nor the to path currently exists'
620
+ } — cannot tell whether the rename finished. Inspect both paths by hand, then either re-run the rename or delete the marker: ${markerPath}${gitAdvisory}`,
621
+ );
411
622
  }
412
623
 
413
624
  function checkBrokenLinks(hypoDir, ignorePatterns = []) {
@@ -588,6 +799,239 @@ function checkProjectIndexAnchors(hypoDir) {
588
799
  }
589
800
  }
590
801
 
802
+ // The project(s) a commit touched, by which projects/<slug>/ paths it
803
+ // changed — a SET, because a merge or a multi-project commit can touch more
804
+ // than one, and `close the session` in that commit's message must then be
805
+ // verified against ALL of them, not silently attributed to just one.
806
+ // Empty when the commit touches no project path, or `git show` itself fails
807
+ // (a corrupt or unreachable object) — both are "can't determine scope", which
808
+ // this returns as *no scope at all*, not "any scope"; see markerCoversArtifact
809
+ // for why that direction matters.
810
+ function deriveCommitProjects(hypoDir, hash) {
811
+ // --name-status -M (not --name-only): a rename prints ONLY the destination
812
+ // path under --name-only ("R100 old\tnew" collapses to just "new"), so a
813
+ // projects/a/hot.md → projects/b/hot.md rename would silently drop project
814
+ // a from scope — a marker naming only "b" would then wrongly cover the
815
+ // whole commit. -M's tab-separated `status\told\tnew` line carries both.
816
+ const show = spawnSync(
817
+ 'git',
818
+ ['-C', hypoDir, 'show', '--name-status', '-M', '--format=', hash],
819
+ { encoding: 'utf-8' },
820
+ );
821
+ if (show.status !== 0 || !show.stdout) return [];
822
+ const projects = new Set();
823
+ for (const line of show.stdout.split('\n')) {
824
+ if (!line.trim()) continue;
825
+ // status\tpath (add/modify/delete) or status\told\tnew (rename/copy) —
826
+ // every field after the status column is a path this commit touched.
827
+ const [, ...paths] = line.split('\t');
828
+ for (const path of paths) {
829
+ const m = /^projects\/([^/]+)\//.exec(path.trim());
830
+ // _template is the scaffold, never a real project — same exclusion the
831
+ // file-scan loop above applies, so a commit that happens to touch it
832
+ // (e.g. an init/upgrade run) doesn't manufacture a phantom project a
833
+ // marker can never cover.
834
+ if (m && m[1] !== '_template') projects.add(m[1]);
835
+ }
836
+ }
837
+ return [...projects];
838
+ }
839
+
840
+ // A marker only vouches for the project(s) it names, PLUS its own calendar
841
+ // day. Two ways that can go wrong if handled loosely, both fixed here:
842
+ //
843
+ // 1. Scope creep: a same-day close in a DIFFERENT project must not silence
844
+ // this one (the marker was introduced for per-session precision
845
+ // specifically to prevent one close from vouching for another —
846
+ // hooks/hypo-shared.mjs SESSION_CLOSED_MARKER_STALE_MS comment).
847
+ // 2. Timezone artifact: closed_at is UTC; a file-content artifact's date is
848
+ // local (hooks/hypo-shared.mjs localAndUtcDates() accepts both for
849
+ // exactly this reason).
850
+ //
851
+ // `artifact.scope.kind` is a closed set, not a free-form nullable project —
852
+ // an UNKNOWN scope must never default to "any marker on that date covers
853
+ // it": this is a warning-only, best-effort check, so the cost of a false
854
+ // PASS (the exact failure mode it exists to catch) outweighs the cost of a
855
+ // false WARN (mild noise). Concretely:
856
+ // • 'root-universal' — root hot.md. The real close procedure updates it on
857
+ // EVERY close regardless of project (closeFileTargets in
858
+ // hooks/hypo-shared.mjs), so date-only correlation is legitimate here,
859
+ // not a gap.
860
+ // • 'unscoped' — root session-state.md (NOT part of the real close file
861
+ // set — the same closeFileTargets list proves it), or a commit whose
862
+ // touched-project set came back empty/ambiguous. No marker can vouch for
863
+ // an artifact with no verifiable scope, so this never matches, ever.
864
+ // • 'projects' — a project file, or a commit that touched one or more
865
+ // identifiable projects/<slug>/ paths. EVERY named project must appear
866
+ // in the marker's own `projects` list.
867
+ function markerCoversArtifact(marker, artifact) {
868
+ if (!marker.dates.includes(artifact.date)) return false;
869
+ switch (artifact.scope.kind) {
870
+ case 'root-universal':
871
+ return true;
872
+ case 'projects':
873
+ return artifact.scope.projects.every((p) => marker.projects.includes(p));
874
+ default:
875
+ return false; // 'unscoped'
876
+ }
877
+ }
878
+
879
+ // Post-hoc surface for a session closed BY HAND, without the session-closed
880
+ // marker hard gate ever firing (the gate lives on the marker writer, and a
881
+ // close done by editing files directly never calls it). Warning-only: this
882
+ // cannot tell an actually-approved-but-ungated close from an over-close, it
883
+ // can only say the two signals disagree, so it never blocks.
884
+ //
885
+ // Window: 7 days for the git-log scan, matching SESSION_CLOSED_MARKER_STALE_MS
886
+ // in hooks/hypo-shared.mjs — a marker older than that is already treated as
887
+ // expired everywhere else (enforced below too, when reading the raw marker
888
+ // files), so correlating against a longer window would compare a close
889
+ // artifact against a marker doctor itself considers stale.
890
+ function checkSessionCloseArtifacts(hypoDir) {
891
+ const artifacts = [];
892
+
893
+ const scanFile = (path, scope) => {
894
+ if (!existsSync(path)) return;
895
+ let content;
896
+ try {
897
+ content = readFileSync(path, 'utf-8');
898
+ } catch {
899
+ return;
900
+ }
901
+ const result = detectSessionCloseArtifact({ path, content });
902
+ if (result.matched) {
903
+ artifacts.push({ date: result.date, scope, where: relative(hypoDir, path) });
904
+ }
905
+ };
906
+
907
+ // Root hot.md is a real close artifact of every close, project or not — see
908
+ // markerCoversArtifact's 'root-universal' case. Root session-state.md is
909
+ // NOT: the real close procedure never touches it (closeFileTargets in
910
+ // hooks/hypo-shared.mjs lists hot.md/log.md + the ACTIVE project's files
911
+ // only), so a 마감 heading appearing there has no legitimate close to
912
+ // attribute it to — 'unscoped' means no marker, however fresh, ever covers it.
913
+ scanFile(join(hypoDir, 'hot.md'), { kind: 'root-universal' });
914
+ scanFile(join(hypoDir, 'session-state.md'), { kind: 'unscoped' });
915
+ const projectsDir = join(hypoDir, 'projects');
916
+ if (existsSync(projectsDir)) {
917
+ for (const slug of readdirSync(projectsDir)) {
918
+ if (slug === '_template') continue;
919
+ const dir = join(projectsDir, slug);
920
+ try {
921
+ if (!statSync(dir).isDirectory()) continue;
922
+ } catch {
923
+ continue;
924
+ }
925
+ scanFile(join(dir, 'session-state.md'), { kind: 'projects', projects: [slug] });
926
+ scanFile(join(dir, 'hot.md'), { kind: 'projects', projects: [slug] });
927
+ }
928
+ }
929
+
930
+ if (existsSync(join(hypoDir, '.git'))) {
931
+ const log = spawnSync(
932
+ 'git',
933
+ ['-C', hypoDir, 'log', '--since=7.days.ago', '--format=%H%x1f%ad%x1f%s', '--date=short'],
934
+ { encoding: 'utf-8' },
935
+ );
936
+ if (log.status === 0 && log.stdout) {
937
+ for (const line of log.stdout.split('\n')) {
938
+ if (!line.trim()) continue;
939
+ const [hash, date, subject] = line.split('\x1f');
940
+ if (detectSessionCloseArtifact({ commitMessage: subject }).matched) {
941
+ const projects = deriveCommitProjects(hypoDir, hash);
942
+ artifacts.push({
943
+ date,
944
+ scope: projects.length > 0 ? { kind: 'projects', projects } : { kind: 'unscoped' },
945
+ where: `commit "${subject}"`,
946
+ });
947
+ }
948
+ }
949
+ }
950
+ }
951
+
952
+ if (artifacts.length === 0) {
953
+ pass('Session-close artifacts', 'No close artifacts found');
954
+ return;
955
+ }
956
+
957
+ // Read the raw marker files directly rather than readSessionClosedMarker,
958
+ // which also DELETES an expired one as a side effect; a health check
959
+ // should not mutate state to produce a diagnosis. Staleness is still
960
+ // enforced here (same SESSION_CLOSED_MARKER_STALE_MS window) so an
961
+ // already-expired marker can't vouch for anything.
962
+ const cacheDir = join(hypoDir, '.cache');
963
+ const markers = [];
964
+ let cacheEntries = [];
965
+ if (existsSync(cacheDir)) {
966
+ try {
967
+ cacheEntries = readdirSync(cacheDir);
968
+ } catch {
969
+ // unreadable .cache/ — not this check's job to diagnose that
970
+ }
971
+ }
972
+ for (const file of cacheEntries) {
973
+ if (!file.startsWith('session-closed-') || !file.endsWith('.marker')) continue;
974
+ try {
975
+ const data = JSON.parse(readFileSync(join(cacheDir, file), 'utf-8'));
976
+ const ts = Date.parse(data?.closed_at || '');
977
+ if (!Number.isFinite(ts) || Date.now() - ts > SESSION_CLOSED_MARKER_STALE_MS) continue;
978
+ // ONLY the v4 `projects` array counts as project-scope evidence. A
979
+ // legacy flat `project` field can be a recency-derived misattribution
980
+ // (the P1 bug resolveCloseScope's own doc comment describes) — the
981
+ // runtime scope resolver already refuses to trust it uncorroborated,
982
+ // and doctor has no corroborating signal of its own to add, so it
983
+ // must refuse it too rather than re-opening the same hole standalone.
984
+ const projects = Array.isArray(data?.projects) ? data.projects.filter(Boolean) : [];
985
+ markers.push({ projects: [...new Set(projects)], dates: localAndUtcDates(new Date(ts)) });
986
+ } catch {
987
+ // corrupt marker — not this check's job to clean up
988
+ }
989
+ }
990
+
991
+ const unmatched = artifacts.filter(
992
+ (a) => !a.date || !markers.some((m) => markerCoversArtifact(m, a)),
993
+ );
994
+ if (unmatched.length === 0) {
995
+ pass(
996
+ 'Session-close artifacts',
997
+ `${artifacts.length} close artifact(s), all covered by a session-closed marker`,
998
+ );
999
+ return;
1000
+ }
1001
+
1002
+ const sample = unmatched
1003
+ .slice(0, 5)
1004
+ .map((a) => `${a.where}${a.date ? ` (${a.date})` : ''}`)
1005
+ .join(', ');
1006
+ const extra = unmatched.length > 5 ? ` (+${unmatched.length - 5} more)` : '';
1007
+ // Not necessarily UNAPPROVED — an approved log-only marker (deliberately
1008
+ // empty `projects`) or one scoped to a different project would ALSO land
1009
+ // here, since neither covers a specific project's artifact. Both readings
1010
+ // are worth a human look, so the wording covers both instead of asserting
1011
+ // the stronger, sometimes-wrong one.
1012
+ warn(
1013
+ 'Session-close artifacts',
1014
+ `${unmatched.length} close artifact(s) with no session-closed marker covering their ` +
1015
+ `date and project scope — may be an unapproved hand-made close, or an approved close ` +
1016
+ `whose marker doesn't attribute this project: ${sample}${extra}`,
1017
+ );
1018
+ }
1019
+
1020
+ /**
1021
+ * Render a `.cache/sync-last-success.json` record as an absolute-time note.
1022
+ * Deliberately no "recent"/staleness verdict — the failure-state check above
1023
+ * already carries the health signal; this only says what and when.
1024
+ */
1025
+ function formatLastSuccess(lastSuccess) {
1026
+ const pull = lastSuccess.pull
1027
+ ? `pull ${lastSuccess.pull.timestamp} (${lastSuccess.pull.host})`
1028
+ : 'pull: none recorded';
1029
+ const push = lastSuccess.push
1030
+ ? `push ${lastSuccess.push.timestamp} (${lastSuccess.push.host})`
1031
+ : 'push: none recorded';
1032
+ return `${pull}; ${push}`;
1033
+ }
1034
+
591
1035
  function checkSyncState(hypoDir) {
592
1036
  // "open" = file exists with ≥1 entries; session-start clears once
593
1037
  // sync is healthy again. Schema + parsing live in hooks/hypo-shared.mjs.
@@ -599,20 +1043,96 @@ function checkSyncState(hypoDir) {
599
1043
  }
600
1044
 
601
1045
  if (entries.length === 0) {
602
- pass('Sync state', 'No unresolved sync failures');
1046
+ // No unresolved failure — but that alone does not mean sync ever ran.
1047
+ // Read the separate, additive last-success record to tell "never synced"
1048
+ // apart from "healthy" and, when present, from a pull-only/push-only
1049
+ // history. A corrupt success file warns rather than crashing, same as the
1050
+ // sync-state parse-error handling above.
1051
+ const { data: lastSuccess, parseError: successParseError } = readSyncLastSuccess(hypoDir);
1052
+ if (successParseError) {
1053
+ warn('Sync state', 'Cannot parse .cache/sync-last-success.json — inspect manually');
1054
+ } else if (!lastSuccess.pull && !lastSuccess.push) {
1055
+ pass(
1056
+ 'Sync state',
1057
+ 'No unresolved sync failures — never synced (no recorded pull or push yet)',
1058
+ );
1059
+ } else {
1060
+ // Distinguishes pull-only / push-only / both, since formatLastSuccess
1061
+ // reports "none recorded" for whichever op is absent.
1062
+ pass(
1063
+ 'Sync state',
1064
+ `No unresolved sync failures. Last success — ${formatLastSuccess(lastSuccess)}`,
1065
+ );
1066
+ }
603
1067
  } else {
604
1068
  const last = entries[entries.length - 1];
605
- // A merge conflict needs a real manual merge, not a plain push/pull — give
606
- // the same explicit guidance session-start does instead of the generic hint.
607
- if (String(last.op || '').startsWith('conflict')) {
1069
+ // An unresolved failure does not mean the OTHER operation never
1070
+ // succeeded — a push failure can sit right next to a healthy pull, and
1071
+ // the last-success record above used to only wire into the healthy/
1072
+ // never-synced branch, leaving this branch silent about it. Append it
1073
+ // here too, best-effort, same as the healthy branch — a corrupt success
1074
+ // file still warns, never crashes.
1075
+ const { data: lastSuccess, parseError: successParseError } = readSyncLastSuccess(hypoDir);
1076
+ // Not "unreadable": readSyncLastSuccess also sets parseError for a file
1077
+ // that reads fine and parses as valid JSON but has the wrong shape (a
1078
+ // malformed pull/push field, or an unrecognized key) — "unreadable" would
1079
+ // misdescribe that case.
1080
+ const successSuffix = successParseError
1081
+ ? ' (.cache/sync-last-success.json is invalid or unreadable — inspect manually)'
1082
+ : lastSuccess.pull || lastSuccess.push
1083
+ ? ` Last success — ${formatLastSuccess(lastSuccess)}.`
1084
+ : '';
1085
+ // More than one open entry means the vault has been failing across
1086
+ // multiple sync attempts without clearing — name each one (op@timestamp),
1087
+ // not just the last, so the user isn't left to open sync-state.json
1088
+ // themselves to see the pattern. Capped like checkBrokenLinks/checkVerifyBy.
1089
+ const unresolvedList =
1090
+ entries.length > 1
1091
+ ? ` Unresolved: ${entries
1092
+ .slice(-5)
1093
+ .map((e) => `${e.op || '?'}@${e.timestamp || '?'}`)
1094
+ .join(', ')}${entries.length > 5 ? ` (+${entries.length - 5} more)` : ''}.`
1095
+ : '';
1096
+ // classifySyncOp (hooks/hypo-shared.mjs) is the single judgment both this
1097
+ // check and hypo-session-start.mjs's syncStateNotice branch on, so the
1098
+ // two surfaces cannot silently diverge on WHICH op gets which treatment.
1099
+ // 'conflict-unresolved' — the merge --abort itself failed — is the more
1100
+ // dangerous of the two conflict ops: the tree may still be
1101
+ // half-merged (unmerged index entries, or an in-progress MERGE_HEAD), so
1102
+ // it gets dedicated guidance rather than the plain-conflict branch below,
1103
+ // which claims "your local work is committed" (not true when the abort
1104
+ // itself failed) and advises `git pull --no-rebase` (git would simply
1105
+ // refuse that mid-merge). Wording mirrors hypo-session-start.mjs's
1106
+ // syncStateNotice so the two surfaces never contradict each other.
1107
+ const cls = classifySyncOp(last.op);
1108
+ if (cls === 'conflict-unresolved') {
608
1109
  warn(
609
1110
  'Sync state',
610
- `${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.`,
1111
+ `${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}`,
1112
+ );
1113
+ } else if (cls === 'conflict') {
1114
+ // A merge conflict needs a real manual merge, not a plain push/pull —
1115
+ // give the same explicit guidance session-start does instead of the
1116
+ // generic hint. This branch is unmerged-index-free by construction (the
1117
+ // abort succeeded), so "committed and safe" is an accurate claim here.
1118
+ warn(
1119
+ 'Sync state',
1120
+ `${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}`,
1121
+ );
1122
+ } else if (cls === 'unknown-conflict') {
1123
+ // An unrecognized `conflict*` op — a future syncRemote failure mode
1124
+ // this check has no dedicated branch for. Neither the 'conflict'
1125
+ // branch's "committed" claim nor the 'conflict-unresolved' branch's
1126
+ // "abort failed" claim is known to hold here, so assert neither:
1127
+ // report the state as unknown and treat it as unresolved.
1128
+ warn(
1129
+ 'Sync state',
1130
+ `${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}`,
611
1131
  );
612
1132
  } else {
613
1133
  warn(
614
1134
  'Sync state',
615
- `${entries.length} unresolved failure(s) — last: ${last.op || '?'} at ${last.timestamp || '?'}. Inspect .cache/sync-state.json or push/pull manually to clear.`,
1135
+ `${entries.length} unresolved failure(s) — last: ${last.op || '?'} at ${last.timestamp || '?'}. Inspect .cache/sync-state.json or push/pull manually to clear.${unresolvedList}${successSuffix}`,
616
1136
  );
617
1137
  }
618
1138
  }
@@ -669,6 +1189,24 @@ function checkProjectSuggestions(hypoDir) {
669
1189
  }
670
1190
  }
671
1191
 
1192
+ function checkProposals(hypoDir) {
1193
+ // Vault-wide count of parked write-proposals (T8). listProposals is the
1194
+ // count source rather than a raw readdir: it already skips malformed and
1195
+ // spoofed-id artifacts, so the number matches exactly what `hypomnema proposal
1196
+ // list/apply/discard` can act on. Surface only: warn (never fail), pass at 0,
1197
+ // because a pending proposal is a normal state awaiting review, not a broken
1198
+ // install, and the check discovers without changing any state.
1199
+ const count = listProposals(hypoDir).length;
1200
+ if (count === 0) {
1201
+ pass('Pending proposals', 'No parked write-proposals');
1202
+ } else {
1203
+ warn(
1204
+ 'Pending proposals',
1205
+ `${count} parked write-proposal(s) awaiting review; inspect with \`hypomnema proposal list\``,
1206
+ );
1207
+ }
1208
+ }
1209
+
672
1210
  function checkCodexPaths() {
673
1211
  const codexHooks = join(HOME, '.codex', 'hooks');
674
1212
  const allFiles = [...Object.values(HOOK_MAP).flat(), ...SHARED_FILES];
@@ -805,6 +1343,58 @@ function checkExtensions(hypoDir, claudeHome, target = 'claude') {
805
1343
  // Skip keys outside this target's covered types (defensive: a Claude run records
806
1344
  // skills/agents that a codex target never installs — don't false-flag them).
807
1345
  if (!types.includes(key.split('/')[0])) continue;
1346
+
1347
+ // A directory skill records one key whose value is a per-file SHA map. Joining
1348
+ // that key straight onto root would land on the skill DIRECTORY and report it
1349
+ // as "not a regular file" on every run. Check the subtree file by file instead.
1350
+ const skillKey = parseSkillKey(key);
1351
+ if (skillKey) {
1352
+ const nested = parseSkillShaValue(recSHA);
1353
+ if (!nested) {
1354
+ problems.push({
1355
+ severity: 'warn',
1356
+ msg: `${key} has a corrupt ownership record — run upgrade --apply`,
1357
+ });
1358
+ continue;
1359
+ }
1360
+ const skillRoot = join(root, 'skills', skillKey.installDir);
1361
+ for (const [rel, sha] of Object.entries(nested)) {
1362
+ const filePath = join(skillRoot, ...rel.split('/'));
1363
+ const label = `${key}/${rel}`;
1364
+ if (!existsSync(filePath)) {
1365
+ problems.push({
1366
+ severity: 'warn',
1367
+ msg: `${label} recorded but not installed — run upgrade --apply`,
1368
+ });
1369
+ continue;
1370
+ }
1371
+ const buf = readFileIfRegular(filePath);
1372
+ if (buf === null) {
1373
+ problems.push({
1374
+ severity: 'warn',
1375
+ msg: `${label} is not a regular file — left untouched`,
1376
+ });
1377
+ continue;
1378
+ }
1379
+ if (sha256(buf) !== sha) {
1380
+ problems.push({
1381
+ severity: 'warn',
1382
+ msg: `${label} modified since install (drift) — use --force-extensions`,
1383
+ });
1384
+ }
1385
+ }
1386
+ continue;
1387
+ }
1388
+
1389
+ // A flat key must carry a plain hex SHA; a wrong-shaped value is a corrupt
1390
+ // record, not a drifted file.
1391
+ if (!isFlatShaValue(recSHA)) {
1392
+ problems.push({
1393
+ severity: 'warn',
1394
+ msg: `${key} has a corrupt ownership record — run upgrade --apply`,
1395
+ });
1396
+ continue;
1397
+ }
808
1398
  const destPath = join(root, key);
809
1399
  if (!existsSync(destPath)) {
810
1400
  problems.push({
@@ -1129,9 +1719,44 @@ function checkFeedbackProjection(hypoDir, claudeHome, projectId) {
1129
1719
  `${name}: ${reason} — run \`hypomnema feedback-sync --import-target-change\` to reconcile`,
1130
1720
  );
1131
1721
  } else {
1132
- // 2) build error → WARN
1133
- const buildErr = targets.find(([, t]) => t.buildError);
1134
- if (buildErr) {
1722
+ // 2) build error. Split by kind:
1723
+ //
1724
+ // 'build-failed' → FAIL. The target file EXISTS but its
1725
+ // <learned_behaviors> container is gone, so the projection cannot be
1726
+ // built: NOT ONE L1 rule is loaded on that machine and every later sync
1727
+ // is a silent no-op. This was a warn, which made a structurally broken
1728
+ // projection near-invisible — nothing else reported it either (the
1729
+ // PreCompact gate classified it as "nothing to do" and failed open), and
1730
+ // it went unnoticed on a real machine. A projection that cannot be built
1731
+ // is a failure, not a note.
1732
+ //
1733
+ // 'target-missing' → WARN, as before. No ~/.claude/CLAUDE.md yet is the
1734
+ // ordinary first-run state, not a broken one; failing here would fail
1735
+ // every new user's doctor run.
1736
+ //
1737
+ // Select the build-failed target SPECIFICALLY rather than taking the first
1738
+ // buildError of any kind: with more than one container target, a
1739
+ // 'target-missing' one earlier in iteration order would otherwise mask a
1740
+ // structurally broken one behind a warn — the exact invisibility this check
1741
+ // exists to end. Only `claude` carries a container today, so that masking is
1742
+ // latent, not live; the precompact gate filters the same way.
1743
+ const failedErr = targets.find(([, t]) => t.buildErrorKind === 'build-failed');
1744
+ const buildErr = failedErr || targets.find(([, t]) => t.buildError);
1745
+ if (failedErr) {
1746
+ const [name, t] = failedErr;
1747
+ // Name the remedy for THIS cause, not a fixed one. `--ensure-container`
1748
+ // restores a MISSING container; it cannot chmod an unreadable file, cannot
1749
+ // repoint a dangling symlink, and refuses a corrupt container outright — so
1750
+ // printing it for those causes hands the reader a command that does nothing
1751
+ // and teaches them to ignore the next failure too. feedback-sync decides the
1752
+ // remedy where it detects the cause and ships it as `buildErrorRemedy`; this
1753
+ // just prints it (one judgment, several consumers).
1754
+ fail(
1755
+ 'Feedback projection',
1756
+ `${name}: ${t.buildError} — the projection cannot be built, so NO rules are loaded from it ` +
1757
+ `and every sync is a silent no-op. ${t.buildErrorRemedy || ''}`.trimEnd(),
1758
+ );
1759
+ } else if (buildErr) {
1135
1760
  warn('Feedback projection', buildErr[1].buildError);
1136
1761
  } else if (targets.find(([, t]) => t.overCap)) {
1137
1762
  warn('Feedback projection', 'projection over cap — demote/archive feedback pages');
@@ -1144,7 +1769,23 @@ function checkFeedbackProjection(hypoDir, claudeHome, projectId) {
1144
1769
  }
1145
1770
  }
1146
1771
 
1147
- // 3) unresolved project-id is a separate, non-fatal concern (MEMORY skipped)
1772
+ // 3) side-file I/O problems: a WARN, and additive rather than part of the
1773
+ // if/else chain above — an unreadable feedback_<slug>.md copy is orthogonal
1774
+ // to the primary target's health. It must not fail: the index line still
1775
+ // projects, every rule still loads, and the remedy is a permission bit on a
1776
+ // named path — nothing `--ensure-container` (the build-failed remedy) can
1777
+ // touch. feedback-sync already put the path in the message.
1778
+ for (const [name, t] of targets) {
1779
+ for (const w of t.sideWarnings || []) {
1780
+ warn(
1781
+ 'Feedback projection side file',
1782
+ `${name}: ${w} — fix the permissions on that path; the primary projection still loads ` +
1783
+ `(\`--ensure-container\` does not fix this)`,
1784
+ );
1785
+ }
1786
+ }
1787
+
1788
+ // 4) unresolved project-id is a separate, non-fatal concern (MEMORY skipped)
1148
1789
  if (report.projectIdResolved === false) {
1149
1790
  warn(
1150
1791
  'Feedback projection',
@@ -1187,6 +1828,121 @@ function checkStaleSibling() {
1187
1828
  }
1188
1829
  }
1189
1830
 
1831
+ // ── package integrity ─────────────────────────────────────────────────────────
1832
+ //
1833
+ // ~/.claude/hypo-pkg.json is a snapshot written once by init/upgrade and never
1834
+ // re-validated: nothing tracks whether pkgRoot still exists, whether its
1835
+ // package.json version still matches the recorded pkgVersion, or whether
1836
+ // pkgRoot points at a dev checkout instead of a distributed copy. Command and
1837
+ // skill script resolution was unified onto this one pointer, so a stale or
1838
+ // dev-pointing pkgRoot means the wiki silently runs uncommitted WIP.
1839
+ // All three checks are WARN, never FAIL — a dogfooding maintainer must be
1840
+ // able to ignore them on purpose.
1841
+ function checkPkgIntegrity(claudeHome) {
1842
+ const pkgPath = join(claudeHome, 'hypo-pkg.json');
1843
+ const meta = readPkgJson(pkgPath);
1844
+ // Distinguish "no metadata file at all" (a fresh install — non-actionable, stay
1845
+ // silent) from "a file is present but has no pkgRoot" (incomplete metadata that
1846
+ // breaks runtime package resolution — warn rather than silently reading as OK).
1847
+ // readPkgJson renames a corrupt file aside and returns {}, so after that the path
1848
+ // no longer exists and this correctly falls into the silent fresh-install branch.
1849
+ if (!existsSync(pkgPath)) return;
1850
+ if (!meta || !meta.pkgRoot) {
1851
+ warn(
1852
+ 'hypo-pkg.json pkgRoot',
1853
+ `hypo-pkg.json has no pkgRoot field — metadata is incomplete; re-run /hypo:init or /hypo:upgrade`,
1854
+ );
1855
+ return;
1856
+ }
1857
+
1858
+ const { pkgRoot, pkgVersion } = meta;
1859
+
1860
+ if (!existsSync(pkgRoot)) {
1861
+ warn(
1862
+ 'hypo-pkg.json pkgRoot exists',
1863
+ `${pkgRoot} does not exist — metadata is stale; re-run /hypo:init or /hypo:upgrade`,
1864
+ );
1865
+ return;
1866
+ }
1867
+ pass('hypo-pkg.json pkgRoot exists', pkgRoot);
1868
+
1869
+ let actualVersion = null;
1870
+ let pkgJsonReadable = false;
1871
+ try {
1872
+ actualVersion = JSON.parse(readFileSync(join(pkgRoot, 'package.json'), 'utf-8')).version;
1873
+ pkgJsonReadable = true;
1874
+ } catch {
1875
+ warn('hypo-pkg.json version match', `${pkgRoot}/package.json is unreadable — cannot verify`);
1876
+ }
1877
+ // Every reachable state emits exactly one line: a silent skip here would read as
1878
+ // "verified" on the health report when nothing was actually checked.
1879
+ if (actualVersion) {
1880
+ if (pkgVersion && actualVersion !== pkgVersion) {
1881
+ warn(
1882
+ 'hypo-pkg.json version match',
1883
+ `recorded pkgVersion is ${pkgVersion}, but pkgRoot's package.json is v${actualVersion} — ` +
1884
+ `stale metadata; re-run /hypo:init or /hypo:upgrade`,
1885
+ );
1886
+ } else if (pkgVersion) {
1887
+ pass('hypo-pkg.json version match', `v${actualVersion}`);
1888
+ } else {
1889
+ warn(
1890
+ 'hypo-pkg.json version match',
1891
+ `metadata records no pkgVersion — cannot verify against pkgRoot's v${actualVersion}; ` +
1892
+ `re-run /hypo:init or /hypo:upgrade`,
1893
+ );
1894
+ }
1895
+ } else if (pkgJsonReadable) {
1896
+ warn(
1897
+ 'hypo-pkg.json version match',
1898
+ `${pkgRoot}/package.json has no "version" field — cannot verify`,
1899
+ );
1900
+ }
1901
+
1902
+ // dev-repo check: pkgRoot is a git working tree that is dirty or sits on a
1903
+ // commit with no exact release tag. Normal users should point at a
1904
+ // distributed copy (plugin cache or npm install), not a source checkout.
1905
+ if (existsSync(join(pkgRoot, '.git'))) {
1906
+ const status = spawnSync('git', ['-C', pkgRoot, 'status', '--porcelain'], {
1907
+ encoding: 'utf-8',
1908
+ });
1909
+ const tag = spawnSync('git', ['-C', pkgRoot, 'describe', '--tags', '--exact-match'], {
1910
+ encoding: 'utf-8',
1911
+ });
1912
+ // A non-zero `git describe` exit is the real "HEAD is not an exact tag" signal,
1913
+ // but a spawn failure (git not installed, ENOENT) sets `.error` / a null status
1914
+ // too. Treat those as "cannot classify" instead of silently mislabeling the
1915
+ // pkgRoot as an untagged dev checkout. `git status --porcelain` must exit 0 on
1916
+ // any real repo (clean OR dirty), so a non-zero status there means the repo is
1917
+ // corrupt/inaccessible — also "cannot classify", not "untagged". `git describe`
1918
+ // legitimately exits non-zero when HEAD carries no exact tag, so only its spawn
1919
+ // (error/null status) disqualifies it, not a non-zero exit.
1920
+ const gitUsable =
1921
+ status.error == null && status.status === 0 && tag.error == null && tag.status != null;
1922
+ if (!gitUsable) {
1923
+ warn(
1924
+ 'hypo-pkg.json pkgRoot install kind',
1925
+ `pkgRoot has a .git directory but git could not be run — cannot classify the install kind`,
1926
+ );
1927
+ return;
1928
+ }
1929
+ const dirty = status.stdout.trim().length > 0; // gitUsable already asserts status 0
1930
+ const untagged = tag.status !== 0;
1931
+ if (dirty || untagged) {
1932
+ const reasons = [];
1933
+ if (dirty) reasons.push('uncommitted changes');
1934
+ if (untagged) reasons.push('HEAD is not an exact release tag');
1935
+ warn(
1936
+ 'hypo-pkg.json pkgRoot install kind',
1937
+ `pkgRoot looks like a development checkout (${reasons.join(', ')}) — normal users should ` +
1938
+ `point at a distributed copy (plugin cache or npm install), not a source repo`,
1939
+ );
1940
+ } else {
1941
+ pass('hypo-pkg.json pkgRoot install kind', 'pkgRoot is a clean, tagged checkout');
1942
+ }
1943
+ }
1944
+ }
1945
+
1190
1946
  // ── main ─────────────────────────────────────────────────────────────────────
1191
1947
 
1192
1948
  const args = parseArgs(process.argv);
@@ -1197,18 +1953,23 @@ const rootOk = checkHypoRoot(args.hypoDir);
1197
1953
  if (rootOk) {
1198
1954
  checkDirectories(args.hypoDir);
1199
1955
  checkFiles(args.hypoDir);
1956
+ checkScanIgnoreFile(args.hypoDir);
1200
1957
  checkBrokenLinks(args.hypoDir, ignorePatterns);
1958
+ checkIncompleteRename(args.hypoDir);
1201
1959
  checkVerifyBy(args.hypoDir, ignorePatterns);
1202
1960
  }
1203
- checkHooks();
1204
- checkSettingsJson();
1961
+ checkHooks(coreManagedByPlugin);
1962
+ checkSettingsJson(coreManagedByPlugin);
1963
+ checkPkgIntegrity(args.claudeHome);
1205
1964
  checkStaleSibling();
1206
1965
  if (args.codex) checkCodexPaths();
1207
1966
  if (rootOk) checkExtensions(args.hypoDir, args.claudeHome, 'claude');
1208
1967
  if (rootOk && args.codex) checkExtensions(args.hypoDir, args.claudeHome, 'codex');
1209
1968
  if (rootOk) checkProjectIndexAnchors(args.hypoDir);
1969
+ if (rootOk) checkSessionCloseArtifacts(args.hypoDir);
1210
1970
  if (rootOk) checkSyncState(args.hypoDir);
1211
1971
  if (rootOk) checkProjectSuggestions(args.hypoDir);
1972
+ if (rootOk) checkProposals(args.hypoDir);
1212
1973
  if (rootOk) checkFeedbackProjection(args.hypoDir, args.claudeHome, args.projectId);
1213
1974
  checkGit(args.hypoDir);
1214
1975