hypomnema 1.7.0 → 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/hooks/hooks.json +11 -0
- package/hooks/hypo-auto-commit.mjs +92 -15
- package/hooks/hypo-auto-stage.mjs +28 -18
- package/hooks/hypo-close-guard.mjs +246 -0
- package/hooks/hypo-hot-rebuild.mjs +43 -5
- package/hooks/hypo-session-start.mjs +110 -13
- package/hooks/hypo-shared.mjs +1224 -96
- package/hooks/version-check.mjs +45 -0
- package/package.json +3 -1
- package/scripts/crystallize.mjs +112 -2
- package/scripts/doctor.mjs +483 -17
- package/scripts/graph.mjs +27 -12
- package/scripts/init.mjs +57 -7
- package/scripts/lib/git-hooks-dir.mjs +229 -0
- package/scripts/lib/project-create.mjs +5 -1
- package/scripts/lib/rename-marker.mjs +39 -0
- package/scripts/lint.mjs +84 -2
- package/scripts/rename.mjs +223 -18
- package/scripts/stats.mjs +14 -2
- package/templates/gitignore +4 -0
- package/templates/hypo-config.md +1 -1
package/scripts/rename.mjs
CHANGED
|
@@ -39,18 +39,20 @@ import {
|
|
|
39
39
|
existsSync,
|
|
40
40
|
readFileSync,
|
|
41
41
|
writeFileSync,
|
|
42
|
-
rmSync,
|
|
43
42
|
mkdirSync,
|
|
44
43
|
readdirSync,
|
|
45
44
|
renameSync,
|
|
46
45
|
statSync,
|
|
47
46
|
lstatSync,
|
|
48
47
|
realpathSync,
|
|
48
|
+
chmodSync,
|
|
49
|
+
rmSync,
|
|
49
50
|
} from 'fs';
|
|
50
51
|
import { join, basename, dirname, normalize, isAbsolute, sep } from 'path';
|
|
51
52
|
import { resolveHypoRoot, expandHome } from './lib/hypo-root.mjs';
|
|
52
53
|
import { loadHypoIgnore } from './lib/hypo-ignore.mjs';
|
|
53
54
|
import { collectPagesRename, slugForms } from './lib/wikilink.mjs';
|
|
55
|
+
import { RENAME_MARKER_REL, renameMarkerPath, readRenameMarker } from './lib/rename-marker.mjs';
|
|
54
56
|
|
|
55
57
|
// ── arg parsing ───────────────────────────────────────────────────────────────
|
|
56
58
|
|
|
@@ -227,6 +229,141 @@ function fail(args, msg) {
|
|
|
227
229
|
process.exit(1);
|
|
228
230
|
}
|
|
229
231
|
|
|
232
|
+
/** Atomic write via tmp+rename (same pattern as crystallize.mjs/proposal.mjs's
|
|
233
|
+
* atomicWrite): a crash or full disk mid-write leaves the ORIGINAL bytes at
|
|
234
|
+
* `path` untouched, never a truncated/partial file. Needed for every rewrite
|
|
235
|
+
* this script lands on a page that already has content on disk — including a
|
|
236
|
+
* page's own self-referential rewrite — because a plain writeFileSync
|
|
237
|
+
* truncates before writing and a crash in that gap destroys the one copy. */
|
|
238
|
+
function atomicWrite(path, content) {
|
|
239
|
+
const tmp = `${path}.${process.pid}.${Math.random().toString(36).slice(2, 10)}.tmp`;
|
|
240
|
+
// Swapping in a fresh inode also swaps in fresh permissions, so a 0444 or 0600
|
|
241
|
+
// page would come back 0644 and this rewrite would quietly widen it. Carry the
|
|
242
|
+
// existing mode over. Ownership, ACLs and hard-link identity are NOT carried;
|
|
243
|
+
// a vault page with any of those is outside what this script claims to handle.
|
|
244
|
+
let mode = null;
|
|
245
|
+
try {
|
|
246
|
+
mode = statSync(path).mode;
|
|
247
|
+
} catch {
|
|
248
|
+
mode = null; // new file, nothing to preserve
|
|
249
|
+
}
|
|
250
|
+
// `wx` so a collision is an error rather than a silent clobber of somebody
|
|
251
|
+
// else's temp, and drop the temp if anything after it fails (a full disk,
|
|
252
|
+
// a rename that cannot land) instead of leaving `.tmp` litter in the vault.
|
|
253
|
+
try {
|
|
254
|
+
writeFileSync(tmp, content, { flag: 'wx' });
|
|
255
|
+
if (mode !== null) chmodSync(tmp, mode);
|
|
256
|
+
renameSync(tmp, path);
|
|
257
|
+
} catch (err) {
|
|
258
|
+
try {
|
|
259
|
+
rmSync(tmp, { force: true });
|
|
260
|
+
} catch {
|
|
261
|
+
// best effort; the original at `path` is untouched either way
|
|
262
|
+
}
|
|
263
|
+
throw err;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// ── crash-recovery marker ────────────────────────────────────────────────────
|
|
268
|
+
// Written as the FIRST disk write of an --apply run (right after the last guard
|
|
269
|
+
// passes, before any inbound-link rewrite lands) and cleared as the LAST action
|
|
270
|
+
// (right after the terminal renameSync). If the process dies in between, this
|
|
271
|
+
// is the only trace left that the rename is incomplete: --from still resolves
|
|
272
|
+
// at that point (the move itself hasn't happened yet — see the module
|
|
273
|
+
// docstring's move-last invariant), so a re-run of the identical command
|
|
274
|
+
// converges on its own. Nothing else in the vault says "you need to re-run it".
|
|
275
|
+
//
|
|
276
|
+
// Fail-closed by construction: writeRenameMarker calls fail() (process.exit(1))
|
|
277
|
+
// on any write error, so --apply never proceeds without a marker on disk. A
|
|
278
|
+
// detector with no reliable marker is worse than no detector at all.
|
|
279
|
+
function writeRenameMarker(args, marker) {
|
|
280
|
+
const path = join(args.hypoDir, RENAME_MARKER_REL);
|
|
281
|
+
try {
|
|
282
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
283
|
+
atomicWrite(path, JSON.stringify(marker, null, 2));
|
|
284
|
+
} catch (err) {
|
|
285
|
+
fail(
|
|
286
|
+
args,
|
|
287
|
+
`could not write the rename-in-progress marker (${err.message}) — refusing --apply without it: it is the only signal a crash mid-rewrite would leave behind.`,
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// codex BLOCKER: writeRenameMarker above used to atomicWrite unconditionally,
|
|
293
|
+
// so a SECOND rename whose own --apply started after a FIRST one crashed mid-run
|
|
294
|
+
// would silently clobber the first rename's marker — permanently erasing the
|
|
295
|
+
// only trace that the first rename's move/rewrite is incomplete. Called right
|
|
296
|
+
// before writeRenameMarker in both modes, so the check runs before this run's
|
|
297
|
+
// marker (or anything else) touches disk.
|
|
298
|
+
//
|
|
299
|
+
// Three outcomes:
|
|
300
|
+
// - no marker on disk → nothing to conflict with, proceed.
|
|
301
|
+
// - marker names THIS exact command → this is the legitimate re-run path the
|
|
302
|
+
// whole mechanism exists to let converge (identical mode/from/to). Proceed;
|
|
303
|
+
// writeRenameMarker below simply overwrites it with a fresh started_at —
|
|
304
|
+
// mode/from/to don't change, only the timestamp does.
|
|
305
|
+
// - marker names a DIFFERENT command, or can't be parsed at all → refuse. A
|
|
306
|
+
// marker we can't parse might belong to this command or a different one;
|
|
307
|
+
// "can't tell" must resolve to refuse, not to a silent overwrite.
|
|
308
|
+
function guardExistingMarker(args, thisMarker) {
|
|
309
|
+
const path = renameMarkerPath(args.hypoDir);
|
|
310
|
+
if (!existsSync(path)) return;
|
|
311
|
+
const marker = readRenameMarker(args.hypoDir);
|
|
312
|
+
if (!marker || !marker.from || !marker.to) {
|
|
313
|
+
fail(
|
|
314
|
+
args,
|
|
315
|
+
`${path} exists but could not be parsed (or is missing from/to), so it cannot be told apart from this rename's own marker. Inspect it by hand, finish or discard the rename it describes, then delete the marker before retrying.`,
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
const same =
|
|
319
|
+
marker.mode === thisMarker.mode && marker.from === thisMarker.from && marker.to === thisMarker.to;
|
|
320
|
+
if (!same) {
|
|
321
|
+
fail(
|
|
322
|
+
args,
|
|
323
|
+
`a rename is already in progress (${marker.mode === 'directory' ? 'directory' : 'page'} '${marker.from}' → '${marker.to}', marker at ${path}) — finish that rename first (re-run the identical command), or confirm it is safe and delete the marker, before starting a different one.`,
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// A failure here is not fatal — the move already succeeded — but a marker left
|
|
329
|
+
// behind would make doctor misreport a finished rename as incomplete, so it is
|
|
330
|
+
// surfaced (stderr) rather than swallowed.
|
|
331
|
+
function clearRenameMarker(args) {
|
|
332
|
+
const path = join(args.hypoDir, RENAME_MARKER_REL);
|
|
333
|
+
try {
|
|
334
|
+
rmSync(path, { force: true });
|
|
335
|
+
} catch (err) {
|
|
336
|
+
console.error(
|
|
337
|
+
`⚠ rename completed, but could not remove the in-progress marker (${err.message}) — remove ${path} by hand.`,
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Refuse an --apply that would have to cross a filesystem/mount boundary,
|
|
343
|
+
// BEFORE any rewrite is written to disk. renameSync cannot move across
|
|
344
|
+
// devices (throws EXDEV); falling back to write-then-remove in that case
|
|
345
|
+
// reintroduces the exact non-convergent "both paths exist, --to-already-exists
|
|
346
|
+
// forever" crash window a single renameSync exists to close. Call this first,
|
|
347
|
+
// so a device mismatch is refused with the vault untouched — not discovered
|
|
348
|
+
// mid-move with inbound links already rewritten.
|
|
349
|
+
function assertSameDevice(args, fromAbsPath, toAbsPath) {
|
|
350
|
+
let ancestor = dirname(toAbsPath);
|
|
351
|
+
while (!existsSync(ancestor)) ancestor = dirname(ancestor);
|
|
352
|
+
let fromDev, toDev;
|
|
353
|
+
try {
|
|
354
|
+
fromDev = statSync(fromAbsPath).dev;
|
|
355
|
+
toDev = statSync(ancestor).dev;
|
|
356
|
+
} catch {
|
|
357
|
+
return; // can't tell in advance — let the real move surface the real error
|
|
358
|
+
}
|
|
359
|
+
if (fromDev !== toDev) {
|
|
360
|
+
fail(
|
|
361
|
+
args,
|
|
362
|
+
`--from and --to are on different filesystems/mounts — an atomic move is not possible across that boundary. Move it by hand instead of --apply.`,
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
230
367
|
// ── directory mode ─────────────────────────────────────────────────────────────
|
|
231
368
|
// A directory rename relocates a whole subtree (`projects/old/**` → `projects/new/**`)
|
|
232
369
|
// and rewrites inbound links across the vault. Key facts that shape the algorithm:
|
|
@@ -545,7 +682,7 @@ function runDirectory(args, fromDirRel, ignorePatterns) {
|
|
|
545
682
|
|
|
546
683
|
// Rewrite inbound references across the vault.
|
|
547
684
|
const externalWrites = new Map(); // abs path → content (non-moved source files)
|
|
548
|
-
const movedBodies = new Map(); //
|
|
685
|
+
const movedBodies = new Map(); // OLD rel → content (moved-page bodies, written pre-rename)
|
|
549
686
|
const fileResults = [];
|
|
550
687
|
const ambiguities = [];
|
|
551
688
|
let totalRewrites = 0;
|
|
@@ -577,22 +714,55 @@ function runDirectory(args, fromDirRel, ignorePatterns) {
|
|
|
577
714
|
const landRel = inSubtree ? movedByRel.get(p.rel).toPage.rel : p.rel;
|
|
578
715
|
fileResults.push({ file: landRel, rewrites });
|
|
579
716
|
totalRewrites += rewrites.length;
|
|
580
|
-
|
|
717
|
+
// Keyed by the OLD rel: this gets written at the OLD path, before the
|
|
718
|
+
// subtree rename, so the content travels with the directory move instead
|
|
719
|
+
// of racing it (see the apply block below).
|
|
720
|
+
if (inSubtree) movedBodies.set(p.rel, content);
|
|
581
721
|
else externalWrites.set(p.path, content);
|
|
582
722
|
}
|
|
583
723
|
if (ambiguous.length > 0) ambiguities.push({ file: p.rel, ambiguous });
|
|
584
724
|
}
|
|
585
725
|
|
|
586
|
-
// Apply:
|
|
587
|
-
//
|
|
726
|
+
// Apply: refuse a cross-device move up front (assertSameDevice) → external
|
|
727
|
+
// rewrites in place → moved-page bodies rewritten at their OLD paths → THEN
|
|
728
|
+
// renameSync the whole subtree in one syscall (carries non-.md assets and the
|
|
729
|
+
// just-rewritten bodies along with it).
|
|
730
|
+
//
|
|
731
|
+
// Writing moved bodies before the rename (not after, as this used to) matters
|
|
732
|
+
// for convergence: a crash after renameSync but before those writes used to
|
|
733
|
+
// leave `projects/new/*` links still pointing at `projects/old/*` forever —
|
|
734
|
+
// --from no longer resolves (the directory already moved), so a re-run failed
|
|
735
|
+
// with "did not resolve to a unique existing page" instead of finishing the
|
|
736
|
+
// job. Writing pre-rename means the content moves atomically with the
|
|
737
|
+
// directory: there is no window where the subtree has moved but its own
|
|
738
|
+
// internal links have not been rewritten yet.
|
|
588
739
|
let moved = false;
|
|
589
740
|
if (args.apply) {
|
|
590
|
-
|
|
741
|
+
assertSameDevice(args, fromAbs, toAbs);
|
|
742
|
+
const thisMarker = {
|
|
743
|
+
mode: 'directory',
|
|
744
|
+
from: fromDirRel,
|
|
745
|
+
to: toDirRel,
|
|
746
|
+
started_at: new Date().toISOString(),
|
|
747
|
+
};
|
|
748
|
+
guardExistingMarker(args, thisMarker);
|
|
749
|
+
writeRenameMarker(args, thisMarker);
|
|
750
|
+
for (const [path, content] of externalWrites) atomicWrite(path, content);
|
|
751
|
+
for (const [oldRel, content] of movedBodies) {
|
|
752
|
+
atomicWrite(join(args.hypoDir, oldRel), content);
|
|
753
|
+
}
|
|
591
754
|
mkdirSync(dirname(toAbs), { recursive: true });
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
755
|
+
try {
|
|
756
|
+
renameSync(fromAbs, toAbs);
|
|
757
|
+
} catch (err) {
|
|
758
|
+
// assertSameDevice above should already have refused this. A device
|
|
759
|
+
// change mid-run is the only way to reach EXDEV here — fail loudly
|
|
760
|
+
// instead of an unsafe write-then-remove fallback; every write above is
|
|
761
|
+
// already atomic, so a re-run picks up cleanly.
|
|
762
|
+
if (err.code !== 'EXDEV') throw err;
|
|
763
|
+
fail(args, `--from and --to ended up on different filesystems mid-run — refusing an unsafe fallback move.`);
|
|
595
764
|
}
|
|
765
|
+
clearRenameMarker(args);
|
|
596
766
|
moved = true;
|
|
597
767
|
}
|
|
598
768
|
|
|
@@ -730,6 +900,22 @@ function run(args) {
|
|
|
730
900
|
}
|
|
731
901
|
}
|
|
732
902
|
|
|
903
|
+
// Refuse a cross-device --apply before anything below writes a single byte
|
|
904
|
+
// (see assertSameDevice) — the external rewrite loop right after this can
|
|
905
|
+
// otherwise land partial vault changes ahead of a move that turns out to be
|
|
906
|
+
// impossible.
|
|
907
|
+
if (args.apply) {
|
|
908
|
+
assertSameDevice(args, fromPage.path, toPath);
|
|
909
|
+
const thisMarker = {
|
|
910
|
+
mode: 'page',
|
|
911
|
+
from: fromPage.rel,
|
|
912
|
+
to: toRel,
|
|
913
|
+
started_at: new Date().toISOString(),
|
|
914
|
+
};
|
|
915
|
+
guardExistingMarker(args, thisMarker);
|
|
916
|
+
writeRenameMarker(args, thisMarker);
|
|
917
|
+
}
|
|
918
|
+
|
|
733
919
|
// Rewrite inbound references across every NON-preserved page (skip the moved
|
|
734
920
|
// page itself — self-references are rewritten on its own content separately).
|
|
735
921
|
const fileResults = [];
|
|
@@ -748,12 +934,12 @@ function run(args) {
|
|
|
748
934
|
fileResults.push({ file: p.rel, rewrites });
|
|
749
935
|
totalRewrites += rewrites.length;
|
|
750
936
|
if (args.apply && content !== raw) {
|
|
751
|
-
// The from-page is about to move;
|
|
752
|
-
// path below,
|
|
937
|
+
// The from-page is about to move; stash its own rewritten body — it is
|
|
938
|
+
// written to the OLD path below, right before the rename, not here.
|
|
753
939
|
if (p.rel === fromPage.rel) {
|
|
754
940
|
fromPage._rewritten = content;
|
|
755
941
|
} else {
|
|
756
|
-
|
|
942
|
+
atomicWrite(p.path, content);
|
|
757
943
|
}
|
|
758
944
|
} else if (p.rel === fromPage.rel) {
|
|
759
945
|
fromPage._rewritten = content;
|
|
@@ -764,15 +950,34 @@ function run(args) {
|
|
|
764
950
|
}
|
|
765
951
|
}
|
|
766
952
|
|
|
767
|
-
// Move the file (--apply only):
|
|
768
|
-
//
|
|
769
|
-
//
|
|
953
|
+
// Move the file (--apply only): if the from-page carries a self-rewritten body
|
|
954
|
+
// (its own [[foo]]-style links needed retargeting), write it to the OLD path
|
|
955
|
+
// first (atomically — a crash mid-write must never corrupt the one copy),
|
|
956
|
+
// then renameSync the file into place. A rename is one syscall with no
|
|
957
|
+
// observable intermediate state, so a crash either lands before it (old path
|
|
958
|
+
// still has the rewritten body, re-run converges) or after it (new path exists,
|
|
959
|
+
// nothing to redo) — never both paths present at once, which is the state a
|
|
960
|
+
// prior write-then-remove could leave and that the --to-already-exists guard
|
|
961
|
+
// above then refuses to recover from.
|
|
770
962
|
let moved = false;
|
|
771
963
|
if (args.apply) {
|
|
964
|
+
if (fromPage._rewritten !== undefined) atomicWrite(fromPage.path, fromPage._rewritten);
|
|
772
965
|
mkdirSync(dirname(toPath), { recursive: true });
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
966
|
+
if (toPath !== fromPage.path) {
|
|
967
|
+
try {
|
|
968
|
+
renameSync(fromPage.path, toPath);
|
|
969
|
+
} catch (err) {
|
|
970
|
+
// assertSameDevice above should already have refused a cross-device
|
|
971
|
+
// move before any write happened. Reaching EXDEV here means the mount
|
|
972
|
+
// changed mid-run — fail loudly rather than silently falling back to
|
|
973
|
+
// write-then-remove, which is exactly the non-atomic state this fix
|
|
974
|
+
// removes. fromPage.path still holds the valid (possibly rewritten)
|
|
975
|
+
// body, so a re-run picks up cleanly once the device issue is gone.
|
|
976
|
+
if (err.code !== 'EXDEV') throw err;
|
|
977
|
+
fail(args, `--from and --to ended up on different filesystems mid-run — refusing an unsafe fallback move.`);
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
clearRenameMarker(args);
|
|
776
981
|
moved = true;
|
|
777
982
|
}
|
|
778
983
|
|
package/scripts/stats.mjs
CHANGED
|
@@ -98,7 +98,12 @@ function getLastActivity(hypoDir) {
|
|
|
98
98
|
const args = parseArgs(process.argv);
|
|
99
99
|
// Only validate the auto-resolved path (env/marker/default). An explicit
|
|
100
100
|
// --hypo-dir=<path> (tests, other tooling) is trusted as-is, valid or not.
|
|
101
|
-
|
|
101
|
+
// See lint.mjs's matching comment: source 'default'/stale-'marker' stays
|
|
102
|
+
// exit-0 (CI-safe), but the all-zero counts below must not be printed as if a
|
|
103
|
+
// real (empty) vault had been scanned.
|
|
104
|
+
const vaultMissing = args.hypoDirSource
|
|
105
|
+
? checkVaultOrExit(args.hypoDir, args.hypoDirSource)
|
|
106
|
+
: false;
|
|
102
107
|
|
|
103
108
|
const ignorePatterns = loadHypoIgnore(args.hypoDir);
|
|
104
109
|
const pageFiles = collectMdFiles(join(args.hypoDir, 'pages'), [], args.hypoDir, ignorePatterns);
|
|
@@ -165,7 +170,14 @@ const stats = {
|
|
|
165
170
|
};
|
|
166
171
|
|
|
167
172
|
if (args.json) {
|
|
168
|
-
|
|
173
|
+
// vaultFound lets a machine consumer of `--json` tell "scanned, genuinely
|
|
174
|
+
// empty" apart from "nothing was scanned" without needing stderr — the
|
|
175
|
+
// same reasoning as lint.mjs's matching field.
|
|
176
|
+
console.log(JSON.stringify({ ...stats, vaultFound: !vaultMissing }, null, 2));
|
|
177
|
+
} else if (vaultMissing) {
|
|
178
|
+
// checkVaultOrExit already printed the "No Hypomnema vault found" notice on
|
|
179
|
+
// stderr. An all-zero stats block here would read as "scanned an empty
|
|
180
|
+
// vault", which never happened.
|
|
169
181
|
} else {
|
|
170
182
|
console.log(`Pages: ${pageFiles.length} total`);
|
|
171
183
|
const typeEntries = Object.entries(typeCounts).sort((a, b) => b[1] - a[1]);
|
package/templates/gitignore
CHANGED
|
@@ -8,3 +8,7 @@
|
|
|
8
8
|
# writes (crystallize apply, deriveRootLogEntries). Normally deleted immediately;
|
|
9
9
|
# a crashed holder can leave a stale one, which must never be committed.
|
|
10
10
|
*.lock
|
|
11
|
+
# The lock is published by linking a staged sibling into place. `*.lock` does not
|
|
12
|
+
# match the staging name, and a crash between the link and its cleanup leaves one
|
|
13
|
+
# behind — where `git add -A` would happily pick it up.
|
|
14
|
+
*.lock.*.tmp
|