hypomnema 1.7.1 → 1.7.3
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/commands/crystallize.md +5 -5
- package/hooks/hypo-hot-rebuild.mjs +22 -2
- package/hooks/hypo-session-start.mjs +60 -4
- package/hooks/hypo-shared.mjs +364 -118
- package/hooks/version-check.mjs +47 -0
- package/package.json +2 -1
- package/scripts/capture.mjs +15 -20
- package/scripts/crystallize.mjs +97 -12
- package/scripts/doctor.mjs +151 -7
- package/scripts/init.mjs +17 -7
- package/scripts/lib/extensions.mjs +43 -4
- package/scripts/lib/git-hooks-dir.mjs +10 -0
- package/scripts/lib/pkg-provenance.mjs +166 -0
- package/scripts/lint.mjs +45 -1
- package/scripts/uninstall.mjs +266 -2
- package/scripts/upgrade.mjs +8 -0
- package/templates/hypo-config.md +1 -1
package/hooks/version-check.mjs
CHANGED
|
@@ -430,6 +430,53 @@ export function clearPkgRootDriftNotified(path) {
|
|
|
430
430
|
}
|
|
431
431
|
}
|
|
432
432
|
|
|
433
|
+
// ── pkgRoot-null notice ─────────────────────────────────────────────────────
|
|
434
|
+
// A DIFFERENT failure than drift above: drift only ever fires when
|
|
435
|
+
// self-location resolved (PKG_ROOT is non-null, just disagreeing with the
|
|
436
|
+
// cache). This one fires when PKG_ROOT itself resolved to null — self-location
|
|
437
|
+
// failed AND no verified provenance sidecar covered it — the case where
|
|
438
|
+
// PreCompact's lint/feedback calls silently no-op because they have no root to
|
|
439
|
+
// shell scripts through. The two conditions can never both hold in the same
|
|
440
|
+
// session (drift requires a non-null self-location), so there is no "which
|
|
441
|
+
// wins" question in practice, but the two use separate cache fields regardless
|
|
442
|
+
// so neither implementation depends on that being true forever.
|
|
443
|
+
// Boolean (not a pair-key like drift) — the notified state is just "already
|
|
444
|
+
// told them this session's install has no resolvable pkgRoot".
|
|
445
|
+
|
|
446
|
+
/** Has the PKG_ROOT-null state already been surfaced since it last cleared? */
|
|
447
|
+
export function pkgRootNullAlreadyNotified(cache) {
|
|
448
|
+
return Boolean(cache && cache.pkgRootNullNotified === true);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/** Record that the PKG_ROOT-null banner was shown (read-merge-write). */
|
|
452
|
+
export function markPkgRootNullNotified(path) {
|
|
453
|
+
try {
|
|
454
|
+
const cache = readCache(path) || {};
|
|
455
|
+
cache.pkgRootNullNotified = true;
|
|
456
|
+
writeCacheAtomic(path, cache);
|
|
457
|
+
} catch {
|
|
458
|
+
/* best-effort */
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Clear a previously-recorded PKG_ROOT-null mark once PKG_ROOT resolves again
|
|
464
|
+
* (self-location or a verified provenance sidecar). Without this, a null state
|
|
465
|
+
* that resolves and then recurs later (e.g. the provenance sidecar's hash
|
|
466
|
+
* binding breaks again after a partial re-install) would stay silently
|
|
467
|
+
* suppressed forever. Read-merge-write, best-effort.
|
|
468
|
+
*/
|
|
469
|
+
export function clearPkgRootNullNotified(path) {
|
|
470
|
+
try {
|
|
471
|
+
const cache = readCache(path);
|
|
472
|
+
if (!cache || !('pkgRootNullNotified' in cache)) return;
|
|
473
|
+
delete cache.pkgRootNullNotified;
|
|
474
|
+
writeCacheAtomic(path, cache);
|
|
475
|
+
} catch {
|
|
476
|
+
/* best-effort */
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
433
480
|
/**
|
|
434
481
|
* Shared one-line message for the init/upgrade downgrade guard (P). `op` is
|
|
435
482
|
* 'init' or 'upgrade'. Kept here so guard text stays identical across both CLIs.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hypomnema",
|
|
3
|
-
"version": "1.7.
|
|
3
|
+
"version": "1.7.3",
|
|
4
4
|
"description": "LLM-native personal wiki system for Claude Code",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -38,6 +38,7 @@
|
|
|
38
38
|
"scripts/lib/hypo-root.mjs",
|
|
39
39
|
"scripts/lib/page-usage.mjs",
|
|
40
40
|
"scripts/lib/pkg-json.mjs",
|
|
41
|
+
"scripts/lib/pkg-provenance.mjs",
|
|
41
42
|
"scripts/lib/plugin-detect.mjs",
|
|
42
43
|
"scripts/lib/project-create.mjs",
|
|
43
44
|
"scripts/lib/rename-marker.mjs",
|
package/scripts/capture.mjs
CHANGED
|
@@ -42,9 +42,10 @@ import {
|
|
|
42
42
|
unlinkSync,
|
|
43
43
|
renameSync,
|
|
44
44
|
realpathSync,
|
|
45
|
-
lstatSync,
|
|
46
45
|
openSync,
|
|
47
46
|
closeSync,
|
|
47
|
+
statSync,
|
|
48
|
+
chmodSync,
|
|
48
49
|
} from 'fs';
|
|
49
50
|
import { randomBytes } from 'crypto';
|
|
50
51
|
import { join, dirname, relative, sep } from 'path';
|
|
@@ -76,6 +77,7 @@ import {
|
|
|
76
77
|
HOOK_EVENT_ALLOWLIST,
|
|
77
78
|
SKILL_ROOT_FILE,
|
|
78
79
|
EXT_PREFIX,
|
|
80
|
+
withSrcExecBits,
|
|
79
81
|
} from './lib/extensions.mjs';
|
|
80
82
|
import { readCoreHooksConfig, deriveCoreHookBasenames } from './lib/core-hooks.mjs';
|
|
81
83
|
|
|
@@ -582,7 +584,12 @@ function log(msg) {
|
|
|
582
584
|
// `${dest}.tmp.${pid}` name was predictable, and writeFileSync on a path someone had
|
|
583
585
|
// already planted a symlink at would follow it straight out of the wiki. O_EXCL fails
|
|
584
586
|
// on an existing path of any kind, symlink included.
|
|
585
|
-
|
|
587
|
+
// `srcMode`, when given, carries the source file's execute bit onto the wiki
|
|
588
|
+
// copy (openSync's own mode argument is clipped by umask same as writeFileSync,
|
|
589
|
+
// so this still has to be a separate chmod). Omitted for a manifest write: that
|
|
590
|
+
// content is JSON we generated, not a copy of something with a mode worth
|
|
591
|
+
// keeping.
|
|
592
|
+
function writeAtomic(dest, buf, srcMode) {
|
|
586
593
|
const tmp = `${dest}.tmp.${process.pid}.${randomBytes(6).toString('hex')}`;
|
|
587
594
|
const fd = openSync(tmp, 'wx');
|
|
588
595
|
try {
|
|
@@ -590,6 +597,9 @@ function writeAtomic(dest, buf) {
|
|
|
590
597
|
} finally {
|
|
591
598
|
closeSync(fd);
|
|
592
599
|
}
|
|
600
|
+
if (srcMode != null) {
|
|
601
|
+
chmodSync(tmp, withSrcExecBits(statSync(tmp).mode, srcMode));
|
|
602
|
+
}
|
|
593
603
|
try {
|
|
594
604
|
renameSync(tmp, dest);
|
|
595
605
|
} catch (err) {
|
|
@@ -719,7 +729,8 @@ function writeSkill({ rec, skillRoot, manifestPath, manifest, files, guard, wiki
|
|
|
719
729
|
madeDirs.add(cur);
|
|
720
730
|
}
|
|
721
731
|
}
|
|
722
|
-
|
|
732
|
+
const buf = readFileSync(f.srcPath);
|
|
733
|
+
writeAtomic(destPath, buf, statSync(f.srcPath).mode);
|
|
723
734
|
rec.createdFiles.push(destPath);
|
|
724
735
|
}
|
|
725
736
|
}
|
|
@@ -814,22 +825,6 @@ function captureOneSkill({ c, extDir, guard, wikiRoot, args, captured, skipped,
|
|
|
814
825
|
return;
|
|
815
826
|
}
|
|
816
827
|
|
|
817
|
-
// Content round-trips; the executable bit does not (forward-sync writes with the
|
|
818
|
-
// default mode). Say so rather than let a captured `scripts/run.sh` arrive
|
|
819
|
-
// non-executable on the far machine without a word.
|
|
820
|
-
const execFiles = c.files.filter((f) => {
|
|
821
|
-
try {
|
|
822
|
-
return (lstatSync(f.srcPath).mode & 0o111) !== 0;
|
|
823
|
-
} catch {
|
|
824
|
-
return false;
|
|
825
|
-
}
|
|
826
|
-
});
|
|
827
|
-
if (execFiles.length > 0) {
|
|
828
|
-
log(
|
|
829
|
-
`! ${label}: ${execFiles.length} executable file(s) — content is captured, but the executable bit is not carried by sync`,
|
|
830
|
-
);
|
|
831
|
-
}
|
|
832
|
-
|
|
833
828
|
if (!args.dryRun) {
|
|
834
829
|
// The ledger is owned by the caller so a throw MID-write is still recoverable: the
|
|
835
830
|
// paths created before the failure are already recorded in it.
|
|
@@ -1055,7 +1050,7 @@ function run(args, { claudeHome = join(HOME, '.claude') } = {}) {
|
|
|
1055
1050
|
manifestPrevBuf: existingManifestBuf,
|
|
1056
1051
|
};
|
|
1057
1052
|
writeAtomic(manifestPath, JSON.stringify(plan.manifest, null, 2) + '\n');
|
|
1058
|
-
writeAtomic(filePath, srcBuf);
|
|
1053
|
+
writeAtomic(filePath, srcBuf, statSync(c.srcPath).mode);
|
|
1059
1054
|
created.push(rec);
|
|
1060
1055
|
}
|
|
1061
1056
|
captured.push({ ...c, installFile, requiredKeys, status: 'ready' });
|
package/scripts/crystallize.mjs
CHANGED
|
@@ -109,6 +109,7 @@ import {
|
|
|
109
109
|
scopeVisible,
|
|
110
110
|
readVisibilityScope,
|
|
111
111
|
withFileLock,
|
|
112
|
+
extractTouchedWikiFilesWithTrust,
|
|
112
113
|
} from '../hooks/hypo-shared.mjs';
|
|
113
114
|
import { hashContent, readBaseEntry, advanceBase } from '../hooks/base-store.mjs';
|
|
114
115
|
import { writeProposal } from '../hooks/proposal-store.mjs';
|
|
@@ -225,6 +226,35 @@ function requireProjectDir(args, slug) {
|
|
|
225
226
|
}
|
|
226
227
|
}
|
|
227
228
|
|
|
229
|
+
// When the global gate's own discovery (hot.md pointer table + today
|
|
230
|
+
// close-activity scan, both in hypo-shared.mjs) comes back with NO project at
|
|
231
|
+
// all, a real apply never hits that dead end: it is handed `payload.project`
|
|
232
|
+
// directly and never infers. --check-session-close has no payload, so its one
|
|
233
|
+
// remaining authoritative signal is the same session's own transcript — which
|
|
234
|
+
// project's files did THIS session actually touch. Reusing the exact
|
|
235
|
+
// evidence-resolution helper the widened-lint-scope path already trusts here
|
|
236
|
+
// keeps this a single inference vocabulary (touched wiki files), not a second
|
|
237
|
+
// one: the difference is only which project-shaped question gets asked of it.
|
|
238
|
+
// Never guessed: a transcript touching zero or more than one project's files
|
|
239
|
+
// leaves the check exactly as unresolved as it was before this fallback.
|
|
240
|
+
function deriveTouchedProject(hypoDir, transcriptPath) {
|
|
241
|
+
if (!transcriptPath) return null;
|
|
242
|
+
const { files, trusted } = extractTouchedWikiFilesWithTrust(transcriptPath, hypoDir);
|
|
243
|
+
// `trusted:false` means the walk itself may be incomplete (a missing/unreadable
|
|
244
|
+
// transcript, or a line that failed to parse). A truncated line could have named
|
|
245
|
+
// a SECOND project the walk never saw, so treating this Set as "the whole
|
|
246
|
+
// truth" would resolve a single-project reading off a scope that is only
|
|
247
|
+
// single-project because part of it is missing, exactly the ambiguity this
|
|
248
|
+
// fallback exists to refuse rather than guess through.
|
|
249
|
+
if (!trusted) return null;
|
|
250
|
+
const slugs = new Set();
|
|
251
|
+
for (const f of files) {
|
|
252
|
+
const m = /^projects\/([^/]+)\//.exec(f);
|
|
253
|
+
if (m && existsSync(join(hypoDir, 'projects', m[1]))) slugs.add(m[1]);
|
|
254
|
+
}
|
|
255
|
+
return slugs.size === 1 ? [...slugs][0] : null;
|
|
256
|
+
}
|
|
257
|
+
|
|
228
258
|
// ── session-close check (spec §5.2.7 / §8.3) ────────────────────────
|
|
229
259
|
// Mirrors the hard gate in hypo-personal-check.mjs so the /hypo:crystallize
|
|
230
260
|
// flow can self-verify before /compact triggers PreCompact.
|
|
@@ -261,7 +291,7 @@ function runSessionCloseCheck(args) {
|
|
|
261
291
|
args.transcriptPath ||
|
|
262
292
|
(args.sessionId ? resolveTranscriptBySessionId(args.sessionId) : null) ||
|
|
263
293
|
null;
|
|
264
|
-
|
|
294
|
+
let status = precompactGateStatus(args.hypoDir, {
|
|
265
295
|
...(args.project
|
|
266
296
|
? { projectOverride: args.project }
|
|
267
297
|
: checkTranscript
|
|
@@ -276,7 +306,30 @@ function runSessionCloseCheck(args) {
|
|
|
276
306
|
// enforcement lives in the PreCompact/Stop hooks, which carry payload.cwd).
|
|
277
307
|
...(args.sessionCwd && !args.project ? { sessionCwd: args.sessionCwd } : {}),
|
|
278
308
|
});
|
|
309
|
+
|
|
310
|
+
// check/apply divergence (2026-08-25 QA): a real apply never hits discovery
|
|
311
|
+
// dead-ends because payload.project is required input, not an inference. This
|
|
312
|
+
// check has no payload, so when discovery finds NO project at all (not even
|
|
313
|
+
// the recency fallback), it retries scoped to whatever single project this
|
|
314
|
+
// session's own transcript shows it touching. This is a diagnostic estimate,
|
|
315
|
+
// not a preview of what a real apply will do: a payload's `project` field is
|
|
316
|
+
// whatever the caller puts there and can legitimately name a project the
|
|
317
|
+
// transcript never mentions. Only fires on a fully unresolved global result,
|
|
318
|
+
// and only on a TRUSTED single-project reading (see deriveTouchedProject): an
|
|
319
|
+
// already-successful discovery, an ambiguous/empty transcript, or one the walk
|
|
320
|
+
// could not fully read is left untouched rather than guessed at.
|
|
321
|
+
let inferredProject = null;
|
|
322
|
+
if (!args.project && !status.close.project) {
|
|
323
|
+
inferredProject = deriveTouchedProject(args.hypoDir, checkTranscript);
|
|
324
|
+
if (inferredProject) {
|
|
325
|
+
status = precompactGateStatus(args.hypoDir, {
|
|
326
|
+
projectOverride: inferredProject,
|
|
327
|
+
...(args.sessionId ? { sessionId: args.sessionId } : {}),
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
}
|
|
279
331
|
const close = status.close;
|
|
332
|
+
const scopedProject = args.project || inferredProject;
|
|
280
333
|
|
|
281
334
|
// When a --session-id is supplied, report whether THIS session's
|
|
282
335
|
// per-session marker (the Stop-chain completion signal) exists. This is a
|
|
@@ -301,8 +354,8 @@ function runSessionCloseCheck(args) {
|
|
|
301
354
|
// log-only marker governs the session, the gate runs in log-only mode and the
|
|
302
355
|
// --project override is IGNORED — surface that rather than implying X was
|
|
303
356
|
// checked (it was not).
|
|
304
|
-
const logOnlyWon =
|
|
305
|
-
const scope =
|
|
357
|
+
const logOnlyWon = scopedProject != null && markerObj?.scope === 'log-only';
|
|
358
|
+
const scope = scopedProject ? (logOnlyWon ? 'log-only' : 'project') : 'global';
|
|
306
359
|
|
|
307
360
|
if (args.json) {
|
|
308
361
|
console.log(
|
|
@@ -325,9 +378,13 @@ function runSessionCloseCheck(args) {
|
|
|
325
378
|
skipped: status.skipped,
|
|
326
379
|
// scope is additive; `global` keeps prior semantics for existing readers
|
|
327
380
|
scope,
|
|
328
|
-
...(
|
|
381
|
+
...(scopedProject
|
|
329
382
|
? {
|
|
330
|
-
scoped_project:
|
|
383
|
+
scoped_project: scopedProject,
|
|
384
|
+
// Distinguishes a user-typed --project from this check picking one
|
|
385
|
+
// for itself off the transcript — a reader should not mistake the
|
|
386
|
+
// latter for an explicit ask (see deriveTouchedProject above).
|
|
387
|
+
...(inferredProject ? { project_inferred_from_transcript: true } : {}),
|
|
331
388
|
...(logOnlyWon ? { project_override_ignored: true } : {}),
|
|
332
389
|
}
|
|
333
390
|
: {}),
|
|
@@ -340,13 +397,20 @@ function runSessionCloseCheck(args) {
|
|
|
340
397
|
process.exit(status.ok ? 0 : 1);
|
|
341
398
|
}
|
|
342
399
|
|
|
400
|
+
// Label the scoped project by how it was chosen — an explicit --project reads
|
|
401
|
+
// as a flag the caller typed; an inferred one reads as this check's own guess
|
|
402
|
+
// off the transcript, so a reader does not credit the caller with an ask
|
|
403
|
+
// nobody made.
|
|
404
|
+
const scopedProjectLabel = args.project
|
|
405
|
+
? `--project=${args.project}`
|
|
406
|
+
: `project=${scopedProject} (inferred from the session transcript, no --project given)`;
|
|
343
407
|
if (logOnlyWon) {
|
|
344
408
|
console.log(
|
|
345
|
-
`Note: a log-only session-closed marker governs session ${args.sessionId}, so the gate ran in log-only mode and
|
|
409
|
+
`Note: a log-only session-closed marker governs session ${args.sessionId}, so the gate ran in log-only mode and ${scopedProjectLabel} was IGNORED (no project was checked).\n`,
|
|
346
410
|
);
|
|
347
411
|
} else if (scope === 'project') {
|
|
348
412
|
console.log(
|
|
349
|
-
`Note:
|
|
413
|
+
`Note: ${scopedProjectLabel} — this is a PROJECT-SCOPED diagnostic, not the global /compact gate. A green result means only ${scopedProject} is close-complete; another project can still block /compact.\n`,
|
|
350
414
|
);
|
|
351
415
|
}
|
|
352
416
|
|
|
@@ -398,8 +462,8 @@ function runSessionCloseCheck(args) {
|
|
|
398
462
|
// Do NOT claim global compact-readiness (the whole point of the narrow).
|
|
399
463
|
console.log(
|
|
400
464
|
status.ok
|
|
401
|
-
? `✓ ${
|
|
402
|
-
: `✗ ${
|
|
465
|
+
? `✓ ${scopedProject} is close-complete (project-scoped). This is NOT a global /compact guarantee — run \`--check-session-close\` without --project for that.`
|
|
466
|
+
: `✗ ${scopedProject} is not close-complete — resolve the ✗ items above.`,
|
|
403
467
|
);
|
|
404
468
|
} else {
|
|
405
469
|
console.log(
|
|
@@ -1043,7 +1107,10 @@ function applySessionClose(args) {
|
|
|
1043
1107
|
stage: 'no-user-close-signal',
|
|
1044
1108
|
reason: closeAuth.reason,
|
|
1045
1109
|
applied: [],
|
|
1046
|
-
|
|
1110
|
+
// `null`, not `false`: this refusal fires before the commit step is ever
|
|
1111
|
+
// reached (see the general result's own `committed` contract below).
|
|
1112
|
+
// `false` is reserved for a commit that actually ran and failed.
|
|
1113
|
+
committed: null,
|
|
1047
1114
|
error: closeAuth.error,
|
|
1048
1115
|
};
|
|
1049
1116
|
console.log(
|
|
@@ -1103,7 +1170,9 @@ function applySessionClose(args) {
|
|
|
1103
1170
|
stage: 'session-id-mismatch',
|
|
1104
1171
|
error: msg,
|
|
1105
1172
|
applied: [],
|
|
1106
|
-
|
|
1173
|
+
// `null`, not `false` — refused before the commit step, same contract as
|
|
1174
|
+
// the `no-user-close-signal` refusal above.
|
|
1175
|
+
committed: null,
|
|
1107
1176
|
};
|
|
1108
1177
|
console.log(args.json ? JSON.stringify(out, null, 2) : `✗ ${msg}`);
|
|
1109
1178
|
process.exit(1);
|
|
@@ -1734,6 +1803,10 @@ function applySessionClose(args) {
|
|
|
1734
1803
|
// but silently.
|
|
1735
1804
|
let markerWritten = false;
|
|
1736
1805
|
let markerSkipReason = null;
|
|
1806
|
+
// Hoisted so the result JSON below can report it: `null` when this apply never
|
|
1807
|
+
// reached the commit step at all (ok:false before the writes were even
|
|
1808
|
+
// verified), distinct from a commit that ran and reported `committed:false`.
|
|
1809
|
+
let commitOutcome = null;
|
|
1737
1810
|
if (ok && args.sessionId) {
|
|
1738
1811
|
// IO stays lazy so this preserves the exact side-effect order (codex design
|
|
1739
1812
|
// review): commit first (the only mutation), then resolve the
|
|
@@ -1749,7 +1822,6 @@ function applySessionClose(args) {
|
|
|
1749
1822
|
// apply's stage+commit. A lock-timeout is treated exactly like any other
|
|
1750
1823
|
// commit failure below (skip the marker, surface the reason) rather than
|
|
1751
1824
|
// crashing the apply.
|
|
1752
|
-
let commitOutcome;
|
|
1753
1825
|
try {
|
|
1754
1826
|
commitOutcome = withFileLock(vaultCommitLockTarget(args.hypoDir), () =>
|
|
1755
1827
|
commitWikiChanges(args.hypoDir, appliedPaths),
|
|
@@ -1843,6 +1915,19 @@ function applySessionClose(args) {
|
|
|
1843
1915
|
date,
|
|
1844
1916
|
applied,
|
|
1845
1917
|
skipped,
|
|
1918
|
+
// Was the general-shape sibling of the two early-refusal `committed:null`
|
|
1919
|
+
// fields (no-user-close-signal / session-id-mismatch), which this path never
|
|
1920
|
+
// carried before: a reader of `applied:[]` on a no-op re-run had no
|
|
1921
|
+
// `committed` value to check against and no way to tell it apart from a run
|
|
1922
|
+
// that never reached the commit step. `null` here means exactly that: `ok`
|
|
1923
|
+
// came back false before the commit ever ran (see `stage` for which check
|
|
1924
|
+
// failed: post-apply-verification, post-apply-lint, or proposal-pending). It
|
|
1925
|
+
// does NOT mean nothing was written — an overwrite/append can already be on
|
|
1926
|
+
// disk (see `applied` / `appliedUncommitted`) while `committed` stays `null`.
|
|
1927
|
+
// `true` covers both an actual commit and the legitimate "nothing to stage"
|
|
1928
|
+
// no-op (commitWikiChanges' own contract, see hooks/hypo-shared.mjs); `false`
|
|
1929
|
+
// is a real commit failure, surfaced together with markerSkipReason below.
|
|
1930
|
+
committed: commitOutcome ? commitOutcome.committed : null,
|
|
1846
1931
|
// Targets withheld: an overwrite drifted from this session's observed base, or
|
|
1847
1932
|
// an append could not take the file lock in time (`kind: 'append'`). Two
|
|
1848
1933
|
// channels resolve these, and `proposals` vs `conflicts[].kind` are the sole
|
package/scripts/doctor.mjs
CHANGED
|
@@ -20,7 +20,7 @@ import { fileURLToPath } from 'url';
|
|
|
20
20
|
import { resolveHypoRoot, expandHome } from './lib/hypo-root.mjs';
|
|
21
21
|
import { loadHypoIgnore, isScanIgnored } from './lib/hypo-ignore.mjs';
|
|
22
22
|
import { readRenameMarker, renameMarkerPath, RENAME_MARKER_REL } from './lib/rename-marker.mjs';
|
|
23
|
-
import { resolveGitHooksDir } from './lib/git-hooks-dir.mjs';
|
|
23
|
+
import { resolveGitHooksDir, WIKI_PRE_COMMIT_MARKER_START } from './lib/git-hooks-dir.mjs';
|
|
24
24
|
import { parseFrontmatter } from './lib/frontmatter.mjs';
|
|
25
25
|
import {
|
|
26
26
|
readSyncState,
|
|
@@ -31,6 +31,8 @@ import {
|
|
|
31
31
|
detectSessionCloseArtifact,
|
|
32
32
|
localAndUtcDates,
|
|
33
33
|
SESSION_CLOSED_MARKER_STALE_MS,
|
|
34
|
+
isUsablePkgRootLocal,
|
|
35
|
+
selfLocationPkgRootFrom,
|
|
34
36
|
} from '../hooks/hypo-shared.mjs';
|
|
35
37
|
import { listProposals } from '../hooks/proposal-store.mjs';
|
|
36
38
|
import {
|
|
@@ -50,12 +52,19 @@ import {
|
|
|
50
52
|
CODEX_TYPES,
|
|
51
53
|
} from './lib/extensions.mjs';
|
|
52
54
|
import { sha256, readFileIfRegular, readPkgJson } from './lib/pkg-json.mjs';
|
|
55
|
+
import {
|
|
56
|
+
provenancePath,
|
|
57
|
+
EXPECTED_PKG_NAME,
|
|
58
|
+
HOOKS_DIGEST_FIELD,
|
|
59
|
+
computeHooksDigest,
|
|
60
|
+
} from './lib/pkg-provenance.mjs';
|
|
53
61
|
import { resolveCliOnPath, classifyInstall } from '../hooks/version-check.mjs';
|
|
54
62
|
import { isHypomnemaPluginEnabled } from './lib/plugin-detect.mjs';
|
|
55
63
|
|
|
56
64
|
const HOME = homedir();
|
|
57
65
|
const SCRIPT_DIR = fileURLToPath(new URL('.', import.meta.url));
|
|
58
66
|
const PKG_ROOT = join(SCRIPT_DIR, '..');
|
|
67
|
+
const HOOKS_SRC = join(PKG_ROOT, 'hooks');
|
|
59
68
|
|
|
60
69
|
// ── install channel ───────────────────────────────────────────────────────────
|
|
61
70
|
//
|
|
@@ -320,6 +329,8 @@ function checkHooks(coreManagedByPlugin) {
|
|
|
320
329
|
} else {
|
|
321
330
|
fail('Hook files installed', `No hook files found in ${claudeHooks} — run /hypo:init`);
|
|
322
331
|
}
|
|
332
|
+
|
|
333
|
+
checkProvenanceSidecar(claudeHooks, 'hooks/.hypo-provenance.json');
|
|
323
334
|
}
|
|
324
335
|
|
|
325
336
|
function checkSettingsJson(coreManagedByPlugin) {
|
|
@@ -495,7 +506,7 @@ function checkGit(hypoDir) {
|
|
|
495
506
|
? 'Not installed — run /hypo:init to install .hypoignore guard'
|
|
496
507
|
: 'Not installed, and /hypo:init will not install into this path — point core.hooksPath back inside the repository, or install the guard yourself',
|
|
497
508
|
);
|
|
498
|
-
} else if (content.includes(
|
|
509
|
+
} else if (content.includes(WIKI_PRE_COMMIT_MARKER_START)) {
|
|
499
510
|
pass(label, 'Hypomnema .hypoignore guard installed');
|
|
500
511
|
} else {
|
|
501
512
|
warn(label, 'Exists but not managed by Hypomnema — manual git add can bypass .hypoignore');
|
|
@@ -813,11 +824,9 @@ function deriveCommitProjects(hypoDir, hash) {
|
|
|
813
824
|
// projects/a/hot.md → projects/b/hot.md rename would silently drop project
|
|
814
825
|
// a from scope — a marker naming only "b" would then wrongly cover the
|
|
815
826
|
// whole commit. -M's tab-separated `status\told\tnew` line carries both.
|
|
816
|
-
const show = spawnSync(
|
|
817
|
-
'
|
|
818
|
-
|
|
819
|
-
{ encoding: 'utf-8' },
|
|
820
|
-
);
|
|
827
|
+
const show = spawnSync('git', ['-C', hypoDir, 'show', '--name-status', '-M', '--format=', hash], {
|
|
828
|
+
encoding: 'utf-8',
|
|
829
|
+
});
|
|
821
830
|
if (show.status !== 0 || !show.stdout) return [];
|
|
822
831
|
const projects = new Set();
|
|
823
832
|
for (const line of show.stdout.split('\n')) {
|
|
@@ -1230,6 +1239,8 @@ function checkCodexPaths() {
|
|
|
1230
1239
|
);
|
|
1231
1240
|
}
|
|
1232
1241
|
|
|
1242
|
+
checkProvenanceSidecar(codexHooks, 'Codex hooks/.hypo-provenance.json');
|
|
1243
|
+
|
|
1233
1244
|
const settingsPath = join(HOME, '.codex', 'settings.json');
|
|
1234
1245
|
if (!existsSync(settingsPath)) {
|
|
1235
1246
|
warn(
|
|
@@ -1828,6 +1839,139 @@ function checkStaleSibling() {
|
|
|
1828
1839
|
}
|
|
1829
1840
|
}
|
|
1830
1841
|
|
|
1842
|
+
// ── provenance sidecar (manual/npm channel) ────────────────────────────────────
|
|
1843
|
+
//
|
|
1844
|
+
// `.hypo-provenance.json` lives next to a standalone-copied hooks/ dir
|
|
1845
|
+
// (installHooks/applyHookFiles — scripts/lib/pkg-provenance.mjs) and is what
|
|
1846
|
+
// hooks/hypo-shared.mjs's resolvePkgRoot() falls back to when self-location
|
|
1847
|
+
// can't resolve. Absent is a normal state whenever there is no INSTALLED
|
|
1848
|
+
// hooks/hypo-shared.mjs at `hooksDir` to worry about at all (plugin channel:
|
|
1849
|
+
// hooks run straight from CLAUDE_PLUGIN_ROOT, nothing is ever copied here;
|
|
1850
|
+
// a truly fresh, never-inited home) — that case stays silent. It is also
|
|
1851
|
+
// normal, and stays silent, when hypo-shared.mjs IS installed here but
|
|
1852
|
+
// self-locates on its own (a dev checkout whose "hooksDir" happens to be the
|
|
1853
|
+
// package's own hooks/, not a copy). The one PRESENT-but-broken and the one
|
|
1854
|
+
// ABSENT-but-should-not-be-silent case are both actionable (CONCERN 4):
|
|
1855
|
+
// - present sidecar that fails to verify → WARN, same three-way shape as
|
|
1856
|
+
// checkPkgIntegrity below, never FAIL (a broken sidecar just degrades
|
|
1857
|
+
// PKG_ROOT to null, surfaced live by hypo-session-start.mjs's
|
|
1858
|
+
// PKG_ROOT-null banner, not corrupting anything on disk)
|
|
1859
|
+
// - installed hypo-shared.mjs, self-location fails for it, AND no sidecar
|
|
1860
|
+
// at all → that combination IS "PKG_ROOT is null for every hook running
|
|
1861
|
+
// from here" (resolvePkgRoot() has nothing left to try), so silence here
|
|
1862
|
+
// would hide exactly the state the live banner already warns about
|
|
1863
|
+
function checkProvenanceSidecar(hooksDir, label) {
|
|
1864
|
+
const sidecarPath = provenancePath(hooksDir);
|
|
1865
|
+
if (!existsSync(sidecarPath)) {
|
|
1866
|
+
if (existsSync(join(hooksDir, 'hypo-shared.mjs')) && !selfLocationPkgRootFrom(hooksDir)) {
|
|
1867
|
+
warn(
|
|
1868
|
+
label,
|
|
1869
|
+
`no provenance sidecar, and this install's hooks cannot self-locate their ` +
|
|
1870
|
+
`package — PKG_ROOT resolves to null for every hook running from ${hooksDir}; ` +
|
|
1871
|
+
`run \`hypomnema upgrade --apply\` to write one`,
|
|
1872
|
+
);
|
|
1873
|
+
}
|
|
1874
|
+
return;
|
|
1875
|
+
}
|
|
1876
|
+
|
|
1877
|
+
let sidecar;
|
|
1878
|
+
try {
|
|
1879
|
+
sidecar = JSON.parse(readFileSync(sidecarPath, 'utf-8'));
|
|
1880
|
+
} catch {
|
|
1881
|
+
warn(
|
|
1882
|
+
label,
|
|
1883
|
+
`${sidecarPath} is not valid JSON — run \`hypomnema upgrade --apply\` to rewrite it`,
|
|
1884
|
+
);
|
|
1885
|
+
return;
|
|
1886
|
+
}
|
|
1887
|
+
|
|
1888
|
+
const { pkgRoot, hypoSharedSha256, [HOOKS_DIGEST_FIELD]: hooksDigest } = sidecar || {};
|
|
1889
|
+
|
|
1890
|
+
// Same predicate the runtime resolver requires (isUsablePkgRootLocal, used
|
|
1891
|
+
// by readVerifiedProvenancePkgRoot in hooks/hypo-shared.mjs) — imported,
|
|
1892
|
+
// not hand-rolled again here, so a version-less "name":"hypomnema" root can
|
|
1893
|
+
// no longer PASS this check while resolvePkgRoot() treats it as null.
|
|
1894
|
+
const usableOk = isUsablePkgRootLocal(pkgRoot);
|
|
1895
|
+
|
|
1896
|
+
let producerName = null;
|
|
1897
|
+
try {
|
|
1898
|
+
producerName = JSON.parse(readFileSync(join(pkgRoot, 'package.json'), 'utf-8')).name;
|
|
1899
|
+
} catch {
|
|
1900
|
+
/* pkgRoot missing/unreadable — nameOk stays false below */
|
|
1901
|
+
}
|
|
1902
|
+
const nameOk = producerName === EXPECTED_PKG_NAME;
|
|
1903
|
+
|
|
1904
|
+
let hashOk = false;
|
|
1905
|
+
try {
|
|
1906
|
+
hashOk = sha256(readFileSync(join(hooksDir, 'hypo-shared.mjs'))) === hypoSharedSha256;
|
|
1907
|
+
} catch {
|
|
1908
|
+
/* hooks/hypo-shared.mjs unreadable in its own hooks dir — hashOk stays false */
|
|
1909
|
+
}
|
|
1910
|
+
|
|
1911
|
+
if (usableOk && nameOk && hashOk) {
|
|
1912
|
+
// hooksDigest (BLOCKER A item 3) can still turn this into a warn: it is
|
|
1913
|
+
// folded into the SAME label/check rather than pushed as a second entry,
|
|
1914
|
+
// so a caller reading checks by label (every other check in this file,
|
|
1915
|
+
// and every test asserting on one) sees exactly one verdict per sidecar.
|
|
1916
|
+
const digestIssue = hooksDigestMismatch(hooksDir, hooksDigest);
|
|
1917
|
+
if (!digestIssue) {
|
|
1918
|
+
pass(label, `verified — resolves to ${pkgRoot}`);
|
|
1919
|
+
} else {
|
|
1920
|
+
warn(label, `verified (pkgRoot/hash), but ${digestIssue}`);
|
|
1921
|
+
}
|
|
1922
|
+
return;
|
|
1923
|
+
}
|
|
1924
|
+
const reasons = [];
|
|
1925
|
+
if (!usableOk) {
|
|
1926
|
+
reasons.push(
|
|
1927
|
+
`recorded pkgRoot (${pkgRoot}) is not a usable package root (must be an absolute ` +
|
|
1928
|
+
`path to a directory whose package.json carries a version)`,
|
|
1929
|
+
);
|
|
1930
|
+
}
|
|
1931
|
+
if (!nameOk) reasons.push(`recorded pkgRoot (${pkgRoot}) is not this package`);
|
|
1932
|
+
if (!hashOk)
|
|
1933
|
+
reasons.push('recorded hypoSharedSha256 does not match the installed hypo-shared.mjs');
|
|
1934
|
+
warn(
|
|
1935
|
+
label,
|
|
1936
|
+
`${sidecarPath} does not verify (${reasons.join('; ')}) — resolvePkgRoot() will treat ` +
|
|
1937
|
+
`PKG_ROOT as unresolved this session; run \`hypomnema upgrade --apply\` to refresh it`,
|
|
1938
|
+
);
|
|
1939
|
+
}
|
|
1940
|
+
|
|
1941
|
+
// BLOCKER A item 3: the runtime SHA check above (hashOk) pins ONE file,
|
|
1942
|
+
// hypo-shared.mjs — see that function's own hooks/hypo-shared.mjs-side
|
|
1943
|
+
// comment for why. This pins every OTHER file hooks.json wires up too, once
|
|
1944
|
+
// per `hypomnema doctor` run rather than once per hook load (too expensive
|
|
1945
|
+
// to do there — see computeHooksDigest's doc comment in
|
|
1946
|
+
// scripts/lib/pkg-provenance.mjs). Skips silently when the sidecar predates
|
|
1947
|
+
// this field (an older init/upgrade wrote it before BLOCKER A existed) —
|
|
1948
|
+
// there is nothing recorded to compare against, and the runtime never reads
|
|
1949
|
+
// this field either, so staying quiet hides nothing the runtime already
|
|
1950
|
+
// trusts.
|
|
1951
|
+
//
|
|
1952
|
+
// Returns null when there is nothing to report (field absent, or digests
|
|
1953
|
+
// match), or a ready-to-append reason string naming a few diverged files.
|
|
1954
|
+
function hooksDigestMismatch(hooksDir, recordedDigest) {
|
|
1955
|
+
if (typeof recordedDigest !== 'string' || !recordedDigest) return null;
|
|
1956
|
+
const actualDigest = computeHooksDigest(PKG_ROOT, hooksDir);
|
|
1957
|
+
if (actualDigest === null || actualDigest === recordedDigest) return null;
|
|
1958
|
+
|
|
1959
|
+
const allFiles = [...Object.values(HOOK_MAP).flat(), ...SHARED_FILES];
|
|
1960
|
+
const diverged = allFiles.filter((file) => {
|
|
1961
|
+
try {
|
|
1962
|
+
return !readFileSync(join(hooksDir, file)).equals(readFileSync(join(HOOKS_SRC, file)));
|
|
1963
|
+
} catch {
|
|
1964
|
+
return true; // unreadable on either side counts as diverged
|
|
1965
|
+
}
|
|
1966
|
+
});
|
|
1967
|
+
const examples = diverged.slice(0, 3).join(', ');
|
|
1968
|
+
return (
|
|
1969
|
+
`hooksDigest mismatch: ${diverged.length}/${allFiles.length} hook file(s) differ from ` +
|
|
1970
|
+
`the current package source${examples ? ` (e.g. ${examples})` : ''} — run ` +
|
|
1971
|
+
`\`hypomnema upgrade --apply\``
|
|
1972
|
+
);
|
|
1973
|
+
}
|
|
1974
|
+
|
|
1831
1975
|
// ── package integrity ─────────────────────────────────────────────────────────
|
|
1832
1976
|
//
|
|
1833
1977
|
// ~/.claude/hypo-pkg.json is a snapshot written once by init/upgrade and never
|
package/scripts/init.mjs
CHANGED
|
@@ -38,7 +38,14 @@ 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 {
|
|
41
|
+
import {
|
|
42
|
+
hooksDirForInstall,
|
|
43
|
+
unsafeHookTargetReason,
|
|
44
|
+
WIKI_PRE_COMMIT_MARKER_START,
|
|
45
|
+
WIKI_PRE_COMMIT_MARKER_END,
|
|
46
|
+
SHELL_MARKER_START,
|
|
47
|
+
SHELL_MARKER_END,
|
|
48
|
+
} from './lib/git-hooks-dir.mjs';
|
|
42
49
|
import { readCoreHooksConfig } from './lib/core-hooks.mjs';
|
|
43
50
|
import {
|
|
44
51
|
readPkgJson as readPkgJsonSafe,
|
|
@@ -48,6 +55,7 @@ import {
|
|
|
48
55
|
readFileIfRegular,
|
|
49
56
|
} from './lib/pkg-json.mjs';
|
|
50
57
|
import { syncExtensions } from './lib/extensions.mjs';
|
|
58
|
+
import { writeProvenanceSidecar } from './lib/pkg-provenance.mjs';
|
|
51
59
|
import { templateSchemaVersion } from './lib/template-schema-version.mjs';
|
|
52
60
|
import { classifyInstall, downgradeGuardMessage } from '../hooks/version-check.mjs';
|
|
53
61
|
import {
|
|
@@ -458,6 +466,14 @@ function installHooks(targetDir, dryRun) {
|
|
|
458
466
|
if (!dryRun) copyFileSync(join(HOOKS_SRC, file), dest);
|
|
459
467
|
log('created', dest);
|
|
460
468
|
}
|
|
469
|
+
// Refresh the provenance sidecar every run, even when every .mjs above was
|
|
470
|
+
// skipped as already-present: this is the standalone (manual/npm) channel
|
|
471
|
+
// only (the plugin channel never calls installHooks), and resolvePkgRoot()'s
|
|
472
|
+
// provenance fallback needs it to track the truth of what is actually on
|
|
473
|
+
// disk here, not just what a fresh copy left behind. See
|
|
474
|
+
// hooks/hypo-shared.mjs's readVerifiedProvenancePkgRoot() for the reader.
|
|
475
|
+
const sidecar = writeProvenanceSidecar(targetDir, PKG_ROOT, PKG_VERSION, HOOKS_SRC, dryRun);
|
|
476
|
+
if (sidecar) log('created', sidecar);
|
|
461
477
|
}
|
|
462
478
|
|
|
463
479
|
function mergeSettingsJson(settingsPath, hooksDir, dryRun, hookMap) {
|
|
@@ -739,9 +755,6 @@ function installPkgGitHook(dryRun) {
|
|
|
739
755
|
|
|
740
756
|
// ── wiki pre-commit hook ─────────────────────────────────────────────────────
|
|
741
757
|
|
|
742
|
-
const WIKI_PRE_COMMIT_MARKER_START = '# hypo-managed:pre-commit:start';
|
|
743
|
-
const WIKI_PRE_COMMIT_MARKER_END = '# hypo-managed:pre-commit:end';
|
|
744
|
-
|
|
745
758
|
// Single-quote escaping prevents shell expansion of special chars (e.g. $HOME, backticks) in path
|
|
746
759
|
function shellSingleQuote(p) {
|
|
747
760
|
return `'${p.replace(/'/g, "'\\''")}'`;
|
|
@@ -850,9 +863,6 @@ function installWikiPreCommitHook(hypoDir, dryRun, force, root, lintStrict) {
|
|
|
850
863
|
|
|
851
864
|
// ── shell function setup ─────────────────────────────────────────────────────
|
|
852
865
|
|
|
853
|
-
const SHELL_MARKER_START = '# hypo-managed:shell-setup:start';
|
|
854
|
-
const SHELL_MARKER_END = '# hypo-managed:shell-setup:end';
|
|
855
|
-
|
|
856
866
|
function shellFunctionBlock() {
|
|
857
867
|
return `${SHELL_MARKER_START}
|
|
858
868
|
function claude() {
|