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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.ko.md +39 -14
- package/README.md +39 -14
- package/commands/capture.md +8 -6
- package/commands/crystallize.md +39 -20
- package/docs/ARCHITECTURE.md +49 -14
- package/docs/CONTRIBUTING.md +31 -29
- package/hooks/base-store.mjs +265 -0
- package/hooks/hooks.json +18 -1
- package/hooks/hypo-auto-commit.mjs +92 -15
- package/hooks/hypo-auto-minimal-crystallize.mjs +63 -20
- package/hooks/hypo-auto-stage.mjs +41 -1
- package/hooks/hypo-close-guard.mjs +246 -0
- package/hooks/hypo-cwd-change.mjs +31 -2
- package/hooks/hypo-file-watch.mjs +21 -2
- package/hooks/hypo-first-prompt.mjs +19 -3
- package/hooks/hypo-hot-rebuild.mjs +43 -5
- package/hooks/hypo-lookup.mjs +86 -29
- package/hooks/hypo-personal-check.mjs +24 -3
- package/hooks/hypo-session-record.mjs +2 -3
- package/hooks/hypo-session-start.mjs +199 -20
- package/hooks/hypo-shared.mjs +2080 -176
- package/hooks/proposal-store.mjs +513 -0
- package/hooks/version-check.mjs +45 -0
- package/package.json +42 -14
- package/scripts/capture.mjs +556 -37
- package/scripts/crystallize.mjs +751 -110
- package/scripts/doctor.mjs +787 -26
- package/scripts/feedback-sync.mjs +515 -44
- package/scripts/graph.mjs +35 -13
- package/scripts/init.mjs +287 -41
- package/scripts/lib/extensions.mjs +656 -1
- package/scripts/lib/git-hooks-dir.mjs +229 -0
- package/scripts/lib/hypo-ignore.mjs +54 -6
- package/scripts/lib/hypo-root.mjs +56 -6
- package/scripts/lib/page-usage.mjs +15 -2
- package/scripts/lib/pkg-json.mjs +40 -0
- package/scripts/lib/plugin-detect.mjs +96 -6
- package/scripts/lib/project-create.mjs +5 -1
- package/scripts/lib/rename-marker.mjs +39 -0
- package/scripts/lib/wd-match.mjs +23 -5
- package/scripts/lib/wikilink.mjs +32 -6
- package/scripts/lint.mjs +103 -5
- package/scripts/proposal.mjs +1032 -0
- package/scripts/query.mjs +25 -4
- package/scripts/rename.mjs +223 -18
- package/scripts/resume.mjs +34 -12
- package/scripts/stats.mjs +41 -9
- package/scripts/uninstall.mjs +141 -6
- package/scripts/upgrade.mjs +197 -15
- package/skills/crystallize/SKILL.md +44 -7
- package/skills/debate/SKILL.md +88 -0
- package/skills/debate/references/orchestration-patterns.md +83 -0
- package/templates/.hyposcanignore +10 -0
- package/templates/SCHEMA.md +12 -0
- package/templates/gitignore +9 -0
- package/templates/hypo-config.md +1 -1
- package/templates/hypo-guide.md +6 -0
- package/scripts/.gitkeep +0 -0
- package/scripts/check-bilingual.mjs +0 -153
- package/scripts/check-readme-version.mjs +0 -126
- package/scripts/check-tracker-ids.mjs +0 -426
- package/scripts/check-versions.mjs +0 -171
- package/scripts/install-git-hooks.mjs +0 -293
- package/scripts/lib/changelog-classify.mjs +0 -216
- package/scripts/lib/check-bilingual.mjs +0 -244
- package/scripts/lib/check-tracker-ids.mjs +0 -217
- package/scripts/lib/pre-commit-format.mjs +0 -251
- 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
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
//
|
|
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
|
|
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
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
{
|
|
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(
|
|
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 };
|
package/scripts/lib/pkg-json.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|