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.
- 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 +7 -1
- package/hooks/hypo-auto-minimal-crystallize.mjs +63 -20
- package/hooks/hypo-auto-stage.mjs +30 -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-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 +89 -7
- package/hooks/hypo-shared.mjs +904 -128
- package/hooks/proposal-store.mjs +513 -0
- package/package.json +40 -14
- package/scripts/capture.mjs +556 -37
- package/scripts/crystallize.mjs +639 -108
- package/scripts/doctor.mjs +304 -9
- package/scripts/feedback-sync.mjs +515 -44
- package/scripts/graph.mjs +9 -2
- package/scripts/init.mjs +230 -34
- package/scripts/lib/extensions.mjs +656 -1
- 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/wd-match.mjs +23 -5
- package/scripts/lib/wikilink.mjs +32 -6
- package/scripts/lint.mjs +20 -4
- package/scripts/proposal.mjs +1032 -0
- package/scripts/query.mjs +25 -4
- package/scripts/resume.mjs +34 -12
- package/scripts/stats.mjs +28 -8
- 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 +5 -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
|
@@ -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
|
}
|
package/scripts/lib/wd-match.mjs
CHANGED
|
@@ -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 {
|
|
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 (
|
|
109
|
-
|
|
110
|
-
|
|
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)
|
|
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) ─────
|
package/scripts/lib/wikilink.mjs
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
22
|
-
// (
|
|
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
|
|
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 {
|
|
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)
|
|
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
|