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
@@ -0,0 +1,229 @@
1
+ /**
2
+ * Resolve a repository's active git hooks directory by asking git, never by
3
+ * assuming the on-disk layout.
4
+ *
5
+ * The layout assumption this replaces (`join(root, '.git', 'hooks')`) is wrong
6
+ * in two ways that both showed up in practice:
7
+ *
8
+ * 1. In a linked worktree `.git` is a regular FILE holding `gitdir: <path>`,
9
+ * so `existsSync` passes and `mkdirSync` dies with ENOTDIR.
10
+ * 2. When `core.hooksPath` is set, git does not read `.git/hooks` at all, so
11
+ * a hook written there is inert.
12
+ *
13
+ * `git rev-parse --git-path hooks` handles both: it follows the worktree's
14
+ * gitdir pointer AND substitutes `core.hooksPath` (verified on git 2.50.1 —
15
+ * `/dev/null` stays `/dev/null`, a relative value stays relative, and `~` is
16
+ * expanded). That makes rev-parse the single authority here; reading
17
+ * `core.hooksPath` out of the config ourselves would be strictly worse, since
18
+ * it would leave `~` unexpanded and could not tell an empty-but-set value from
19
+ * an unset one.
20
+ *
21
+ * Two things rev-parse does NOT give us, so we add them:
22
+ *
23
+ * - `git -C <root>` does not neutralize ambient git environment variables.
24
+ * `GIT_DIR` + `GIT_WORK_TREE` redirect the probe at a foreign repository,
25
+ * and `GIT_CONFIG_COUNT`/`GIT_CONFIG_PARAMETERS` redirect it at an
26
+ * arbitrary hooks path. Every probe therefore runs under a scrubbed env,
27
+ * with the scrub list taken from git's own `--local-env-vars` when
28
+ * available.
29
+ * - `core.hooksPath` may point at a directory SHARED by many repositories
30
+ * (the documented centralized-hooks pattern). Auto-installing there would
31
+ * put our hook in front of unrelated repositories' commits, and our
32
+ * post-commit executes `$REPO_ROOT/scripts/upgrade.mjs` dynamically. So the
33
+ * result carries an `owned` flag, and callers that WRITE must refuse when
34
+ * it is false. Callers that only READ (doctor) may report the path.
35
+ */
36
+
37
+ import { execFileSync } from 'child_process';
38
+ import { existsSync, lstatSync, realpathSync, statSync } from 'fs';
39
+ import { basename, dirname, isAbsolute, join, resolve, sep } from 'path';
40
+
41
+ // Fallback scrub list for git versions without `rev-parse --local-env-vars`.
42
+ // Mirrors scripts/install-git-hooks.mjs, which established this trust model.
43
+ const STATIC_LOCAL_ENV_VARS = [
44
+ 'GIT_DIR',
45
+ 'GIT_WORK_TREE',
46
+ 'GIT_INDEX_FILE',
47
+ 'GIT_OBJECT_DIRECTORY',
48
+ 'GIT_ALTERNATE_OBJECT_DIRECTORIES',
49
+ 'GIT_COMMON_DIR',
50
+ 'GIT_CONFIG',
51
+ 'GIT_CONFIG_PARAMETERS',
52
+ 'GIT_PREFIX',
53
+ 'GIT_IMPLICIT_WORK_TREE',
54
+ 'GIT_GRAFT_FILE',
55
+ 'GIT_NO_REPLACE_OBJECTS',
56
+ 'GIT_REPLACE_REF_BASE',
57
+ 'GIT_SHALLOW_FILE',
58
+ ];
59
+
60
+ function buildScrubbedEnv(localEnvList) {
61
+ const scrub = new Set([
62
+ ...(localEnvList || STATIC_LOCAL_ENV_VARS),
63
+ 'GIT_NAMESPACE',
64
+ 'GIT_CEILING_DIRECTORIES',
65
+ // GIT_CONFIG_COUNT / GIT_CONFIG_KEY_n / GIT_CONFIG_VALUE_n inject config
66
+ // wholesale and are not always listed by --local-env-vars.
67
+ ...Object.keys(process.env).filter((k) => /^GIT_CONFIG_/.test(k)),
68
+ ]);
69
+ return Object.fromEntries(Object.entries(process.env).filter(([k]) => !scrub.has(k)));
70
+ }
71
+
72
+ // Canonicalize a path that may not exist yet: realpath the deepest existing
73
+ // ancestor and re-append the rest. Without this, a hooks dir git will create
74
+ // lazily could evade the containment check via an unresolved symlinked parent.
75
+ function canonicalize(p) {
76
+ let cur = resolve(p);
77
+ const tail = [];
78
+ for (;;) {
79
+ try {
80
+ return join(realpathSync(cur), ...tail);
81
+ } catch {
82
+ const parent = dirname(cur);
83
+ if (parent === cur) return resolve(p); // hit the root; nothing to resolve
84
+ // basename, not a slice: when the parent IS the root it already ends in a
85
+ // separator, so `parent.length + 1` would eat the first real character
86
+ // ("/Nope/x" -> "ope/x") and could rewrite an external path into one that
87
+ // looks repository-owned.
88
+ tail.unshift(basename(cur));
89
+ cur = parent;
90
+ }
91
+ }
92
+ }
93
+
94
+ function isInside(child, parent) {
95
+ return child === parent || child.startsWith(parent + sep);
96
+ }
97
+
98
+ /**
99
+ * @param {string} repoRoot working tree root to probe
100
+ * @param {{timeoutMs?: number}} [opts]
101
+ * @returns {{ok: true, path: string, owned: boolean, gitDir: string, commonDir: string}
102
+ * |{ok: false, reason: string, detail?: string, path?: string}}
103
+ *
104
+ * Failure reasons are deliberately distinct so callers can react differently:
105
+ * not-a-repo no `.git` entry (also the case for a bare repo — matches
106
+ * the pre-existing behavior of every call site)
107
+ * git-unavailable git is not on PATH / not executable
108
+ * probe-failed git ran but could not resolve the repo (stale `.git`
109
+ * pointer, dubious ownership, timeout, ...)
110
+ * hooks-disabled the active hooks path is `/dev/null` or an existing
111
+ * non-directory — git's documented way to disable hooks
112
+ */
113
+ export function resolveGitHooksDir(repoRoot, { timeoutMs = 5000 } = {}) {
114
+ if (!existsSync(join(repoRoot, '.git'))) return { ok: false, reason: 'not-a-repo' };
115
+
116
+ let env = buildScrubbedEnv(null);
117
+ const run = (args) =>
118
+ execFileSync('git', args, {
119
+ encoding: 'utf-8',
120
+ env,
121
+ cwd: repoRoot,
122
+ timeout: timeoutMs,
123
+ stdio: ['ignore', 'pipe', 'ignore'],
124
+ maxBuffer: 1024 * 1024,
125
+ }).trim();
126
+
127
+ try {
128
+ // Enrich the scrub list from git's own truth when this git supports it.
129
+ try {
130
+ env = buildScrubbedEnv(run(['rev-parse', '--local-env-vars']).split(/\r?\n/).filter(Boolean));
131
+ } catch {
132
+ // Old git without --local-env-vars; the static list already applied.
133
+ }
134
+
135
+ const gitDir = canonicalize(run(['rev-parse', '--absolute-git-dir']));
136
+ const rawCommon = run(['rev-parse', '--git-common-dir']);
137
+ // A relative --git-common-dir is relative to the command's cwd, which we
138
+ // pinned to repoRoot.
139
+ const commonDir = canonicalize(isAbsolute(rawCommon) ? rawCommon : join(repoRoot, rawCommon));
140
+ const topLevel = canonicalize(run(['rev-parse', '--show-toplevel']));
141
+
142
+ // --path-format is git 2.31+. Fall back to the plain form, whose output is
143
+ // relative to cwd (= repoRoot) when core.hooksPath is relative.
144
+ let raw;
145
+ try {
146
+ raw = run(['rev-parse', '--path-format=absolute', '--git-path', 'hooks']);
147
+ } catch {
148
+ raw = run(['rev-parse', '--git-path', 'hooks']);
149
+ }
150
+ if (!raw) return { ok: false, reason: 'probe-failed', detail: 'empty hooks path' };
151
+
152
+ const hooksPath = canonicalize(isAbsolute(raw) ? raw : join(repoRoot, raw));
153
+
154
+ // git documents core.hooksPath=/dev/null as "disable all hooks". Treat any
155
+ // existing non-directory the same way rather than failing on mkdir later.
156
+ if (existsSync(hooksPath) && !statSync(hooksPath).isDirectory()) {
157
+ return { ok: false, reason: 'hooks-disabled', path: hooksPath };
158
+ }
159
+
160
+ // Repository-owned means: inside this repo's git directory (the normal
161
+ // `.git/hooks`, and in a linked worktree the shared common dir) or inside
162
+ // the working tree itself (the `core.hooksPath=.githooks` convention).
163
+ // Anything else is a location we do not own and must not write into.
164
+ const owned =
165
+ isInside(hooksPath, commonDir) ||
166
+ isInside(hooksPath, gitDir) ||
167
+ isInside(hooksPath, topLevel);
168
+
169
+ return { ok: true, path: hooksPath, owned, gitDir, commonDir };
170
+ } catch (e) {
171
+ if (e && e.code === 'ENOENT') return { ok: false, reason: 'git-unavailable' };
172
+ return { ok: false, reason: 'probe-failed', detail: e && (e.code || e.message) };
173
+ }
174
+ }
175
+
176
+ /**
177
+ * Guard the final hook ENTRY, not just the directory it lives in.
178
+ *
179
+ * An owned hooks directory can still contain a symlink pointing anywhere, and
180
+ * `writeFileSync` follows symlinks. Three ways that escapes the boundary the
181
+ * directory check appears to establish:
182
+ * - a live symlink to an external file gets its TARGET overwritten;
183
+ * - if that target happens to carry our managed marker, it is rewritten even
184
+ * without --force-commands;
185
+ * - a DANGLING symlink reads as absent through `existsSync`, so the "not
186
+ * installed yet" path creates the external target outright.
187
+ * So refuse to write through any symlink, and refuse anything that is not a
188
+ * regular file. Callers log the reason and move on.
189
+ *
190
+ * @returns {null | string} null when writing is safe, else a reason to log
191
+ */
192
+ export function unsafeHookTargetReason(hookPath) {
193
+ let st;
194
+ try {
195
+ st = lstatSync(hookPath);
196
+ } catch (e) {
197
+ if (e && e.code === 'ENOENT') return null; // genuinely absent — safe to create
198
+ return `cannot stat (${e.code || e.message})`;
199
+ }
200
+ if (st.isSymbolicLink()) return 'is a symlink — refusing to write through it';
201
+ if (!st.isFile()) return 'exists but is not a regular file';
202
+ return null;
203
+ }
204
+
205
+ /**
206
+ * Write-side wrapper: the hooks directory only if it is safe to install into.
207
+ * Returns `{dir}` when installing is allowed, otherwise `{skip}` carrying a
208
+ * human-readable reason for the caller to log.
209
+ */
210
+ export function hooksDirForInstall(repoRoot, opts) {
211
+ const r = resolveGitHooksDir(repoRoot, opts);
212
+ if (!r.ok) {
213
+ if (r.reason === 'not-a-repo') return { skip: null }; // silent, as before
214
+ if (r.reason === 'hooks-disabled') {
215
+ // Do not name core.hooksPath here: the same branch fires for a plain
216
+ // .git/hooks that happens to be a regular file, where no such setting
217
+ // exists and naming it would send the user hunting for a phantom config.
218
+ return { skip: `hooks path is not a directory, so git runs no hooks (${r.path})` };
219
+ }
220
+ if (r.reason === 'git-unavailable') return { skip: 'git not available on PATH' };
221
+ return { skip: `could not resolve hooks dir (${r.detail || r.reason})` };
222
+ }
223
+ if (!r.owned) {
224
+ return {
225
+ skip: `core.hooksPath points outside this repository (${r.path}) — refusing to install into a shared hooks directory`,
226
+ };
227
+ }
228
+ return { dir: r.path };
229
+ }
@@ -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
  }
@@ -28,7 +28,11 @@ import { resolveHypoRoot, expandHome } from './hypo-root.mjs';
28
28
 
29
29
  const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
30
30
  const PKG_ROOT = join(SCRIPT_DIR, '..', '..');
31
- const TEMPLATE_DIR = join(PKG_ROOT, 'templates', 'projects', '_template');
31
+ // Exported so other project-scaffolding call sites (crystallize.mjs's
32
+ // session-close apply, which fills a MISSING index.md on a project that
33
+ // bypassed createProject entirely) resolve the same template dir instead of
34
+ // re-deriving the path.
35
+ export const TEMPLATE_DIR = join(PKG_ROOT, 'templates', 'projects', '_template');
32
36
 
33
37
  const TEMPLATE_FILES = ['index.md', 'prd.md', 'hot.md', 'session-state.md'];
34
38
 
@@ -0,0 +1,39 @@
1
+ // scripts/lib/rename-marker.mjs
2
+ //
3
+ // The crash-recovery marker for rename.mjs --apply. Shared read-only path/parse
4
+ // logic between the writer (rename.mjs) and the reader (doctor.mjs) so the two
5
+ // never drift on where the marker lives or what shape it is in.
6
+ //
7
+ // Kept under .cache/ deliberately, matching every other runtime marker this repo
8
+ // already writes there (sync-state.json, session-closed-*.marker, proposals/,
9
+ // project-suggestions.json). Two things fall out of that placement for free:
10
+ // - it is JSON, not .md, so every vault walker (scripts/lib/wikilink.mjs) that
11
+ // only ever collects .md files simply never sees it — no lint/rename/graph
12
+ // scan needs to know this file exists.
13
+ // - .cache/ ships in templates/.hypoignore's default pattern list, so a scan
14
+ // that DOES walk directories rather than just filtering by extension
15
+ // (doctor's own checkBrokenLinks walker) is also covered on any vault that
16
+ // carries that baseline — which every vault already needs, since .cache/
17
+ // holds the pre-commit secret-gate's own state.
18
+ // No .hyposcanignore entry or scan-logic change was needed to get this property;
19
+ // it comes from where the file lives, not from a new exclusion rule.
20
+ import { existsSync, readFileSync } from 'fs';
21
+ import { join } from 'path';
22
+
23
+ export const RENAME_MARKER_REL = '.cache/rename-in-progress.json';
24
+
25
+ export function renameMarkerPath(hypoDir) {
26
+ return join(hypoDir, RENAME_MARKER_REL);
27
+ }
28
+
29
+ // Best-effort read: a missing or corrupt marker both mean "nothing to report" —
30
+ // this runs inside doctor's health scan and must never throw.
31
+ export function readRenameMarker(hypoDir) {
32
+ const path = renameMarkerPath(hypoDir);
33
+ if (!existsSync(path)) return null;
34
+ try {
35
+ return JSON.parse(readFileSync(path, 'utf-8'));
36
+ } catch {
37
+ return null;
38
+ }
39
+ }