hypomnema 1.6.2 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) 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 +7 -1
  11. package/hooks/hypo-auto-minimal-crystallize.mjs +63 -20
  12. package/hooks/hypo-auto-stage.mjs +30 -0
  13. package/hooks/hypo-cwd-change.mjs +31 -2
  14. package/hooks/hypo-file-watch.mjs +21 -2
  15. package/hooks/hypo-first-prompt.mjs +19 -3
  16. package/hooks/hypo-lookup.mjs +86 -29
  17. package/hooks/hypo-personal-check.mjs +24 -3
  18. package/hooks/hypo-session-record.mjs +2 -3
  19. package/hooks/hypo-session-start.mjs +89 -7
  20. package/hooks/hypo-shared.mjs +904 -128
  21. package/hooks/proposal-store.mjs +513 -0
  22. package/package.json +40 -14
  23. package/scripts/capture.mjs +556 -37
  24. package/scripts/crystallize.mjs +639 -108
  25. package/scripts/doctor.mjs +304 -9
  26. package/scripts/feedback-sync.mjs +515 -44
  27. package/scripts/graph.mjs +9 -2
  28. package/scripts/init.mjs +230 -34
  29. package/scripts/lib/extensions.mjs +656 -1
  30. package/scripts/lib/hypo-ignore.mjs +54 -6
  31. package/scripts/lib/hypo-root.mjs +56 -6
  32. package/scripts/lib/page-usage.mjs +15 -2
  33. package/scripts/lib/pkg-json.mjs +40 -0
  34. package/scripts/lib/plugin-detect.mjs +96 -6
  35. package/scripts/lib/wd-match.mjs +23 -5
  36. package/scripts/lib/wikilink.mjs +32 -6
  37. package/scripts/lint.mjs +20 -4
  38. package/scripts/proposal.mjs +1032 -0
  39. package/scripts/query.mjs +25 -4
  40. package/scripts/resume.mjs +34 -12
  41. package/scripts/stats.mjs +28 -8
  42. package/scripts/uninstall.mjs +141 -6
  43. package/scripts/upgrade.mjs +197 -15
  44. package/skills/crystallize/SKILL.md +44 -7
  45. package/skills/debate/SKILL.md +88 -0
  46. package/skills/debate/references/orchestration-patterns.md +83 -0
  47. package/templates/.hyposcanignore +10 -0
  48. package/templates/SCHEMA.md +12 -0
  49. package/templates/gitignore +5 -0
  50. package/templates/hypo-config.md +1 -1
  51. package/templates/hypo-guide.md +6 -0
  52. package/scripts/.gitkeep +0 -0
  53. package/scripts/check-bilingual.mjs +0 -153
  54. package/scripts/check-readme-version.mjs +0 -126
  55. package/scripts/check-tracker-ids.mjs +0 -426
  56. package/scripts/check-versions.mjs +0 -171
  57. package/scripts/install-git-hooks.mjs +0 -293
  58. package/scripts/lib/changelog-classify.mjs +0 -216
  59. package/scripts/lib/check-bilingual.mjs +0 -244
  60. package/scripts/lib/check-tracker-ids.mjs +0 -217
  61. package/scripts/lib/pre-commit-format.mjs +0 -251
  62. package/scripts/pre-commit-format.mjs +0 -198
@@ -1,5 +1,5 @@
1
1
  import { existsSync, readFileSync } from 'fs';
2
- import { join, relative, basename } from 'path';
2
+ import { join, relative, basename, resolve } from 'path';
3
3
 
4
4
  export function loadHypoIgnore(hypoDir) {
5
5
  const ignorePath = join(hypoDir, '.hypoignore');
@@ -10,6 +10,46 @@ export function loadHypoIgnore(hypoDir) {
10
10
  .filter((l) => l && !l.startsWith('#'));
11
11
  }
12
12
 
13
+ // .hyposcanignore: scan-only exclusions (lint/graph/stats/query/verify/doctor
14
+ // catalog scans). Deliberately NOT a privacy boundary: a match here is still
15
+ // committed and still readable by hooks. Only isScanIgnored() below reads this
16
+ // file. loadHypoIgnore()/isIgnored() must never see it, that is the whole
17
+ // point of the split, so do not thread it into the pre-commit gate or
18
+ // ingest --check.
19
+ export function loadScanIgnore(hypoDir) {
20
+ const ignorePath = join(resolve(hypoDir), '.hyposcanignore');
21
+ if (!existsSync(ignorePath)) return [];
22
+ return readFileSync(ignorePath, 'utf-8')
23
+ .split('\n')
24
+ .map((l) => l.trim())
25
+ .filter((l) => l && !l.startsWith('#'));
26
+ }
27
+
28
+ // Parsed .hyposcanignore patterns, cached per vault for the process lifetime.
29
+ // The shared walker (wikilink.mjs) calls isScanIgnored() once per directory
30
+ // entry, so re-reading this file on every call would turn a vault scan into
31
+ // O(files) filesystem reads for one small file. Nothing writes
32
+ // .hyposcanignore mid-process, so a stale cache is not a real scenario.
33
+ //
34
+ // Keyed on path.resolve(hypoDir), NOT the raw argument: a raw-string key lets
35
+ // two different relative spellings of the SAME vault ('.', './') miss each
36
+ // other (harmless, just a wasted read), but it also lets a relative key like
37
+ // '.' collide ACROSS vaults when the caller cd's between them between calls —
38
+ // vault B would silently reuse vault A's scan patterns. Resolving to an
39
+ // absolute path first makes the key vault-identity-stable regardless of cwd
40
+ // or spelling.
41
+ const scanIgnoreCache = new Map();
42
+
43
+ function cachedScanIgnore(hypoDir) {
44
+ const key = resolve(hypoDir);
45
+ let patterns = scanIgnoreCache.get(key);
46
+ if (patterns === undefined) {
47
+ patterns = loadScanIgnore(key);
48
+ scanIgnoreCache.set(key, patterns);
49
+ }
50
+ return patterns;
51
+ }
52
+
13
53
  function globToRegex(glob) {
14
54
  return new RegExp(
15
55
  '^' +
@@ -39,12 +79,20 @@ export function isGeneratedArtifact(filePath, hypoDir) {
39
79
  }
40
80
 
41
81
  // Catalog/scan exclusion = privacy/.hypoignore patterns PLUS generated root
42
- // artifacts. Use this in tools that build the knowledge catalog (lint, graph,
43
- // stats, query, verify, crystallize, rename, doctor broken-links). Do NOT use it
44
- // in the pre-commit gate or ingest --check, which are privacy boundaries that
45
- // must stay on isIgnored()/.hypoignore alone.
82
+ // artifacts PLUS scan-only/.hyposcanignore patterns (loaded and cached
83
+ // internally, so callers keep passing only their privacy `patterns` array).
84
+ // Use this in tools that build the knowledge catalog (lint, graph, stats,
85
+ // query, verify, crystallize, rename, doctor broken-links). Do NOT use it in
86
+ // the pre-commit gate or ingest --check, which are privacy boundaries that
87
+ // must stay on isIgnored()/.hypoignore alone — OR-ing in .hyposcanignore here
88
+ // only ever WIDENS what a scan skips, it can never make an .hypoignore match
89
+ // committable, so that separation holds by construction.
46
90
  export function isScanIgnored(filePath, hypoDir, patterns) {
47
- return isIgnored(filePath, hypoDir, patterns) || isGeneratedArtifact(filePath, hypoDir);
91
+ return (
92
+ isIgnored(filePath, hypoDir, patterns) ||
93
+ isGeneratedArtifact(filePath, hypoDir) ||
94
+ isIgnored(filePath, hypoDir, cachedScanIgnore(hypoDir))
95
+ );
48
96
  }
49
97
 
50
98
  export function isIgnored(filePath, hypoDir, patterns) {
@@ -26,13 +26,20 @@ export function expandHome(p) {
26
26
  }
27
27
 
28
28
  /**
29
- * Resolve the Hypomnema root directory.
29
+ * Resolve the Hypomnema root directory, along with how it was resolved.
30
30
  * Checks HYPO_DIR env → hypo-config.md scan → ~/hypomnema default.
31
- * @returns {string} absolute path to Hypomnema root
31
+ *
32
+ * The `source` lets a caller tell "the user pointed us somewhere and it's
33
+ * empty" (an explicit misconfiguration worth failing loud on) apart from
34
+ * "nobody configured anything and none of the usual spots had a vault
35
+ * either" (a silent fallback, worth a quiet notice at most). resolveHypoRoot()
36
+ * itself collapses both into the same path, on purpose, for every caller that
37
+ * only ever wants a directory to hand to fs calls.
38
+ * @returns {{ root: string, source: 'env'|'marker'|'default' }}
32
39
  */
33
- export function resolveHypoRoot() {
40
+ export function resolveHypoRootInfo() {
34
41
  if (process.env.HYPO_DIR) {
35
- return expandHome(process.env.HYPO_DIR);
42
+ return { root: expandHome(process.env.HYPO_DIR), source: 'env' };
36
43
  }
37
44
 
38
45
  const candidates = [
@@ -46,8 +53,51 @@ export function resolveHypoRoot() {
46
53
  ];
47
54
 
48
55
  for (const c of candidates) {
49
- if (existsSync(join(c, 'hypo-config.md'))) return c;
56
+ if (existsSync(join(c, 'hypo-config.md'))) return { root: c, source: 'marker' };
57
+ }
58
+
59
+ return { root: join(HOME, 'hypomnema'), source: 'default' };
60
+ }
61
+
62
+ /**
63
+ * Resolve the Hypomnema root directory.
64
+ * Checks HYPO_DIR env → hypo-config.md scan → ~/hypomnema default.
65
+ * @returns {string} absolute path to Hypomnema root
66
+ */
67
+ export function resolveHypoRoot() {
68
+ return resolveHypoRootInfo().root;
69
+ }
70
+
71
+ /**
72
+ * Validate a resolved root against the hypo-config.md marker, for the
73
+ * read-only CLIs only (lint/stats/graph/query). Two failure classes need
74
+ * different loudness:
75
+ * - `source === 'env'`: the caller pointed HYPO_DIR at a path with no
76
+ * vault there — an explicit misconfiguration. Fail loud: stderr + exit 1.
77
+ * - `source === 'default'` (or a stale `'marker'` hit): nobody configured
78
+ * anything and none of the usual spots had a vault. CI (lint-runner,
79
+ * release.yml) intentionally runs this way and must keep exiting 0 — but
80
+ * the CLI must stop silently reporting "no issues found" / "no results"
81
+ * as if it had actually scanned a wiki. Print a visible notice and tell
82
+ * the caller, so it can adjust its own empty-result copy, then continue.
83
+ * A caller that received `hypoDir` from an explicit --hypo-dir=<path> flag
84
+ * (tests, other tooling) never calls this — that path is not auto-resolved
85
+ * and is trusted as-is, valid or not.
86
+ * @param {string} root
87
+ * @param {'env'|'marker'|'default'} source
88
+ * @returns {boolean} true when the vault is missing (caller should suppress
89
+ * its own "nothing found" messaging in favor of this notice already
90
+ * printed); false when a real vault was found and normal scanning should
91
+ * proceed.
92
+ */
93
+ export function checkVaultOrExit(root, source) {
94
+ if (existsSync(join(root, 'hypo-config.md'))) return false;
95
+
96
+ if (source === 'env') {
97
+ console.error(`Hypomnema vault not found at HYPO_DIR=${root} (no hypo-config.md).`);
98
+ process.exit(1);
50
99
  }
51
100
 
52
- return join(HOME, 'hypomnema');
101
+ console.error('No Hypomnema vault found. Set HYPO_DIR or run inside a vault; nothing to scan.');
102
+ return true;
53
103
  }
@@ -11,6 +11,7 @@ import { readFileSync, existsSync } from 'fs';
11
11
  import { join } from 'path';
12
12
  import { collectPagesCrystallize, extractWikilinks, slugForms } from './wikilink.mjs';
13
13
  import { parseFrontmatter } from './frontmatter.mjs';
14
+ import { currentDevice, scopeVisible, readVisibilityScope } from '../../hooks/hypo-shared.mjs';
14
15
 
15
16
  const PAGE_USAGE_REL = '.cache/page-usage.jsonl';
16
17
  const DAY_MS = 86400000;
@@ -69,7 +70,13 @@ export function readPageUsage(hypoDir) {
69
70
  // within the last thresholdDays.
70
71
  export function aggregateColdCandidates(
71
72
  hypoDir,
72
- { thresholdDays = 90, minLogSpanDays = 14, now = Date.now(), ignorePatterns = [] } = {},
73
+ {
74
+ thresholdDays = 90,
75
+ minLogSpanDays = 14,
76
+ now = Date.now(),
77
+ ignorePatterns = [],
78
+ device = currentDevice(),
79
+ } = {},
73
80
  ) {
74
81
  const records = readPageUsage(hypoDir);
75
82
  if (records.length === 0) return { status: 'insufficient-data', reason: 'no-records' };
@@ -110,6 +117,7 @@ export function aggregateColdCandidates(
110
117
  title: fm.title || slug,
111
118
  forms: formSet(slug),
112
119
  links: extractWikilinks(body),
120
+ scope: readVisibilityScope(content),
113
121
  });
114
122
  }
115
123
 
@@ -134,7 +142,12 @@ export function aggregateColdCandidates(
134
142
  }
135
143
 
136
144
  const candidates = pageList
137
- .filter((p) => hasInbound.has(p.slug) && !intersects(p.forms, recentForms))
145
+ .filter(
146
+ (p) =>
147
+ hasInbound.has(p.slug) &&
148
+ !intersects(p.forms, recentForms) &&
149
+ scopeVisible(p.scope, device),
150
+ )
138
151
  .map((p) => ({ slug: p.slug, title: p.title }));
139
152
 
140
153
  return { status: 'ok', candidates };
@@ -75,3 +75,43 @@ export function writePkgJsonAtomic(pkgJsonPath, data) {
75
75
  throw err;
76
76
  }
77
77
  }
78
+
79
+ // Non-mutating read of the version a candidate install root's own package.json
80
+ // carries, or null. Shared by writeDualSkipProvenance (what to stamp) and
81
+ // upgrade.mjs's read-only report (what to preview/display) so both agree.
82
+ export function readVersionAtRoot(root) {
83
+ try {
84
+ const v = JSON.parse(readFileSync(join(root, 'package.json'), 'utf-8')).version;
85
+ return typeof v === 'string' && v.length > 0 ? v : null;
86
+ } catch {
87
+ return null;
88
+ }
89
+ }
90
+
91
+ // Dual-install self-heal: writes a NARROW provenance correction to
92
+ // pkgJsonPath, pointing pkgRoot/pkgVersion at the plugin registry's own
93
+ // resolved root/version, while preserving every other existing field
94
+ // (including a prior `commands` map — that map reflects the PLUGIN's own
95
+ // install, not the caller's own run, so unlike a full plugin-mode metadata
96
+ // write it must NOT be dropped or touched here).
97
+ //
98
+ // TOCTOU guard: the caller (upgrade.mjs) resolves `registryRoot` as usable via
99
+ // a SEPARATE earlier read (resolveEnabledPluginRoot / usablePkgRoot) — that
100
+ // root's own package.json can vanish or corrupt between that resolution and
101
+ // this write. Re-reads the version HERE and refuses the correction outright if
102
+ // it cannot, rather than stamp a NEW pkgRoot with a STALE version (the old
103
+ // recorded one, or none): a wrong root+version pairing is worse than no
104
+ // correction. Returns false without writing anything when refused — the
105
+ // existing metadata is left exactly as it was, and the caller must not report
106
+ // this as "corrected".
107
+ export function writeDualSkipProvenance(pkgJsonPath, registryRoot) {
108
+ const registryVersion = readVersionAtRoot(registryRoot);
109
+ if (!registryVersion) return false;
110
+ const existing = readPkgJson(pkgJsonPath);
111
+ writePkgJsonAtomic(pkgJsonPath, {
112
+ ...existing,
113
+ pkgRoot: registryRoot,
114
+ pkgVersion: registryVersion,
115
+ });
116
+ return true;
117
+ }
@@ -15,7 +15,8 @@
15
15
  // legacy `hypomnema@<marketplace>` key in `enabledPlugins` until they reinstall
16
16
  // as `hypo@<marketplace>`, and the guard must hold across that gap.
17
17
 
18
- import { readFileSync } from 'node:fs';
18
+ import { readFileSync, existsSync } from 'node:fs';
19
+ import { isAbsolute, join } from 'node:path';
19
20
 
20
21
  /**
21
22
  * @param {string} settingsPath path to a Claude Code settings.json (e.g. ~/.claude/settings.json)
@@ -38,11 +39,18 @@ export function isHypomnemaPluginEnabled(settingsPath) {
38
39
  }
39
40
  if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return false;
40
41
 
41
- const enabled = parsed.enabledPlugins;
42
+ return enabledKeyFrom(parsed.enabledPlugins) !== null;
43
+ }
44
+
45
+ // The exact enabledPlugins KEY (`hypo@<marketplace>` / legacy
46
+ // `hypomnema@<marketplace>`) whose value is strictly true, or null. Same matching
47
+ // rules as isHypomnemaPluginEnabled, but returns the identifier so a caller can
48
+ // look that exact plugin up in the install registry rather than guessing among all
49
+ // hypo-named entries.
50
+ function enabledKeyFrom(enabled) {
42
51
  // enabledPlugins is an object map `{ "<name>@<marketplace>": true|false }`.
43
52
  // Anything else (absent, array, scalar) → not enabled.
44
- if (!enabled || typeof enabled !== 'object' || Array.isArray(enabled)) return false;
45
-
53
+ if (!enabled || typeof enabled !== 'object' || Array.isArray(enabled)) return null;
46
54
  for (const [key, value] of Object.entries(enabled)) {
47
55
  if (value !== true) continue; // strictly true only — no truthy coercion
48
56
  // Require a real `name@marketplace` shape: an `@` that is neither the first
@@ -54,7 +62,89 @@ export function isHypomnemaPluginEnabled(settingsPath) {
54
62
  const name = key.slice(0, at);
55
63
  // Match the current plugin name and the legacy one (pre-rename) so the
56
64
  // guard holds across the migration window. Exact, case-sensitive.
57
- if (name === 'hypo' || name === 'hypomnema') return true;
65
+ if (name === 'hypo' || name === 'hypomnema') return key;
66
+ }
67
+ return null;
68
+ }
69
+
70
+ /**
71
+ * @param {string} settingsPath path to a Claude Code settings.json
72
+ * @returns {string|null} the exact `enabledPlugins` key of the enabled Hypomnema
73
+ * plugin (`hypo@<marketplace>` or legacy `hypomnema@<marketplace>`), or null.
74
+ */
75
+ export function enabledHypomnemaPluginKey(settingsPath) {
76
+ let raw;
77
+ try {
78
+ raw = readFileSync(settingsPath, 'utf-8');
79
+ } catch {
80
+ return null;
81
+ }
82
+ let parsed;
83
+ try {
84
+ parsed = JSON.parse(raw);
85
+ } catch {
86
+ return null;
87
+ }
88
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
89
+ return enabledKeyFrom(parsed.enabledPlugins);
90
+ }
91
+
92
+ function readPkgVersionAt(root) {
93
+ try {
94
+ const v = JSON.parse(readFileSync(join(root, 'package.json'), 'utf-8')).version;
95
+ return typeof v === 'string' && v.length > 0 ? v : null;
96
+ } catch {
97
+ return null;
98
+ }
99
+ }
100
+
101
+ // A pkgRoot is "usable" as a DURABLE install root only if it is an ABSOLUTE path to
102
+ // a real package directory whose package.json carries a version. A relative path
103
+ // (e.g. installPath ".") would be resolved against the caller's cwd and break the
104
+ // vault git hook from any other directory; a version-less package.json cannot be
105
+ // attributed a version without lying. A bare path that merely exists is a pointer
106
+ // the runtime cannot resolve scripts through. Shared by init's registry resolution,
107
+ // its durable-root fallback, and upgrade's dualSkip provenance correction so they
108
+ // all agree on what is real.
109
+ export function usablePkgRoot(pkgRoot) {
110
+ return (
111
+ typeof pkgRoot === 'string' &&
112
+ pkgRoot.length > 0 &&
113
+ isAbsolute(pkgRoot) &&
114
+ existsSync(join(pkgRoot, 'package.json')) &&
115
+ readPkgVersionAt(pkgRoot) !== null
116
+ );
117
+ }
118
+
119
+ // Resolve the enabled Hypomnema plugin's REAL install root from the plugin
120
+ // registry (~/.claude/plugins/installed_plugins.json). POSITIVE attribution: it
121
+ // looks up the EXACT key that settingsPath marks enabled (via
122
+ // enabledHypomnemaPluginKey), not just any hypo-named entry — a disabled legacy or
123
+ // other-marketplace entry must never be selected. Among that key's registry
124
+ // entries it prefers the user-scope install (the one a plugin-enabled user runs),
125
+ // falling back to any usable entry. Returns a usable absolute install root, or
126
+ // null when the registry is absent/unreadable, names no entry for the enabled key,
127
+ // or that entry is not a usable package dir. Fails open (null) on every
128
+ // uncertainty — callers must treat null as "cannot positively resolve", never as
129
+ // "resolved to nothing usable exists".
130
+ export function resolveEnabledPluginRoot(settingsPath, registryPath) {
131
+ const key = enabledHypomnemaPluginKey(settingsPath);
132
+ if (!key) return null;
133
+ let reg;
134
+ try {
135
+ reg = JSON.parse(readFileSync(registryPath, 'utf-8'));
136
+ } catch {
137
+ return null;
58
138
  }
59
- return false;
139
+ const plugins =
140
+ reg && typeof reg.plugins === 'object' && !Array.isArray(reg.plugins) ? reg.plugins : null;
141
+ const entries = plugins && Array.isArray(plugins[key]) ? plugins[key] : null;
142
+ if (!entries) return null;
143
+ const paths = (scope) =>
144
+ entries
145
+ .filter((e) => e && (scope === undefined || e.scope === scope))
146
+ .map((e) => (typeof e.installPath === 'string' ? e.installPath : null))
147
+ .filter((p) => usablePkgRoot(p));
148
+ // Prefer the user-scope install, then any usable entry for this exact key.
149
+ return paths('user')[0] ?? paths(undefined)[0] ?? null;
60
150
  }
@@ -72,7 +72,12 @@ function lastSegment(p) {
72
72
  * @returns {string|null} matched slug, or null when nothing matches.
73
73
  */
74
74
  export function pickProjectByCwd(projects, cwd, opts = {}) {
75
- const { eligible = null, realpathCwd = null, caseInsensitive = isCaseInsensitiveFs() } = opts;
75
+ const {
76
+ eligible = null,
77
+ realpathCwd = null,
78
+ caseInsensitive = isCaseInsensitiveFs(),
79
+ rejectAmbiguous = false,
80
+ } = opts;
76
81
  if (!cwd && !realpathCwd) return null;
77
82
 
78
83
  const eligibleSet = eligible ? new Set(eligible) : null;
@@ -98,19 +103,32 @@ export function pickProjectByCwd(projects, cwd, opts = {}) {
98
103
  // ── Tier 1: longest absolute prefix (the original behavior) ────────────────
99
104
  // cwd === path or cwd under path/. Longest path wins so /repo/sub beats /repo.
100
105
  // The FIRST cwd variant that yields any prefix match wins (raw before realpath).
106
+ // With rejectAmbiguous (session-cwd close check), two DISTINCT projects sharing the same
107
+ // longest matching working_dir (a monorepo config) is a real tie we must not
108
+ // break arbitrarily — decline to null so the caller degrades to the
109
+ // unresolved-cwd path instead of attributing the close to the wrong project.
101
110
  for (const c of cwds) {
102
111
  const cf = casefold(c, caseInsensitive);
103
112
  let bestSlug = null;
104
113
  let bestLen = -1;
114
+ let bestTied = false;
105
115
  for (const e of entries) {
106
116
  if (!isEligible(e.slug)) continue;
107
117
  const pf = casefold(e.path, caseInsensitive);
108
- if ((cf === pf || cf.startsWith(`${pf}/`)) && e.path.length > bestLen) {
109
- bestLen = e.path.length;
110
- bestSlug = e.slug;
118
+ if (cf === pf || cf.startsWith(`${pf}/`)) {
119
+ if (e.path.length > bestLen) {
120
+ bestLen = e.path.length;
121
+ bestSlug = e.slug;
122
+ bestTied = false;
123
+ } else if (e.path.length === bestLen && e.slug !== bestSlug) {
124
+ bestTied = true;
125
+ }
111
126
  }
112
127
  }
113
- if (bestSlug) return bestSlug;
128
+ if (bestSlug) {
129
+ if (bestTied && rejectAmbiguous) return null;
130
+ return bestSlug;
131
+ }
114
132
  }
115
133
 
116
134
  // ── Tier 2: globally-unique basename of a cwd ancestor (cross-machine) ─────
@@ -8,21 +8,24 @@
8
8
  // - scripts/crystallize.mjs (page collection, link extraction)
9
9
  //
10
10
  // Shared-resolver consolidation. Before this lib, each script carried its own copy of
11
- // collectPages + a slug-form deriver, drifting in subtle ways. The four
11
+ // collectPages + a slug-form deriver, drifting in subtle ways. The five
12
12
  // collectPages variants are NOT interchangeable — they differ deliberately in
13
13
  // traversal/security/output-shape policy (codex design review, CONCERN):
14
14
  //
15
15
  // - rename uses lstat + skips symlinks → a vault scan can never escape via a
16
16
  // symlinked dir/file (security boundary).
17
17
  // - lint skips `_`-prefixed DIRECTORIES → pages/feedback/_drafts scaffolds
18
- // stay out of the lint set, while `_`-prefixed FILES (e.g. _index.md) lint.
18
+ // stay out of the lint SET, while `_`-prefixed FILES (e.g. _index.md) lint.
19
+ // - linkable is lint WITHOUT that dir skip → the same pages stay reachable as
20
+ // wikilink destinations. Not linting a page and not being able to link to it
21
+ // are separate policies; lint feeds this into its link-target catalog only.
19
22
  // - crystallize skips ANY `.`-prefixed entry (dir AND file).
20
- // - graph/lint/rename skip only `.`-prefixed FILES.
21
- // - graph/crystallize emit raw `rel`/`slug`; lint keeps OS-native `rel`
22
- // (its own buildSlugMap POSIX-normalizes downstream); rename/graph
23
+ // - graph/lint/linkable/rename skip only `.`-prefixed FILES.
24
+ // - graph/crystallize emit raw `rel`/`slug`; lint/linkable keep OS-native `rel`
25
+ // (lint's buildSlugMap POSIX-normalizes downstream); rename/graph
23
26
  // POSIX-normalize the slug at scan time.
24
27
  //
25
- // So this lib exposes ONE walker core plus four NAMED PRESETS, not a pile of
28
+ // So this lib exposes ONE walker core plus five NAMED PRESETS, not a pile of
26
29
  // boolean flags — each preset pins exactly one caller's historical behavior and
27
30
  // is fixed by a unit test. readdirSync order is preserved verbatim (NO sort):
28
31
  // graph's bare-first slug index is order-sensitive (first page wins a shared
@@ -81,6 +84,29 @@ export function collectPagesLint(dir, root, ignorePatterns = []) {
81
84
  );
82
85
  }
83
86
 
87
+ // lint link-target catalog: collectPagesLint WITHOUT the `_`-dir skip.
88
+ //
89
+ // Not linting a page and not being able to link to it are different things. The
90
+ // `_`-dir skip exists so draft/spec scaffolds don't have to satisfy the schema —
91
+ // it was never meant to make them unreachable. Sharing one list for both jobs
92
+ // made lint report a live file as a broken wikilink, and under --strict that is
93
+ // an error, so a vault could not reach a green gate no matter how clean it was.
94
+ // lint feeds this into buildSlugMap's extraTargets (verbatim full slugs, no bare
95
+ // or dir-relative aliases): `_specs/<name>/spec.md` repeats across specs, and a
96
+ // bare `spec` alias would swallow unrelated broken links.
97
+ export function collectPagesLinkable(dir, root, ignorePatterns = []) {
98
+ return walkMarkdown(
99
+ dir,
100
+ root,
101
+ ignorePatterns,
102
+ {
103
+ skipDotFile: true,
104
+ shape: (full) => ({ path: full, rel: relative(root, full) }),
105
+ },
106
+ [],
107
+ );
108
+ }
109
+
84
110
  // graph: POSIX slug + bare, no `rel`.
85
111
  export function collectPagesGraph(dir, root, ignorePatterns = []) {
86
112
  return walkMarkdown(
package/scripts/lint.mjs CHANGED
@@ -18,7 +18,7 @@
18
18
 
19
19
  import { existsSync, readFileSync, writeFileSync, readdirSync, statSync } from 'fs';
20
20
  import { join, extname, basename } from 'path';
21
- import { resolveHypoRoot, expandHome } from './lib/hypo-root.mjs';
21
+ import { resolveHypoRootInfo, checkVaultOrExit, expandHome } from './lib/hypo-root.mjs';
22
22
  import { SESSION_STATE_NEXT_HEADINGS } from '../hooks/hypo-shared.mjs';
23
23
  import { loadHypoIgnore, isScanIgnored } from './lib/hypo-ignore.mjs';
24
24
  import {
@@ -30,7 +30,7 @@ import {
30
30
  import { findDesignHistoryStale } from './lib/design-history-stale.mjs';
31
31
  import { FEEDBACK_SCOPE_RE } from './lib/feedback-scope.mjs';
32
32
  import { FAILURE_TYPE_ENUM } from './lib/failure-type.mjs';
33
- import { collectPagesLint, slugForms } from './lib/wikilink.mjs';
33
+ import { collectPagesLint, collectPagesLinkable, slugForms } from './lib/wikilink.mjs';
34
34
  import { parseFrontmatter, SEQUENCE_ENTRY_RE } from './lib/frontmatter.mjs';
35
35
 
36
36
  // ── arg parsing ──────────────────────────────────────────────────────────────
@@ -43,7 +43,11 @@ function parseArgs(argv) {
43
43
  else if (arg === '--fix') args.fix = true;
44
44
  else if (arg === '--strict') args.strict = true;
45
45
  }
46
- if (!args.hypoDir) args.hypoDir = resolveHypoRoot();
46
+ if (!args.hypoDir) {
47
+ const info = resolveHypoRootInfo();
48
+ args.hypoDir = info.root;
49
+ args.hypoDirSource = info.source;
50
+ }
47
51
  return args;
48
52
  }
49
53
 
@@ -483,12 +487,24 @@ function lintPage({ path, rel }, slugMap, tagVocab, pageDirs, validTypes) {
483
487
  // ── main ──────────────────────────────────────────────────────────────────────
484
488
 
485
489
  const args = parseArgs(process.argv);
490
+ // Only validate the auto-resolved path (env/marker/default). An explicit
491
+ // --hypo-dir=<path> (tests, other tooling) is trusted as-is, valid or not.
492
+ if (args.hypoDirSource) checkVaultOrExit(args.hypoDir, args.hypoDirSource);
486
493
 
487
494
  const ignorePatterns = loadHypoIgnore(args.hypoDir);
488
495
  const scanDirs = ['pages', 'projects', 'journal'].map((d) => join(args.hypoDir, d));
489
496
  const pages = scanDirs.flatMap((d) => collectPagesLint(d, args.hypoDir, ignorePatterns));
497
+ // Pages under `_`-prefixed dirs are deliberately not linted, but they are real
498
+ // files and a link to one is not broken. Catalog them as link targets only, as
499
+ // verbatim full slugs (extraTargets gets no derived aliases — a bare `spec` from
500
+ // every `_specs/<name>/spec.md` would mask unrelated broken links).
501
+ const lintedRels = new Set(pages.map((p) => p.rel));
502
+ const underscoreDirTargets = scanDirs
503
+ .flatMap((d) => collectPagesLinkable(d, args.hypoDir, ignorePatterns))
504
+ .filter((p) => !lintedRels.has(p.rel))
505
+ .map((p) => p.rel.replace(/\.md$/, '').replace(/\\/g, '/'));
490
506
  const linkTargets = collectLinkTargets(args.hypoDir, ignorePatterns);
491
- const slugMap = buildSlugMap(pages, linkTargets);
507
+ const slugMap = buildSlugMap(pages, [...linkTargets, ...underscoreDirTargets]);
492
508
  const tagVocab = parseSchemaVocab(args.hypoDir);
493
509
  const pageDirs = parseSchemaPageDirs(args.hypoDir);
494
510
  // Accepted page types = hardcoded core ∪ the vault SCHEMA's taxonomy. Union (not