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
package/scripts/graph.mjs
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
|
|
17
17
|
import { readFileSync } from 'fs';
|
|
18
18
|
import { join } from 'path';
|
|
19
|
-
import {
|
|
19
|
+
import { resolveHypoRootInfo, checkVaultOrExit, expandHome } from './lib/hypo-root.mjs';
|
|
20
20
|
import { loadHypoIgnore } from './lib/hypo-ignore.mjs';
|
|
21
21
|
import { collectPagesGraph, extractWikilinks } from './lib/wikilink.mjs';
|
|
22
22
|
|
|
@@ -29,7 +29,11 @@ function parseArgs(argv) {
|
|
|
29
29
|
else if (arg.startsWith('--format=')) args.format = arg.slice(9);
|
|
30
30
|
else if (arg.startsWith('--min-edges=')) args.minEdges = parseInt(arg.slice(12), 10) || 0;
|
|
31
31
|
}
|
|
32
|
-
if (!args.hypoDir)
|
|
32
|
+
if (!args.hypoDir) {
|
|
33
|
+
const info = resolveHypoRootInfo();
|
|
34
|
+
args.hypoDir = info.root;
|
|
35
|
+
args.hypoDirSource = info.source;
|
|
36
|
+
}
|
|
33
37
|
return args;
|
|
34
38
|
}
|
|
35
39
|
|
|
@@ -87,7 +91,7 @@ function escapeDot(s) {
|
|
|
87
91
|
|
|
88
92
|
// ── formatters ────────────────────────────────────────────────────────────────
|
|
89
93
|
|
|
90
|
-
function formatJson(pages, graph, minEdges) {
|
|
94
|
+
function formatJson(pages, graph, minEdges, vaultFound) {
|
|
91
95
|
const nodes = pages
|
|
92
96
|
.map((p) => ({
|
|
93
97
|
slug: p.slug,
|
|
@@ -103,7 +107,9 @@ function formatJson(pages, graph, minEdges) {
|
|
|
103
107
|
return fn && tn;
|
|
104
108
|
});
|
|
105
109
|
|
|
106
|
-
|
|
110
|
+
// vaultFound: same reasoning as lint.mjs/stats.mjs — an empty graph from a
|
|
111
|
+
// vault that was never found must not read the same as a real, linkless one.
|
|
112
|
+
return JSON.stringify({ nodes, edges, vaultFound }, null, 2);
|
|
107
113
|
}
|
|
108
114
|
|
|
109
115
|
function formatMermaid(pages, graph, minEdges) {
|
|
@@ -150,6 +156,13 @@ function formatDot(pages, graph, minEdges) {
|
|
|
150
156
|
// ── main ──────────────────────────────────────────────────────────────────────
|
|
151
157
|
|
|
152
158
|
const args = parseArgs(process.argv);
|
|
159
|
+
// Only validate the auto-resolved path (env/marker/default). An explicit
|
|
160
|
+
// --hypo-dir=<path> (tests, other tooling) is trusted as-is, valid or not.
|
|
161
|
+
// See lint.mjs's matching comment: source 'default'/stale-'marker' stays
|
|
162
|
+
// exit-0 (CI-safe).
|
|
163
|
+
const vaultMissing = args.hypoDirSource
|
|
164
|
+
? checkVaultOrExit(args.hypoDir, args.hypoDirSource)
|
|
165
|
+
: false;
|
|
153
166
|
|
|
154
167
|
const ignorePatterns = loadHypoIgnore(args.hypoDir);
|
|
155
168
|
const scanDirs = ['pages', 'projects'].map((d) => join(args.hypoDir, d));
|
|
@@ -157,13 +170,22 @@ const pages = scanDirs.flatMap((d) => collectPagesGraph(d, args.hypoDir, ignoreP
|
|
|
157
170
|
const slugIndex = buildSlugIndex(pages);
|
|
158
171
|
const graph = buildGraph(pages, slugIndex);
|
|
159
172
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
173
|
+
if (vaultMissing && (args.format === 'mermaid' || args.format === 'dot')) {
|
|
174
|
+
// Same reasoning as lint.mjs/stats.mjs: mermaid/dot render straight to a
|
|
175
|
+
// diagram tool with no JSON envelope to carry `vaultFound`, so a valid but
|
|
176
|
+
// linkless diagram from a real (empty) vault and "no vault was found at
|
|
177
|
+
// all" would otherwise render identically. checkVaultOrExit already put the
|
|
178
|
+
// "No Hypomnema vault found" notice on stderr; suppress the stdout diagram
|
|
179
|
+
// rather than hand the renderer an empty-but-"real"-looking graph.
|
|
180
|
+
} else {
|
|
181
|
+
switch (args.format) {
|
|
182
|
+
case 'mermaid':
|
|
183
|
+
console.log(formatMermaid(pages, graph, args.minEdges));
|
|
184
|
+
break;
|
|
185
|
+
case 'dot':
|
|
186
|
+
console.log(formatDot(pages, graph, args.minEdges));
|
|
187
|
+
break;
|
|
188
|
+
default:
|
|
189
|
+
console.log(formatJson(pages, graph, args.minEdges, !vaultMissing));
|
|
190
|
+
}
|
|
169
191
|
}
|
package/scripts/init.mjs
CHANGED
|
@@ -32,12 +32,13 @@ import {
|
|
|
32
32
|
renameSync,
|
|
33
33
|
unlinkSync,
|
|
34
34
|
} from 'fs';
|
|
35
|
-
import { join, basename, dirname } from 'path';
|
|
35
|
+
import { join, basename, dirname, resolve } from 'path';
|
|
36
36
|
import { homedir } from 'os';
|
|
37
37
|
import { execSync, spawnSync } from 'child_process';
|
|
38
38
|
import { fileURLToPath } from 'url';
|
|
39
39
|
import { createHash } from 'crypto';
|
|
40
40
|
import { expandHome, resolveHypoRoot } from './lib/hypo-root.mjs';
|
|
41
|
+
import { hooksDirForInstall, unsafeHookTargetReason } from './lib/git-hooks-dir.mjs';
|
|
41
42
|
import { readCoreHooksConfig } from './lib/core-hooks.mjs';
|
|
42
43
|
import {
|
|
43
44
|
readPkgJson as readPkgJsonSafe,
|
|
@@ -49,6 +50,11 @@ import {
|
|
|
49
50
|
import { syncExtensions } from './lib/extensions.mjs';
|
|
50
51
|
import { templateSchemaVersion } from './lib/template-schema-version.mjs';
|
|
51
52
|
import { classifyInstall, downgradeGuardMessage } from '../hooks/version-check.mjs';
|
|
53
|
+
import {
|
|
54
|
+
isHypomnemaPluginEnabled,
|
|
55
|
+
resolveEnabledPluginRoot as resolveEnabledPluginRootShared,
|
|
56
|
+
usablePkgRoot,
|
|
57
|
+
} from './lib/plugin-detect.mjs';
|
|
52
58
|
|
|
53
59
|
const HOME = homedir();
|
|
54
60
|
const SCRIPT_DIR = fileURLToPath(new URL('.', import.meta.url));
|
|
@@ -93,6 +99,7 @@ const KNOWN_SUBCOMMANDS = new Set([
|
|
|
93
99
|
'uninstall',
|
|
94
100
|
'feedback-sync',
|
|
95
101
|
'capture',
|
|
102
|
+
'proposal',
|
|
96
103
|
]);
|
|
97
104
|
const _verb = process.argv[2];
|
|
98
105
|
if (_verb && KNOWN_SUBCOMMANDS.has(_verb) && _verb !== 'init') {
|
|
@@ -119,6 +126,7 @@ function parseArgs(argv) {
|
|
|
119
126
|
shellSetup: true,
|
|
120
127
|
shellConfig: null,
|
|
121
128
|
allowDowngrade: false,
|
|
129
|
+
lintStrict: false,
|
|
122
130
|
};
|
|
123
131
|
for (const arg of argv.slice(2)) {
|
|
124
132
|
if (arg === '--help' || arg === '-h') {
|
|
@@ -132,6 +140,12 @@ Commands:
|
|
|
132
140
|
uninstall Remove hooks and registrations (dry-run by default; pass --apply)
|
|
133
141
|
feedback-sync Project feedback (SoT) → MEMORY.md / CLAUDE.md learned-behaviors
|
|
134
142
|
projection (--check default, --write to apply)
|
|
143
|
+
capture Reverse-capture ~/.claude/{commands,agents} extensions and
|
|
144
|
+
settings.json hooks into the wiki for cross-machine sync
|
|
145
|
+
(pass names or --all; --dry-run to preview)
|
|
146
|
+
proposal Manage staged crystallize proposals: list, apply, or
|
|
147
|
+
discard a parked write (also challenge/resolve for the
|
|
148
|
+
no-TTY agent approval channel)
|
|
135
149
|
|
|
136
150
|
Running \`hypomnema\` with no command is equivalent to \`hypomnema init\`.
|
|
137
151
|
|
|
@@ -149,11 +163,19 @@ Init options:
|
|
|
149
163
|
--shell-config=<path> Shell config file path (default: auto-detect)
|
|
150
164
|
--allow-downgrade Override the guard that refuses to overwrite NEWER active
|
|
151
165
|
hooks with an older package (stale-PATH-CLI footgun)
|
|
166
|
+
--lint-strict Opt-in: also gate the wiki pre-commit hook on
|
|
167
|
+
\`lint --strict\` (promotes STRICT_PROMOTE_IDS warnings
|
|
168
|
+
to a blocking error), sequenced after the .hypoignore
|
|
169
|
+
guard. Off by default; re-run init to add or drop it.
|
|
152
170
|
--dry-run Show what would be done without making changes
|
|
171
|
+
--version Print the installed package version and exit
|
|
153
172
|
--help, -h Show this help message
|
|
154
173
|
|
|
155
|
-
Subcommand-specific flags (upgrade/doctor/uninstall) live in the
|
|
156
|
-
docstring at the top of scripts/<command>.mjs
|
|
174
|
+
Subcommand-specific flags (upgrade/doctor/uninstall/capture) live in the
|
|
175
|
+
docstring at the top of scripts/<command>.mjs; capture also accepts \`--help\`.`);
|
|
176
|
+
process.exit(0);
|
|
177
|
+
} else if (arg === '--version') {
|
|
178
|
+
console.log(PKG_VERSION ?? 'unknown');
|
|
157
179
|
process.exit(0);
|
|
158
180
|
} else if (arg.startsWith('--hypo-dir=')) args.hypoDir = expandHome(arg.slice(11));
|
|
159
181
|
else if (arg === '--no-hooks') args.hooks = false;
|
|
@@ -174,6 +196,19 @@ docstring at the top of scripts/<command>.mjs.`);
|
|
|
174
196
|
else if (arg === '--no-shell') args.shellSetup = false;
|
|
175
197
|
else if (arg.startsWith('--shell-config=')) args.shellConfig = expandHome(arg.slice(15));
|
|
176
198
|
else if (arg === '--allow-downgrade') args.allowDowngrade = true;
|
|
199
|
+
else if (arg === '--lint-strict') args.lintStrict = true;
|
|
200
|
+
else {
|
|
201
|
+
// An unrecognized argument used to fall through silently and run the
|
|
202
|
+
// default init flow (scaffold + hook install + settings.json merge) —
|
|
203
|
+
// a destructive write triggered by what looked like a query. `--version`
|
|
204
|
+
// was the concrete case that bit a user: it reads like an inspection
|
|
205
|
+
// flag, was never implemented, and its typo/mistake landed on a live
|
|
206
|
+
// wiki's pre-commit hook instead of printing anything. Fail loud and
|
|
207
|
+
// do nothing, rather than guess what the caller meant.
|
|
208
|
+
console.error(`Unknown option: ${arg}`);
|
|
209
|
+
console.error('Run `hypomnema --help` for usage.');
|
|
210
|
+
process.exit(2);
|
|
211
|
+
}
|
|
177
212
|
}
|
|
178
213
|
return args;
|
|
179
214
|
}
|
|
@@ -286,6 +321,23 @@ function writeWikiignore(hypoDir, dryRun) {
|
|
|
286
321
|
log('created', dest);
|
|
287
322
|
}
|
|
288
323
|
|
|
324
|
+
// ── .hyposcanignore ──────────────────────────────────────────────────────────
|
|
325
|
+
// Scan-only exclusion sibling to .hypoignore. NOT a privacy boundary — see
|
|
326
|
+
// templates/.hyposcanignore's header. Absence is fine: every scan-ignore
|
|
327
|
+
// reader treats a missing file as an empty pattern list.
|
|
328
|
+
|
|
329
|
+
function writeScanIgnore(hypoDir, dryRun) {
|
|
330
|
+
const dest = join(hypoDir, '.hyposcanignore');
|
|
331
|
+
if (existsSync(dest)) {
|
|
332
|
+
log('skipped', dest);
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
const src = join(TEMPLATES, '.hyposcanignore');
|
|
336
|
+
const content = existsSync(src) ? readFileSync(src, 'utf-8') : '';
|
|
337
|
+
if (!dryRun) writeFileSync(dest, content);
|
|
338
|
+
log('created', dest);
|
|
339
|
+
}
|
|
340
|
+
|
|
289
341
|
// ── .gitignore ───────────────────────────────────────────────────────────────
|
|
290
342
|
|
|
291
343
|
function writeGitignore(hypoDir, dryRun) {
|
|
@@ -468,34 +520,77 @@ function pkgJsonPath() {
|
|
|
468
520
|
* install (no metadata), unparseable versions, the same package re-running itself,
|
|
469
521
|
* --allow-downgrade, or --dry-run.
|
|
470
522
|
*/
|
|
471
|
-
function maybeRefuseDowngrade(op, allowDowngrade, dryRun) {
|
|
472
|
-
if (allowDowngrade || dryRun) return;
|
|
523
|
+
function maybeRefuseDowngrade(op, allowDowngrade, dryRun, durableRoot) {
|
|
524
|
+
if (allowDowngrade || dryRun || !PKG_VERSION) return;
|
|
473
525
|
const active = readPkgJsonSafe(pkgJsonPath());
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
526
|
+
const candidates = [];
|
|
527
|
+
// The recorded metadata: catches a stale bin trying to downgrade a newer
|
|
528
|
+
// recorded install (the original guard).
|
|
529
|
+
if (active && active.pkgVersion) {
|
|
530
|
+
candidates.push({ pkgRoot: active.pkgRoot, version: active.pkgVersion });
|
|
531
|
+
}
|
|
532
|
+
// The positively-resolved durable (plugin) install, when it is a DIFFERENT root
|
|
533
|
+
// than this package. In an npm-first dual install the recorded pkgVersion is the
|
|
534
|
+
// stale npm one, so comparing only against it lets an OLDER npm init run against a
|
|
535
|
+
// NEWER plugin; also compare against the plugin's own version. readPkgVersionAt
|
|
536
|
+
// yields a version only for a usable root.
|
|
537
|
+
if (durableRoot && durableRoot !== PKG_ROOT) {
|
|
538
|
+
const durableVersion = readPkgVersionAt(durableRoot);
|
|
539
|
+
if (durableVersion) candidates.push({ pkgRoot: durableRoot, version: durableVersion });
|
|
540
|
+
}
|
|
541
|
+
for (const active of candidates) {
|
|
542
|
+
const verdict = classifyInstall(
|
|
543
|
+
{ pkgRoot: PKG_ROOT, version: PKG_VERSION },
|
|
544
|
+
{ pkgRoot: active.pkgRoot, version: active.version },
|
|
545
|
+
);
|
|
546
|
+
if (verdict === 'downgrade') {
|
|
547
|
+
console.error(downgradeGuardMessage(PKG_VERSION, active.version, op));
|
|
548
|
+
process.exit(2);
|
|
549
|
+
}
|
|
482
550
|
}
|
|
483
551
|
}
|
|
484
552
|
|
|
485
|
-
function
|
|
553
|
+
function readPkgVersionAt(root) {
|
|
554
|
+
try {
|
|
555
|
+
const v = JSON.parse(readFileSync(join(root, 'package.json'), 'utf-8')).version;
|
|
556
|
+
return typeof v === 'string' && v.length > 0 ? v : null;
|
|
557
|
+
} catch {
|
|
558
|
+
return null;
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// usablePkgRoot now lives in ./lib/plugin-detect.mjs (imported above), shared with
|
|
563
|
+
// upgrade.mjs's dualSkip provenance correction so both agree on what is real.
|
|
564
|
+
|
|
565
|
+
function writePkgJson(dryRun, extraFields = {}, root = PKG_ROOT) {
|
|
486
566
|
const dest = pkgJsonPath();
|
|
487
567
|
const existing = readPkgJsonSafe(dest);
|
|
568
|
+
// `root` is the DURABLE install root the runtime resolves scripts through
|
|
569
|
+
// (hooks/hypo-shared.mjs reads pkgRoot): PKG_ROOT for a normal install, the
|
|
570
|
+
// positively-resolved plugin cache root for a dual install (see resolveDurableRoot).
|
|
571
|
+
// pkgVersion is read from `root` too — stamping a dual-install pointer at the
|
|
572
|
+
// plugin with THIS npm package's version would make doctor's own version-match
|
|
573
|
+
// check contradict itself. schemaVersion describes the templates init writes,
|
|
574
|
+
// which always come from PKG_ROOT regardless of where scripts resolve.
|
|
488
575
|
const merged = {
|
|
489
576
|
...existing,
|
|
490
|
-
pkgRoot:
|
|
491
|
-
pkgVersion: PKG_VERSION,
|
|
577
|
+
pkgRoot: root,
|
|
578
|
+
pkgVersion: readPkgVersionAt(root) ?? PKG_VERSION,
|
|
492
579
|
schemaVersion: templateSchemaVersion(PKG_ROOT) ?? '2.1',
|
|
493
580
|
...extraFields,
|
|
494
581
|
};
|
|
495
|
-
|
|
582
|
+
// Skip the write outright when the durable identity already matches what is on
|
|
583
|
+
// disk, rather than re-serialize an unchanged file. That keeps the dry-run and
|
|
584
|
+
// real write-sets identical: a real run must never silently rewrite a file a dry
|
|
585
|
+
// run reported as skipped. A genuine change (fresh identity, a version bump, or a
|
|
586
|
+
// dual-install correction from a stale npm pointer to the plugin root) is logged
|
|
587
|
+
// as "created" so it shows up in both write-sets. Log is emitted unconditionally
|
|
588
|
+
// (outside the dryRun guard) so a dry run previews exactly what a real run does.
|
|
589
|
+
const noWrite = JSON.stringify(merged) === JSON.stringify(existing);
|
|
590
|
+
if (!dryRun && !noWrite) {
|
|
496
591
|
writePkgJsonAtomic(dest, merged);
|
|
497
|
-
log('created', dest);
|
|
498
592
|
}
|
|
593
|
+
log(noWrite ? 'skipped' : 'created', noWrite ? `${dest} (durable pkgRoot unchanged)` : dest);
|
|
499
594
|
return merged;
|
|
500
595
|
}
|
|
501
596
|
|
|
@@ -618,10 +713,19 @@ function installCommands(targetDir, dryRun, force) {
|
|
|
618
713
|
}
|
|
619
714
|
|
|
620
715
|
function installPkgGitHook(dryRun) {
|
|
621
|
-
const
|
|
622
|
-
if (!
|
|
623
|
-
|
|
716
|
+
const { dir: hooksDir, skip } = hooksDirForInstall(PKG_ROOT);
|
|
717
|
+
if (!hooksDir) {
|
|
718
|
+
if (skip) log('skipped', `${PKG_ROOT} post-commit (${skip})`);
|
|
719
|
+
return;
|
|
720
|
+
}
|
|
624
721
|
const hookPath = join(hooksDir, 'post-commit');
|
|
722
|
+
// Checked before existsSync: a dangling symlink reads as absent there, and
|
|
723
|
+
// writing would create its external target.
|
|
724
|
+
const unsafe = unsafeHookTargetReason(hookPath);
|
|
725
|
+
if (unsafe) {
|
|
726
|
+
log('skipped', `${hookPath} (${unsafe})`);
|
|
727
|
+
return;
|
|
728
|
+
}
|
|
625
729
|
if (existsSync(hookPath)) {
|
|
626
730
|
log('skipped', hookPath);
|
|
627
731
|
return;
|
|
@@ -638,19 +742,67 @@ function installPkgGitHook(dryRun) {
|
|
|
638
742
|
const WIKI_PRE_COMMIT_MARKER_START = '# hypo-managed:pre-commit:start';
|
|
639
743
|
const WIKI_PRE_COMMIT_MARKER_END = '# hypo-managed:pre-commit:end';
|
|
640
744
|
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
745
|
+
// Single-quote escaping prevents shell expansion of special chars (e.g. $HOME, backticks) in path
|
|
746
|
+
function shellSingleQuote(p) {
|
|
747
|
+
return `'${p.replace(/'/g, "'\\''")}'`;
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
// `root` is the DURABLE install root the hook should resolve hypo-pre-commit.mjs
|
|
751
|
+
// (and, when opted in, lint.mjs) through — not necessarily PKG_ROOT. In a dual
|
|
752
|
+
// install (a manual/npm init while the plugin is enabled) PKG_ROOT is the
|
|
753
|
+
// manual/npm checkout the dual-install notice tells the user to uninstall;
|
|
754
|
+
// embedding it here would leave the vault's git hook dangling the moment they
|
|
755
|
+
// do, breaking every wiki commit. The caller passes the preserved plugin-owned
|
|
756
|
+
// root instead (see `durableRoot`), so the hook points at the install that
|
|
757
|
+
// actually persists.
|
|
758
|
+
//
|
|
759
|
+
// The block runs its steps sequentially rather than tail-calling `exit $?` on
|
|
760
|
+
// the first one, so a second step (the opt-in --lint-strict gate below) can
|
|
761
|
+
// run after the .hypoignore guard instead of being unreachable dead code.
|
|
762
|
+
// `lintStrict` is baked into the generated shim at install time — the same
|
|
763
|
+
// pattern already used for HYPOMNEMA_ROOT/HYPOMNEMA_GIT_DIR in
|
|
764
|
+
// install-git-hooks.mjs — so toggling it means re-running init with/without
|
|
765
|
+
// `--lint-strict`, not editing the hook by hand.
|
|
766
|
+
//
|
|
767
|
+
// `hypoDir` MUST be absolutized before it is baked in. Git runs a pre-commit
|
|
768
|
+
// hook with cwd set to the wiki's own working-tree root, so a relative
|
|
769
|
+
// `--hypo-dir` (e.g. from `hypo init --hypo-dir=wiki` run from its parent) is
|
|
770
|
+
// re-resolved AT COMMIT TIME against that root instead of the directory the
|
|
771
|
+
// caller meant — `wiki` becomes `<wiki-root>/wiki`, a path that doesn't exist,
|
|
772
|
+
// and lint.mjs falls through to its own default resolution (HYPO_DIR/
|
|
773
|
+
// hypo-config.md scan) and may silently lint an unrelated vault. `worker`
|
|
774
|
+
// needs no such treatment: `root` is already realpath'd upstream (see
|
|
775
|
+
// `durableRoot` / PKG_ROOT resolution), so it's absolute at every call site.
|
|
776
|
+
function wikiPreCommitContent(root, hypoDir, lintStrict) {
|
|
777
|
+
const absHypoDir = resolve(hypoDir);
|
|
778
|
+
const worker = join(root, 'hooks', 'hypo-pre-commit.mjs');
|
|
779
|
+
const steps = [`node ${shellSingleQuote(worker)} || exit 1`];
|
|
780
|
+
if (lintStrict) {
|
|
781
|
+
const lintScript = join(root, 'scripts', 'lint.mjs');
|
|
782
|
+
steps.push(
|
|
783
|
+
`node ${shellSingleQuote(lintScript)} --hypo-dir=${shellSingleQuote(absHypoDir)} --strict || exit 1`,
|
|
784
|
+
);
|
|
785
|
+
}
|
|
786
|
+
return `#!/bin/sh\n${WIKI_PRE_COMMIT_MARKER_START}\n${steps.join('\n')}\nexit 0\n${WIKI_PRE_COMMIT_MARKER_END}\n`;
|
|
646
787
|
}
|
|
647
788
|
|
|
648
|
-
function installWikiPreCommitHook(hypoDir, dryRun, force) {
|
|
649
|
-
const
|
|
650
|
-
if (!
|
|
651
|
-
|
|
789
|
+
function installWikiPreCommitHook(hypoDir, dryRun, force, root, lintStrict) {
|
|
790
|
+
const { dir: hooksDir, skip } = hooksDirForInstall(hypoDir);
|
|
791
|
+
if (!hooksDir) {
|
|
792
|
+
// no git repo — silently skip, as before; anything else is worth surfacing
|
|
793
|
+
if (skip) log('skipped', `${hypoDir} pre-commit (${skip})`);
|
|
794
|
+
return;
|
|
795
|
+
}
|
|
652
796
|
const hookPath = join(hooksDir, 'pre-commit');
|
|
653
|
-
const newContent = wikiPreCommitContent();
|
|
797
|
+
const newContent = wikiPreCommitContent(root, hypoDir, lintStrict);
|
|
798
|
+
|
|
799
|
+
// Before every branch below, including --force: a symlink here would send the
|
|
800
|
+
// write through to an arbitrary external file.
|
|
801
|
+
const unsafe = unsafeHookTargetReason(hookPath);
|
|
802
|
+
if (unsafe) {
|
|
803
|
+
log('skipped', `${hookPath} (${unsafe})`);
|
|
804
|
+
return;
|
|
805
|
+
}
|
|
654
806
|
|
|
655
807
|
if (existsSync(hookPath)) {
|
|
656
808
|
const existing = readFileSync(hookPath, 'utf-8');
|
|
@@ -665,8 +817,18 @@ function installWikiPreCommitHook(hypoDir, dryRun, force) {
|
|
|
665
817
|
}
|
|
666
818
|
log('merged', `${hookPath} (pre-commit updated)`);
|
|
667
819
|
} else if (force) {
|
|
820
|
+
// The .bak is a SECOND write to a DIFFERENT path, so the guard on
|
|
821
|
+
// hookPath above says nothing about it. Left unchecked, a pre-commit.bak
|
|
822
|
+
// symlink turns --force-commands into an overwrite of whatever it points
|
|
823
|
+
// at — deterministically, not as a race.
|
|
824
|
+
const bakPath = hookPath + '.bak';
|
|
825
|
+
const unsafeBak = unsafeHookTargetReason(bakPath);
|
|
826
|
+
if (unsafeBak) {
|
|
827
|
+
log('skipped', `${bakPath} (${unsafeBak}) — not force-overwriting without a safe backup`);
|
|
828
|
+
return;
|
|
829
|
+
}
|
|
668
830
|
if (!dryRun) {
|
|
669
|
-
writeFileSync(
|
|
831
|
+
writeFileSync(bakPath, existing);
|
|
670
832
|
writeFileSync(hookPath, newContent);
|
|
671
833
|
chmodSync(hookPath, 0o755);
|
|
672
834
|
}
|
|
@@ -837,6 +999,66 @@ function firstCommit(hypoDir, remote, dryRun) {
|
|
|
837
999
|
|
|
838
1000
|
const args = parseArgs(process.argv);
|
|
839
1001
|
|
|
1002
|
+
// ── install channel ───────────────────────────────────────────────────────────
|
|
1003
|
+
//
|
|
1004
|
+
// A plugin-channel install already provides the 14 core hooks + slash commands
|
|
1005
|
+
// through the package's own hooks/hooks.json + commands/ (Claude Code auto-wires
|
|
1006
|
+
// them). Copying the same hooks into ~/.claude/hooks and registering the same
|
|
1007
|
+
// events in settings.json on top makes every core hook fire TWICE, the same
|
|
1008
|
+
// failure mode already fixed for `upgrade --apply`. `pluginMode` mirrors
|
|
1009
|
+
// upgrade.mjs's own detector (this init.mjs itself running from a
|
|
1010
|
+
// `.claude/plugins/…` root); `hypomnemaPluginEnabled` catches the dual-install
|
|
1011
|
+
// variant (a manual/npm init.mjs run while the plugin is ALSO enabled — see
|
|
1012
|
+
// lib/plugin-detect.mjs, fail-open on any uncertainty). Either signal means
|
|
1013
|
+
// Claude's core hook/command surface is plugin-managed already.
|
|
1014
|
+
const pluginMode = PKG_ROOT.replace(/\\/g, '/').includes('/.claude/plugins/');
|
|
1015
|
+
const hypomnemaPluginEnabled =
|
|
1016
|
+
!pluginMode && isHypomnemaPluginEnabled(join(HOME, '.claude', 'settings.json'));
|
|
1017
|
+
const coreManagedByPlugin = pluginMode || hypomnemaPluginEnabled;
|
|
1018
|
+
|
|
1019
|
+
// resolveEnabledPluginRoot lives in ./lib/plugin-detect.mjs (shared with
|
|
1020
|
+
// upgrade.mjs's dualSkip provenance correction, imported above as
|
|
1021
|
+
// resolveEnabledPluginRootShared). This wrapper binds it to init's own HOME-derived
|
|
1022
|
+
// settings/registry paths so callers below read the same as before the move.
|
|
1023
|
+
function resolveEnabledPluginRoot(settingsPath) {
|
|
1024
|
+
return resolveEnabledPluginRootShared(
|
|
1025
|
+
settingsPath,
|
|
1026
|
+
join(HOME, '.claude', 'plugins', 'installed_plugins.json'),
|
|
1027
|
+
);
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
// Non-mutating read of the recorded pkgRoot. resolveDurableRoot runs before init's
|
|
1031
|
+
// package validation and its "no writes before validation" guarantee, so it must
|
|
1032
|
+
// NOT use readPkgJsonSafe (which renames a corrupt hypo-pkg.json aside — a write).
|
|
1033
|
+
function recordedPkgRoot() {
|
|
1034
|
+
try {
|
|
1035
|
+
const v = JSON.parse(readFileSync(pkgJsonPath(), 'utf-8')).pkgRoot;
|
|
1036
|
+
return typeof v === 'string' ? v : null;
|
|
1037
|
+
} catch {
|
|
1038
|
+
return null;
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
// The durable install root that persistent artifacts (the vault's git pre-commit
|
|
1043
|
+
// hook, hypo-pkg.json) should point at. In a dual install PKG_ROOT is the
|
|
1044
|
+
// manual/npm checkout the dual-install notice tells the user to uninstall, so
|
|
1045
|
+
// point at the plugin's REAL cache root, positively resolved from the registry —
|
|
1046
|
+
// NOT at whatever pkgRoot is already recorded, which after an npm-first sequence
|
|
1047
|
+
// (npm init, enable plugin, npm init again) may itself be that soon-to-be-removed
|
|
1048
|
+
// npm root. Only if the registry cannot positively identify the plugin do we fall
|
|
1049
|
+
// back to a usable recorded pointer, then PKG_ROOT (never worse than pre-fix). In
|
|
1050
|
+
// true plugin mode PKG_ROOT already IS the plugin path; for npm-only it is the
|
|
1051
|
+
// only install.
|
|
1052
|
+
function resolveDurableRoot() {
|
|
1053
|
+
if (!hypomnemaPluginEnabled) return PKG_ROOT;
|
|
1054
|
+
const pluginRoot = resolveEnabledPluginRoot(join(HOME, '.claude', 'settings.json'));
|
|
1055
|
+
if (pluginRoot) return pluginRoot;
|
|
1056
|
+
const recorded = recordedPkgRoot();
|
|
1057
|
+
if (usablePkgRoot(recorded)) return recorded;
|
|
1058
|
+
return PKG_ROOT;
|
|
1059
|
+
}
|
|
1060
|
+
const durableRoot = resolveDurableRoot();
|
|
1061
|
+
|
|
840
1062
|
// Validate hooks.json before any file writes so a bad package leaves no partial state
|
|
841
1063
|
const HOOK_MAP = args.hooks || args.codex ? loadHookMap() : null;
|
|
842
1064
|
|
|
@@ -849,7 +1071,7 @@ const HOOK_MAP = args.hooks || args.codex ? loadHookMap() : null;
|
|
|
849
1071
|
// and, with `--codex`, writes ~/.codex hooks/settings. The guard no-ops on a fresh
|
|
850
1072
|
// install, same realpath'd pkgRoot (dev re-run / npm-link / post-commit sync),
|
|
851
1073
|
// --allow-downgrade, or --dry-run, so legitimate flows are unaffected.
|
|
852
|
-
maybeRefuseDowngrade('init', args.allowDowngrade, args.dryRun);
|
|
1074
|
+
maybeRefuseDowngrade('init', args.allowDowngrade, args.dryRun, durableRoot);
|
|
853
1075
|
|
|
854
1076
|
if (args.fromRemote) {
|
|
855
1077
|
// ── from-remote path: clone → read config → install hooks ──────────────────
|
|
@@ -917,24 +1139,38 @@ if (args.fromRemote) {
|
|
|
917
1139
|
args.dryRun,
|
|
918
1140
|
);
|
|
919
1141
|
|
|
920
|
-
// 3. hypo-config.md + .hypoignore + .gitignore
|
|
1142
|
+
// 3. hypo-config.md + .hypoignore + .hyposcanignore + .gitignore
|
|
921
1143
|
writeHypoConfig(args.hypoDir, args.dryRun);
|
|
922
1144
|
writeWikiignore(args.hypoDir, args.dryRun);
|
|
1145
|
+
writeScanIgnore(args.hypoDir, args.dryRun);
|
|
923
1146
|
writeGitignore(args.hypoDir, args.dryRun);
|
|
924
1147
|
}
|
|
925
1148
|
|
|
926
1149
|
// 4. hooks
|
|
927
1150
|
|
|
1151
|
+
if (coreManagedByPlugin) {
|
|
1152
|
+
log(
|
|
1153
|
+
'skipped',
|
|
1154
|
+
`Claude core hooks/settings/commands — provided by the plugin loader (${
|
|
1155
|
+
pluginMode
|
|
1156
|
+
? 'this script is running from a Claude Code plugin install'
|
|
1157
|
+
: 'the Hypomnema plugin is enabled in settings.json'
|
|
1158
|
+
}); installing them here would register every hook twice. Use \`claude plugin update\` to upgrade instead.`,
|
|
1159
|
+
);
|
|
1160
|
+
}
|
|
1161
|
+
|
|
928
1162
|
let commandSHAs = null;
|
|
929
|
-
if (args.commands) {
|
|
1163
|
+
if (args.commands && !coreManagedByPlugin) {
|
|
930
1164
|
const claudeCommands = join(HOME, '.claude', 'commands', 'hypo');
|
|
931
1165
|
commandSHAs = installCommands(claudeCommands, args.dryRun, args.forceCommands);
|
|
932
1166
|
}
|
|
933
1167
|
|
|
934
1168
|
if (args.hooks) {
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
1169
|
+
if (!coreManagedByPlugin) {
|
|
1170
|
+
const claudeHooks = join(HOME, '.claude', 'hooks');
|
|
1171
|
+
installHooks(claudeHooks, args.dryRun);
|
|
1172
|
+
mergeSettingsJson(join(HOME, '.claude', 'settings.json'), claudeHooks, args.dryRun, HOOK_MAP);
|
|
1173
|
+
}
|
|
938
1174
|
}
|
|
939
1175
|
|
|
940
1176
|
// Always record the active install identity (pkgRoot + pkgVersion), even for a
|
|
@@ -943,7 +1179,9 @@ if (args.hooks) {
|
|
|
943
1179
|
// the downgrade guard has nothing to compare against, so a later stale
|
|
944
1180
|
// sibling could repoint those surfaces unguarded. writePkgJson merges (...existing),
|
|
945
1181
|
// so this never clobbers a commands/extensions map written elsewhere.
|
|
946
|
-
|
|
1182
|
+
// Record hypo-pkg.json against the durable root (the plugin's real cache root in a
|
|
1183
|
+
// dual install, PKG_ROOT otherwise), the same root the wiki pre-commit hook uses.
|
|
1184
|
+
writePkgJson(args.dryRun, commandSHAs ? { commands: commandSHAs } : {}, durableRoot);
|
|
947
1185
|
|
|
948
1186
|
// 4b. user extensions companion sync. Runs after
|
|
949
1187
|
// writePkgJson so the per-target SHA map is merged into the same hypo-pkg.json
|
|
@@ -1033,8 +1271,16 @@ if (args.hooks) {
|
|
|
1033
1271
|
installPkgGitHook(args.dryRun);
|
|
1034
1272
|
}
|
|
1035
1273
|
|
|
1036
|
-
// 8b. wiki pre-commit hook (.hypoignore last-line-of-defence guard — §6.8)
|
|
1037
|
-
|
|
1274
|
+
// 8b. wiki pre-commit hook (.hypoignore last-line-of-defence guard — §6.8).
|
|
1275
|
+
// Point it at the durable install root, not PKG_ROOT, so a dual install does not
|
|
1276
|
+
// embed a manual/npm path that dangles once the user uninstalls it.
|
|
1277
|
+
installWikiPreCommitHook(
|
|
1278
|
+
args.hypoDir,
|
|
1279
|
+
args.dryRun,
|
|
1280
|
+
args.forceCommands,
|
|
1281
|
+
durableRoot,
|
|
1282
|
+
args.lintStrict,
|
|
1283
|
+
);
|
|
1038
1284
|
|
|
1039
1285
|
// 9. first commit + push (skip when cloned from remote — already has commits)
|
|
1040
1286
|
if (args.gitInit && !args.fromRemote) {
|