hypomnema 1.7.0 → 1.7.2
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 +79 -50
- package/README.md +63 -34
- 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 +166 -13
- package/hooks/hypo-shared.mjs +1358 -130
- package/hooks/version-check.mjs +92 -0
- package/package.json +4 -1
- package/scripts/crystallize.mjs +112 -2
- package/scripts/doctor.mjs +627 -17
- package/scripts/graph.mjs +27 -12
- package/scripts/init.mjs +66 -7
- package/scripts/lib/git-hooks-dir.mjs +229 -0
- package/scripts/lib/pkg-provenance.mjs +166 -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/scripts/uninstall.mjs +12 -0
- package/scripts/upgrade.mjs +8 -0
- package/templates/gitignore +4 -0
- package/templates/hypo-config.md +1 -1
package/scripts/lint.mjs
CHANGED
|
@@ -112,10 +112,19 @@ function parseTagsField(rawValue) {
|
|
|
112
112
|
}
|
|
113
113
|
|
|
114
114
|
// type-conditional required fields (spec §6.3, SCHEMA.md §2)
|
|
115
|
+
// `project-index` deliberately does NOT list `working_dir` here. An index
|
|
116
|
+
// existing at all is required (W12 above); the cwd anchor inside it is not —
|
|
117
|
+
// crystallize.mjs's auto-created index has no session cwd to fill it with, and
|
|
118
|
+
// requiring it would force a fake, non-empty placeholder into the field. A
|
|
119
|
+
// placeholder value is truthy, and hooks/hypo-shared.mjs's collector +
|
|
120
|
+
// findBackfillCandidate both read "has a truthy working_dir" as "already
|
|
121
|
+
// anchored, do not offer to backfill" — so a fake value permanently poisons
|
|
122
|
+
// the exact recovery path it should trigger. `status`/`started` stay required:
|
|
123
|
+
// they always have a real value to substitute (`active` / the close date).
|
|
115
124
|
const TYPE_CONDITIONAL_FIELDS = {
|
|
116
125
|
prd: ['status', 'started'],
|
|
117
126
|
adr: ['source', 'status', 'date'],
|
|
118
|
-
'project-index': ['
|
|
127
|
+
'project-index': ['status', 'started'],
|
|
119
128
|
'tool-eval': ['status'],
|
|
120
129
|
postmortem: ['outcome'],
|
|
121
130
|
learning: ['source'],
|
|
@@ -397,6 +406,26 @@ function lintPage({ path, rel }, slugMap, tagVocab, pageDirs, validTypes) {
|
|
|
397
406
|
}
|
|
398
407
|
}
|
|
399
408
|
|
|
409
|
+
// W13: project-index with no working_dir anchor. Deliberately NOT in
|
|
410
|
+
// TYPE_CONDITIONAL_FIELDS (see the comment there) — an auto-created index has
|
|
411
|
+
// no cwd to put here, and that close must not block on its own output, so
|
|
412
|
+
// this stays warn-only and out of STRICT_PROMOTE_IDS, exactly like W12. The
|
|
413
|
+
// gap this closes: cwd-first backfill (hooks/hypo-shared.mjs's
|
|
414
|
+
// findBackfillCandidate) only fires when the cwd's leaf directory name
|
|
415
|
+
// matches the project's slug — a project whose real working dir has a
|
|
416
|
+
// DIFFERENT basename than its slug never becomes a backfill candidate, so an
|
|
417
|
+
// emptied/never-filled anchor can otherwise sit invisible except in
|
|
418
|
+
// `doctor`'s manual report.
|
|
419
|
+
if (fm.type === 'project-index' && !fm.working_dir) {
|
|
420
|
+
issue(
|
|
421
|
+
'warn',
|
|
422
|
+
rel,
|
|
423
|
+
`project-index has no working_dir anchor (cwd-first resume can't match this project)`,
|
|
424
|
+
null,
|
|
425
|
+
'W13',
|
|
426
|
+
);
|
|
427
|
+
}
|
|
428
|
+
|
|
400
429
|
// type-conditional forbidden fields (W11): a field valid only on a
|
|
401
430
|
// DIFFERENT type planted here by an unvalidated writer. Object.hasOwn
|
|
402
431
|
// (not `fm[field]`) so a present-but-empty `working_dir:` still flags —
|
|
@@ -489,7 +518,16 @@ function lintPage({ path, rel }, slugMap, tagVocab, pageDirs, validTypes) {
|
|
|
489
518
|
const args = parseArgs(process.argv);
|
|
490
519
|
// Only validate the auto-resolved path (env/marker/default). An explicit
|
|
491
520
|
// --hypo-dir=<path> (tests, other tooling) is trusted as-is, valid or not.
|
|
492
|
-
|
|
521
|
+
// `vaultMissing` stays on the CI-safe exit-0 path (source 'default'/stale-
|
|
522
|
+
// 'marker' — see checkVaultOrExit's doc comment and its pinned test coverage):
|
|
523
|
+
// this repo's own `npm run lint` runs with no vault at all and must keep
|
|
524
|
+
// passing. What was actually missing is that a vault-less run still printed
|
|
525
|
+
// "no lint issues found" — indistinguishable, on stdout or in --json, from a
|
|
526
|
+
// real empty vault that was actually scanned. Suppress that false-positive-
|
|
527
|
+
// looking output below instead of changing the exit code.
|
|
528
|
+
const vaultMissing = args.hypoDirSource
|
|
529
|
+
? checkVaultOrExit(args.hypoDir, args.hypoDirSource)
|
|
530
|
+
: false;
|
|
493
531
|
|
|
494
532
|
const ignorePatterns = loadHypoIgnore(args.hypoDir);
|
|
495
533
|
const scanDirs = ['pages', 'projects', 'journal'].map((d) => join(args.hypoDir, d));
|
|
@@ -531,6 +569,40 @@ for (const s of findDesignHistoryStale(args.hypoDir)) {
|
|
|
531
569
|
);
|
|
532
570
|
}
|
|
533
571
|
|
|
572
|
+
// W12: project directory missing index.md. SCHEMA.md declares project-index
|
|
573
|
+
// at projects/*/index.md and templates/projects/_template/ ships one, so a
|
|
574
|
+
// project without it is a tooling/vault drift, not a legitimate shape — but
|
|
575
|
+
// warn-only: promoting this to an error would hard-block a close for every
|
|
576
|
+
// pre-existing project that predates the create-on-close path (crystallize.mjs
|
|
577
|
+
// A-1), and a missing index is never this close's fault to fix. `_`-prefixed
|
|
578
|
+
// directories are excluded (not just `_template`), matching the markdown
|
|
579
|
+
// collector's own convention (scripts/lib/wikilink.mjs's skipUnderscoreDir —
|
|
580
|
+
// `_scratch`, `_drafts`, etc. are scaffold, not a project).
|
|
581
|
+
if (existsSync(join(args.hypoDir, 'projects'))) {
|
|
582
|
+
for (const slug of readdirSync(join(args.hypoDir, 'projects'))) {
|
|
583
|
+
if (slug.startsWith('_')) continue;
|
|
584
|
+
const projectDir = join(args.hypoDir, 'projects', slug);
|
|
585
|
+
// A dangling symlink (or any other stat failure) must not crash the whole
|
|
586
|
+
// lint run — skip it exactly as doctor.mjs's own project-anchor scan does.
|
|
587
|
+
let st;
|
|
588
|
+
try {
|
|
589
|
+
st = statSync(projectDir);
|
|
590
|
+
} catch {
|
|
591
|
+
continue;
|
|
592
|
+
}
|
|
593
|
+
if (!st.isDirectory()) continue;
|
|
594
|
+
if (!existsSync(join(projectDir, 'index.md'))) {
|
|
595
|
+
issue(
|
|
596
|
+
'warn',
|
|
597
|
+
`projects/${slug}/index.md`,
|
|
598
|
+
`Missing project index: projects/${slug}/ has no index.md (SCHEMA.md declares project-index at projects/*/index.md)`,
|
|
599
|
+
null,
|
|
600
|
+
'W12',
|
|
601
|
+
);
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
534
606
|
if (args.fix) {
|
|
535
607
|
const today = new Date().toISOString().slice(0, 10);
|
|
536
608
|
const fixed = new Set();
|
|
@@ -597,6 +669,11 @@ if (args.json) {
|
|
|
597
669
|
JSON.stringify(
|
|
598
670
|
{
|
|
599
671
|
ok: errors.length === 0,
|
|
672
|
+
// A machine consumer piping `--json` (the exact CI-scripting case that
|
|
673
|
+
// motivated this field) can tell "scanned and clean" apart from
|
|
674
|
+
// "nothing to scan" without needing stderr. `ok` keeps its existing
|
|
675
|
+
// errors-only meaning so nothing that already reads it breaks.
|
|
676
|
+
vaultFound: !vaultMissing,
|
|
600
677
|
errors: errors.map(toOut),
|
|
601
678
|
warns: warns.map(toOut),
|
|
602
679
|
total: issues.length,
|
|
@@ -605,6 +682,11 @@ if (args.json) {
|
|
|
605
682
|
2,
|
|
606
683
|
),
|
|
607
684
|
);
|
|
685
|
+
} else if (vaultMissing) {
|
|
686
|
+
// checkVaultOrExit already put the "No Hypomnema vault found" notice on
|
|
687
|
+
// stderr. Printing "✓ No lint issues found" here as well would claim a scan
|
|
688
|
+
// that never happened — the exact false-green shape a piped/redirected
|
|
689
|
+
// caller cannot tell apart from a real, clean vault.
|
|
608
690
|
} else {
|
|
609
691
|
if (issues.length === 0) {
|
|
610
692
|
console.log('✓ No lint issues found');
|
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/scripts/uninstall.mjs
CHANGED
|
@@ -47,6 +47,7 @@ import {
|
|
|
47
47
|
hasSymlinkAncestor,
|
|
48
48
|
buildHookCommand,
|
|
49
49
|
} from './lib/extensions.mjs';
|
|
50
|
+
import { removeProvenanceSidecar } from './lib/pkg-provenance.mjs';
|
|
50
51
|
|
|
51
52
|
const HOME = homedir();
|
|
52
53
|
const SCRIPT_DIR = fileURLToPath(new URL('.', import.meta.url));
|
|
@@ -510,6 +511,17 @@ function removeHookFiles(hooksDir, hookFiles, apply) {
|
|
|
510
511
|
missing.push(p);
|
|
511
512
|
}
|
|
512
513
|
}
|
|
514
|
+
// .hypo-provenance.json (scripts/lib/pkg-provenance.mjs) is written next to
|
|
515
|
+
// this exact hooksDir by installHooks/applyHookFiles — same lifecycle as the
|
|
516
|
+
// hook files themselves, so it is removed in the same pass rather than left
|
|
517
|
+
// behind to describe a package root that no longer has any hooks pointing
|
|
518
|
+
// through it. Unlike hookFiles above, this is an optional file (only the
|
|
519
|
+
// manual/npm channel ever writes one) — so, mirroring the loop's own
|
|
520
|
+
// present/absent split, it is only added to `removed` (dry-run: "to remove")
|
|
521
|
+
// when it actually exists; a channel that never wrote one gets no line at
|
|
522
|
+
// all, not a spurious "already absent".
|
|
523
|
+
const sidecar = removeProvenanceSidecar(hooksDir, apply);
|
|
524
|
+
if (sidecar) removed.push(sidecar);
|
|
513
525
|
return { removed, missing };
|
|
514
526
|
}
|
|
515
527
|
|
package/scripts/upgrade.mjs
CHANGED
|
@@ -53,6 +53,7 @@ import {
|
|
|
53
53
|
writeDualSkipProvenance,
|
|
54
54
|
} from './lib/pkg-json.mjs';
|
|
55
55
|
import { syncExtensions } from './lib/extensions.mjs';
|
|
56
|
+
import { writeProvenanceSidecar } from './lib/pkg-provenance.mjs';
|
|
56
57
|
import { isHypomnemaPluginEnabled, resolveEnabledPluginRoot } from './lib/plugin-detect.mjs';
|
|
57
58
|
import { classifyInstall, downgradeGuardMessage } from '../hooks/version-check.mjs';
|
|
58
59
|
|
|
@@ -1135,6 +1136,12 @@ if (args.apply) {
|
|
|
1135
1136
|
);
|
|
1136
1137
|
}
|
|
1137
1138
|
appliedHooks = applyHookFiles(hooks, claudeHooksDir);
|
|
1139
|
+
// Refresh every run managesClaudeCore is true, not only when a stale hook
|
|
1140
|
+
// was actually copied above: applyHookFiles OVERWRITES (never skips) a
|
|
1141
|
+
// stale hook, but even a run that finds every hook already up-to-date
|
|
1142
|
+
// must still keep the sidecar's pkgVersion truthful, same reasoning as
|
|
1143
|
+
// installHooks's own refresh-every-run comment.
|
|
1144
|
+
writeProvenanceSidecar(claudeHooksDir, PKG_ROOT, readVersionAtRoot(PKG_ROOT), HOOKS_SRC, false);
|
|
1138
1145
|
appliedSettings = applySettingsJson(settings, claudeSettingsPath);
|
|
1139
1146
|
// applyCommands handles the single atomic hypo-pkg.json write (pkgRoot, version, schema, commands map)
|
|
1140
1147
|
appliedCommands = applyCommands(commands, args.forceCommands);
|
|
@@ -1196,6 +1203,7 @@ if (args.apply) {
|
|
|
1196
1203
|
);
|
|
1197
1204
|
}
|
|
1198
1205
|
appliedHooksCodex = applyHookFiles(hooksCodex, codexHooksDir);
|
|
1206
|
+
writeProvenanceSidecar(codexHooksDir, PKG_ROOT, readVersionAtRoot(PKG_ROOT), HOOKS_SRC, false);
|
|
1199
1207
|
appliedSettingsCodex = applySettingsJson(settingsCodex, codexSettingsPath);
|
|
1200
1208
|
}
|
|
1201
1209
|
// After applyCommands wrote hypo-pkg.json — merges extensions.<target> alongside.
|
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
|