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/scripts/graph.mjs CHANGED
@@ -91,7 +91,7 @@ function escapeDot(s) {
91
91
 
92
92
  // ── formatters ────────────────────────────────────────────────────────────────
93
93
 
94
- function formatJson(pages, graph, minEdges) {
94
+ function formatJson(pages, graph, minEdges, vaultFound) {
95
95
  const nodes = pages
96
96
  .map((p) => ({
97
97
  slug: p.slug,
@@ -107,7 +107,9 @@ function formatJson(pages, graph, minEdges) {
107
107
  return fn && tn;
108
108
  });
109
109
 
110
- return JSON.stringify({ nodes, edges }, null, 2);
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);
111
113
  }
112
114
 
113
115
  function formatMermaid(pages, graph, minEdges) {
@@ -156,7 +158,11 @@ function formatDot(pages, graph, minEdges) {
156
158
  const args = parseArgs(process.argv);
157
159
  // Only validate the auto-resolved path (env/marker/default). An explicit
158
160
  // --hypo-dir=<path> (tests, other tooling) is trusted as-is, valid or not.
159
- if (args.hypoDirSource) checkVaultOrExit(args.hypoDir, args.hypoDirSource);
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;
160
166
 
161
167
  const ignorePatterns = loadHypoIgnore(args.hypoDir);
162
168
  const scanDirs = ['pages', 'projects'].map((d) => join(args.hypoDir, d));
@@ -164,13 +170,22 @@ const pages = scanDirs.flatMap((d) => collectPagesGraph(d, args.hypoDir, ignoreP
164
170
  const slugIndex = buildSlugIndex(pages);
165
171
  const graph = buildGraph(pages, slugIndex);
166
172
 
167
- switch (args.format) {
168
- case 'mermaid':
169
- console.log(formatMermaid(pages, graph, args.minEdges));
170
- break;
171
- case 'dot':
172
- console.log(formatDot(pages, graph, args.minEdges));
173
- break;
174
- default:
175
- console.log(formatJson(pages, graph, args.minEdges));
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
+ }
176
191
  }
package/scripts/init.mjs CHANGED
@@ -38,6 +38,7 @@ 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,
@@ -47,6 +48,7 @@ import {
47
48
  readFileIfRegular,
48
49
  } from './lib/pkg-json.mjs';
49
50
  import { syncExtensions } from './lib/extensions.mjs';
51
+ import { writeProvenanceSidecar } from './lib/pkg-provenance.mjs';
50
52
  import { templateSchemaVersion } from './lib/template-schema-version.mjs';
51
53
  import { classifyInstall, downgradeGuardMessage } from '../hooks/version-check.mjs';
52
54
  import {
@@ -142,6 +144,9 @@ Commands:
142
144
  capture Reverse-capture ~/.claude/{commands,agents} extensions and
143
145
  settings.json hooks into the wiki for cross-machine sync
144
146
  (pass names or --all; --dry-run to preview)
147
+ proposal Manage staged crystallize proposals: list, apply, or
148
+ discard a parked write (also challenge/resolve for the
149
+ no-TTY agent approval channel)
145
150
 
146
151
  Running \`hypomnema\` with no command is equivalent to \`hypomnema init\`.
147
152
 
@@ -164,11 +169,15 @@ Init options:
164
169
  to a blocking error), sequenced after the .hypoignore
165
170
  guard. Off by default; re-run init to add or drop it.
166
171
  --dry-run Show what would be done without making changes
172
+ --version Print the installed package version and exit
167
173
  --help, -h Show this help message
168
174
 
169
175
  Subcommand-specific flags (upgrade/doctor/uninstall/capture) live in the
170
176
  docstring at the top of scripts/<command>.mjs; capture also accepts \`--help\`.`);
171
177
  process.exit(0);
178
+ } else if (arg === '--version') {
179
+ console.log(PKG_VERSION ?? 'unknown');
180
+ process.exit(0);
172
181
  } else if (arg.startsWith('--hypo-dir=')) args.hypoDir = expandHome(arg.slice(11));
173
182
  else if (arg === '--no-hooks') args.hooks = false;
174
183
  else if (arg === '--no-commands') args.commands = false;
@@ -189,6 +198,18 @@ docstring at the top of scripts/<command>.mjs; capture also accepts \`--help\`.`
189
198
  else if (arg.startsWith('--shell-config=')) args.shellConfig = expandHome(arg.slice(15));
190
199
  else if (arg === '--allow-downgrade') args.allowDowngrade = true;
191
200
  else if (arg === '--lint-strict') args.lintStrict = true;
201
+ else {
202
+ // An unrecognized argument used to fall through silently and run the
203
+ // default init flow (scaffold + hook install + settings.json merge) —
204
+ // a destructive write triggered by what looked like a query. `--version`
205
+ // was the concrete case that bit a user: it reads like an inspection
206
+ // flag, was never implemented, and its typo/mistake landed on a live
207
+ // wiki's pre-commit hook instead of printing anything. Fail loud and
208
+ // do nothing, rather than guess what the caller meant.
209
+ console.error(`Unknown option: ${arg}`);
210
+ console.error('Run `hypomnema --help` for usage.');
211
+ process.exit(2);
212
+ }
192
213
  }
193
214
  return args;
194
215
  }
@@ -438,6 +459,14 @@ function installHooks(targetDir, dryRun) {
438
459
  if (!dryRun) copyFileSync(join(HOOKS_SRC, file), dest);
439
460
  log('created', dest);
440
461
  }
462
+ // Refresh the provenance sidecar every run, even when every .mjs above was
463
+ // skipped as already-present: this is the standalone (manual/npm) channel
464
+ // only (the plugin channel never calls installHooks), and resolvePkgRoot()'s
465
+ // provenance fallback needs it to track the truth of what is actually on
466
+ // disk here, not just what a fresh copy left behind. See
467
+ // hooks/hypo-shared.mjs's readVerifiedProvenancePkgRoot() for the reader.
468
+ const sidecar = writeProvenanceSidecar(targetDir, PKG_ROOT, PKG_VERSION, HOOKS_SRC, dryRun);
469
+ if (sidecar) log('created', sidecar);
441
470
  }
442
471
 
443
472
  function mergeSettingsJson(settingsPath, hooksDir, dryRun, hookMap) {
@@ -693,10 +722,19 @@ function installCommands(targetDir, dryRun, force) {
693
722
  }
694
723
 
695
724
  function installPkgGitHook(dryRun) {
696
- const gitDir = join(PKG_ROOT, '.git');
697
- if (!existsSync(gitDir)) return;
698
- const hooksDir = join(gitDir, 'hooks');
725
+ const { dir: hooksDir, skip } = hooksDirForInstall(PKG_ROOT);
726
+ if (!hooksDir) {
727
+ if (skip) log('skipped', `${PKG_ROOT} post-commit (${skip})`);
728
+ return;
729
+ }
699
730
  const hookPath = join(hooksDir, 'post-commit');
731
+ // Checked before existsSync: a dangling symlink reads as absent there, and
732
+ // writing would create its external target.
733
+ const unsafe = unsafeHookTargetReason(hookPath);
734
+ if (unsafe) {
735
+ log('skipped', `${hookPath} (${unsafe})`);
736
+ return;
737
+ }
700
738
  if (existsSync(hookPath)) {
701
739
  log('skipped', hookPath);
702
740
  return;
@@ -758,12 +796,23 @@ function wikiPreCommitContent(root, hypoDir, lintStrict) {
758
796
  }
759
797
 
760
798
  function installWikiPreCommitHook(hypoDir, dryRun, force, root, lintStrict) {
761
- const gitDir = join(hypoDir, '.git');
762
- if (!existsSync(gitDir)) return; // no git repo — silently skip
763
- const hooksDir = join(gitDir, 'hooks');
799
+ const { dir: hooksDir, skip } = hooksDirForInstall(hypoDir);
800
+ if (!hooksDir) {
801
+ // no git repo — silently skip, as before; anything else is worth surfacing
802
+ if (skip) log('skipped', `${hypoDir} pre-commit (${skip})`);
803
+ return;
804
+ }
764
805
  const hookPath = join(hooksDir, 'pre-commit');
765
806
  const newContent = wikiPreCommitContent(root, hypoDir, lintStrict);
766
807
 
808
+ // Before every branch below, including --force: a symlink here would send the
809
+ // write through to an arbitrary external file.
810
+ const unsafe = unsafeHookTargetReason(hookPath);
811
+ if (unsafe) {
812
+ log('skipped', `${hookPath} (${unsafe})`);
813
+ return;
814
+ }
815
+
767
816
  if (existsSync(hookPath)) {
768
817
  const existing = readFileSync(hookPath, 'utf-8');
769
818
  if (existing.includes(WIKI_PRE_COMMIT_MARKER_START)) {
@@ -777,8 +826,18 @@ function installWikiPreCommitHook(hypoDir, dryRun, force, root, lintStrict) {
777
826
  }
778
827
  log('merged', `${hookPath} (pre-commit updated)`);
779
828
  } else if (force) {
829
+ // The .bak is a SECOND write to a DIFFERENT path, so the guard on
830
+ // hookPath above says nothing about it. Left unchecked, a pre-commit.bak
831
+ // symlink turns --force-commands into an overwrite of whatever it points
832
+ // at — deterministically, not as a race.
833
+ const bakPath = hookPath + '.bak';
834
+ const unsafeBak = unsafeHookTargetReason(bakPath);
835
+ if (unsafeBak) {
836
+ log('skipped', `${bakPath} (${unsafeBak}) — not force-overwriting without a safe backup`);
837
+ return;
838
+ }
780
839
  if (!dryRun) {
781
- writeFileSync(hookPath + '.bak', existing);
840
+ writeFileSync(bakPath, existing);
782
841
  writeFileSync(hookPath, newContent);
783
842
  chmodSync(hookPath, 0o755);
784
843
  }
@@ -0,0 +1,229 @@
1
+ /**
2
+ * Resolve a repository's active git hooks directory by asking git, never by
3
+ * assuming the on-disk layout.
4
+ *
5
+ * The layout assumption this replaces (`join(root, '.git', 'hooks')`) is wrong
6
+ * in two ways that both showed up in practice:
7
+ *
8
+ * 1. In a linked worktree `.git` is a regular FILE holding `gitdir: <path>`,
9
+ * so `existsSync` passes and `mkdirSync` dies with ENOTDIR.
10
+ * 2. When `core.hooksPath` is set, git does not read `.git/hooks` at all, so
11
+ * a hook written there is inert.
12
+ *
13
+ * `git rev-parse --git-path hooks` handles both: it follows the worktree's
14
+ * gitdir pointer AND substitutes `core.hooksPath` (verified on git 2.50.1 —
15
+ * `/dev/null` stays `/dev/null`, a relative value stays relative, and `~` is
16
+ * expanded). That makes rev-parse the single authority here; reading
17
+ * `core.hooksPath` out of the config ourselves would be strictly worse, since
18
+ * it would leave `~` unexpanded and could not tell an empty-but-set value from
19
+ * an unset one.
20
+ *
21
+ * Two things rev-parse does NOT give us, so we add them:
22
+ *
23
+ * - `git -C <root>` does not neutralize ambient git environment variables.
24
+ * `GIT_DIR` + `GIT_WORK_TREE` redirect the probe at a foreign repository,
25
+ * and `GIT_CONFIG_COUNT`/`GIT_CONFIG_PARAMETERS` redirect it at an
26
+ * arbitrary hooks path. Every probe therefore runs under a scrubbed env,
27
+ * with the scrub list taken from git's own `--local-env-vars` when
28
+ * available.
29
+ * - `core.hooksPath` may point at a directory SHARED by many repositories
30
+ * (the documented centralized-hooks pattern). Auto-installing there would
31
+ * put our hook in front of unrelated repositories' commits, and our
32
+ * post-commit executes `$REPO_ROOT/scripts/upgrade.mjs` dynamically. So the
33
+ * result carries an `owned` flag, and callers that WRITE must refuse when
34
+ * it is false. Callers that only READ (doctor) may report the path.
35
+ */
36
+
37
+ import { execFileSync } from 'child_process';
38
+ import { existsSync, lstatSync, realpathSync, statSync } from 'fs';
39
+ import { basename, dirname, isAbsolute, join, resolve, sep } from 'path';
40
+
41
+ // Fallback scrub list for git versions without `rev-parse --local-env-vars`.
42
+ // Mirrors scripts/install-git-hooks.mjs, which established this trust model.
43
+ const STATIC_LOCAL_ENV_VARS = [
44
+ 'GIT_DIR',
45
+ 'GIT_WORK_TREE',
46
+ 'GIT_INDEX_FILE',
47
+ 'GIT_OBJECT_DIRECTORY',
48
+ 'GIT_ALTERNATE_OBJECT_DIRECTORIES',
49
+ 'GIT_COMMON_DIR',
50
+ 'GIT_CONFIG',
51
+ 'GIT_CONFIG_PARAMETERS',
52
+ 'GIT_PREFIX',
53
+ 'GIT_IMPLICIT_WORK_TREE',
54
+ 'GIT_GRAFT_FILE',
55
+ 'GIT_NO_REPLACE_OBJECTS',
56
+ 'GIT_REPLACE_REF_BASE',
57
+ 'GIT_SHALLOW_FILE',
58
+ ];
59
+
60
+ function buildScrubbedEnv(localEnvList) {
61
+ const scrub = new Set([
62
+ ...(localEnvList || STATIC_LOCAL_ENV_VARS),
63
+ 'GIT_NAMESPACE',
64
+ 'GIT_CEILING_DIRECTORIES',
65
+ // GIT_CONFIG_COUNT / GIT_CONFIG_KEY_n / GIT_CONFIG_VALUE_n inject config
66
+ // wholesale and are not always listed by --local-env-vars.
67
+ ...Object.keys(process.env).filter((k) => /^GIT_CONFIG_/.test(k)),
68
+ ]);
69
+ return Object.fromEntries(Object.entries(process.env).filter(([k]) => !scrub.has(k)));
70
+ }
71
+
72
+ // Canonicalize a path that may not exist yet: realpath the deepest existing
73
+ // ancestor and re-append the rest. Without this, a hooks dir git will create
74
+ // lazily could evade the containment check via an unresolved symlinked parent.
75
+ function canonicalize(p) {
76
+ let cur = resolve(p);
77
+ const tail = [];
78
+ for (;;) {
79
+ try {
80
+ return join(realpathSync(cur), ...tail);
81
+ } catch {
82
+ const parent = dirname(cur);
83
+ if (parent === cur) return resolve(p); // hit the root; nothing to resolve
84
+ // basename, not a slice: when the parent IS the root it already ends in a
85
+ // separator, so `parent.length + 1` would eat the first real character
86
+ // ("/Nope/x" -> "ope/x") and could rewrite an external path into one that
87
+ // looks repository-owned.
88
+ tail.unshift(basename(cur));
89
+ cur = parent;
90
+ }
91
+ }
92
+ }
93
+
94
+ function isInside(child, parent) {
95
+ return child === parent || child.startsWith(parent + sep);
96
+ }
97
+
98
+ /**
99
+ * @param {string} repoRoot working tree root to probe
100
+ * @param {{timeoutMs?: number}} [opts]
101
+ * @returns {{ok: true, path: string, owned: boolean, gitDir: string, commonDir: string}
102
+ * |{ok: false, reason: string, detail?: string, path?: string}}
103
+ *
104
+ * Failure reasons are deliberately distinct so callers can react differently:
105
+ * not-a-repo no `.git` entry (also the case for a bare repo — matches
106
+ * the pre-existing behavior of every call site)
107
+ * git-unavailable git is not on PATH / not executable
108
+ * probe-failed git ran but could not resolve the repo (stale `.git`
109
+ * pointer, dubious ownership, timeout, ...)
110
+ * hooks-disabled the active hooks path is `/dev/null` or an existing
111
+ * non-directory — git's documented way to disable hooks
112
+ */
113
+ export function resolveGitHooksDir(repoRoot, { timeoutMs = 5000 } = {}) {
114
+ if (!existsSync(join(repoRoot, '.git'))) return { ok: false, reason: 'not-a-repo' };
115
+
116
+ let env = buildScrubbedEnv(null);
117
+ const run = (args) =>
118
+ execFileSync('git', args, {
119
+ encoding: 'utf-8',
120
+ env,
121
+ cwd: repoRoot,
122
+ timeout: timeoutMs,
123
+ stdio: ['ignore', 'pipe', 'ignore'],
124
+ maxBuffer: 1024 * 1024,
125
+ }).trim();
126
+
127
+ try {
128
+ // Enrich the scrub list from git's own truth when this git supports it.
129
+ try {
130
+ env = buildScrubbedEnv(run(['rev-parse', '--local-env-vars']).split(/\r?\n/).filter(Boolean));
131
+ } catch {
132
+ // Old git without --local-env-vars; the static list already applied.
133
+ }
134
+
135
+ const gitDir = canonicalize(run(['rev-parse', '--absolute-git-dir']));
136
+ const rawCommon = run(['rev-parse', '--git-common-dir']);
137
+ // A relative --git-common-dir is relative to the command's cwd, which we
138
+ // pinned to repoRoot.
139
+ const commonDir = canonicalize(isAbsolute(rawCommon) ? rawCommon : join(repoRoot, rawCommon));
140
+ const topLevel = canonicalize(run(['rev-parse', '--show-toplevel']));
141
+
142
+ // --path-format is git 2.31+. Fall back to the plain form, whose output is
143
+ // relative to cwd (= repoRoot) when core.hooksPath is relative.
144
+ let raw;
145
+ try {
146
+ raw = run(['rev-parse', '--path-format=absolute', '--git-path', 'hooks']);
147
+ } catch {
148
+ raw = run(['rev-parse', '--git-path', 'hooks']);
149
+ }
150
+ if (!raw) return { ok: false, reason: 'probe-failed', detail: 'empty hooks path' };
151
+
152
+ const hooksPath = canonicalize(isAbsolute(raw) ? raw : join(repoRoot, raw));
153
+
154
+ // git documents core.hooksPath=/dev/null as "disable all hooks". Treat any
155
+ // existing non-directory the same way rather than failing on mkdir later.
156
+ if (existsSync(hooksPath) && !statSync(hooksPath).isDirectory()) {
157
+ return { ok: false, reason: 'hooks-disabled', path: hooksPath };
158
+ }
159
+
160
+ // Repository-owned means: inside this repo's git directory (the normal
161
+ // `.git/hooks`, and in a linked worktree the shared common dir) or inside
162
+ // the working tree itself (the `core.hooksPath=.githooks` convention).
163
+ // Anything else is a location we do not own and must not write into.
164
+ const owned =
165
+ isInside(hooksPath, commonDir) ||
166
+ isInside(hooksPath, gitDir) ||
167
+ isInside(hooksPath, topLevel);
168
+
169
+ return { ok: true, path: hooksPath, owned, gitDir, commonDir };
170
+ } catch (e) {
171
+ if (e && e.code === 'ENOENT') return { ok: false, reason: 'git-unavailable' };
172
+ return { ok: false, reason: 'probe-failed', detail: e && (e.code || e.message) };
173
+ }
174
+ }
175
+
176
+ /**
177
+ * Guard the final hook ENTRY, not just the directory it lives in.
178
+ *
179
+ * An owned hooks directory can still contain a symlink pointing anywhere, and
180
+ * `writeFileSync` follows symlinks. Three ways that escapes the boundary the
181
+ * directory check appears to establish:
182
+ * - a live symlink to an external file gets its TARGET overwritten;
183
+ * - if that target happens to carry our managed marker, it is rewritten even
184
+ * without --force-commands;
185
+ * - a DANGLING symlink reads as absent through `existsSync`, so the "not
186
+ * installed yet" path creates the external target outright.
187
+ * So refuse to write through any symlink, and refuse anything that is not a
188
+ * regular file. Callers log the reason and move on.
189
+ *
190
+ * @returns {null | string} null when writing is safe, else a reason to log
191
+ */
192
+ export function unsafeHookTargetReason(hookPath) {
193
+ let st;
194
+ try {
195
+ st = lstatSync(hookPath);
196
+ } catch (e) {
197
+ if (e && e.code === 'ENOENT') return null; // genuinely absent — safe to create
198
+ return `cannot stat (${e.code || e.message})`;
199
+ }
200
+ if (st.isSymbolicLink()) return 'is a symlink — refusing to write through it';
201
+ if (!st.isFile()) return 'exists but is not a regular file';
202
+ return null;
203
+ }
204
+
205
+ /**
206
+ * Write-side wrapper: the hooks directory only if it is safe to install into.
207
+ * Returns `{dir}` when installing is allowed, otherwise `{skip}` carrying a
208
+ * human-readable reason for the caller to log.
209
+ */
210
+ export function hooksDirForInstall(repoRoot, opts) {
211
+ const r = resolveGitHooksDir(repoRoot, opts);
212
+ if (!r.ok) {
213
+ if (r.reason === 'not-a-repo') return { skip: null }; // silent, as before
214
+ if (r.reason === 'hooks-disabled') {
215
+ // Do not name core.hooksPath here: the same branch fires for a plain
216
+ // .git/hooks that happens to be a regular file, where no such setting
217
+ // exists and naming it would send the user hunting for a phantom config.
218
+ return { skip: `hooks path is not a directory, so git runs no hooks (${r.path})` };
219
+ }
220
+ if (r.reason === 'git-unavailable') return { skip: 'git not available on PATH' };
221
+ return { skip: `could not resolve hooks dir (${r.detail || r.reason})` };
222
+ }
223
+ if (!r.owned) {
224
+ return {
225
+ skip: `core.hooksPath points outside this repository (${r.path}) — refusing to install into a shared hooks directory`,
226
+ };
227
+ }
228
+ return { dir: r.path };
229
+ }
@@ -0,0 +1,166 @@
1
+ /**
2
+ * `.hypo-provenance.json` sidecar: copy-time producer proof for the
3
+ * standalone (manual/npm) hooks channel.
4
+ *
5
+ * hooks/hypo-shared.mjs's resolvePkgRoot() falls back to this file, verified
6
+ * against its own package name + a SHA-256 of the running hypo-shared.mjs,
7
+ * whenever self-location can't confirm a package root — the standalone hooks
8
+ * copy has no package.json alongside it to walk up to, by design (hooks
9
+ * import only Node built-ins, nothing outside the hooks dir).
10
+ *
11
+ * This is accidental-staleness protection, not a security boundary: any
12
+ * process running as this OS user can edit this JSON file freely, same as it
13
+ * could edit hooks/hypo-shared.mjs itself. It only guards against the
14
+ * accidental drift init/upgrade left unguarded before this fix — a hook
15
+ * file skip (already-present) that still let hypo-pkg.json's cached pointer
16
+ * move on to a newer version, silently mismatching the code actually copied.
17
+ *
18
+ * The filename and the producer-name check ("hypomnema") here must stay
19
+ * byte-identical with hooks/hypo-shared.mjs's own copy of this contract —
20
+ * hooks can't import scripts/, so the two sides are duplicated, not shared.
21
+ */
22
+
23
+ import { existsSync, readFileSync, writeFileSync, unlinkSync, renameSync } from 'fs';
24
+ import { join } from 'path';
25
+ import { sha256 } from './pkg-json.mjs';
26
+ import { readCoreHooksConfig, deriveCoreHookBasenames } from './core-hooks.mjs';
27
+
28
+ export const PROVENANCE_FILENAME = '.hypo-provenance.json';
29
+ export const EXPECTED_PKG_NAME = 'hypomnema';
30
+
31
+ // Field name for the aggregate hook-set hash (BLOCKER A). Exported so
32
+ // scripts/doctor.mjs reads/writes the same key instead of duplicating the
33
+ // string literal — doctor already imports this module for
34
+ // PROVENANCE_FILENAME/EXPECTED_PKG_NAME, so importing one more constant costs
35
+ // nothing and closes the duplication CONCERN 1 flagged for the filename/name
36
+ // pair (those two stay duplicated only because hooks/hypo-shared.mjs can't
37
+ // import this file at all).
38
+ export const HOOKS_DIGEST_FIELD = 'hooksDigest';
39
+
40
+ /**
41
+ * Deterministic aggregate hash over every hook file hooks/hooks.json wires up
42
+ * (every event handler's file plus every `shared` entry) — the exact set
43
+ * installHooks/applyHookFiles copy, derived from hooks.json itself rather
44
+ * than hand-listed here so this can never drift from what actually gets
45
+ * copied. `dir` is read for CONTENT: pass hooksSrcDir at write time (the
46
+ * package's own hooks/, immediately after files were copied out of it — see
47
+ * writeProvenanceSidecar) and the installed hooksDir at doctor-check time
48
+ * (mirrors how hypoSharedSha256 below is written from source but verified
49
+ * against the installed copy).
50
+ *
51
+ * Deliberately NOT what the runtime resolver reads: hooks/hypo-shared.mjs's
52
+ * readVerifiedProvenancePkgRoot() only ever compares hypoSharedSha256, one
53
+ * file, because hook modules load on every single hook event and hashing an
54
+ * entire directory on every load would tax the hot path for a check only
55
+ * `hypomnema doctor` needs to run once per invocation. This digest is that
56
+ * doctor-only, directory-wide check: it catches a hooks/ dir where some OTHER
57
+ * file (e.g. hypo-personal-check.mjs) went stale while hypo-shared.mjs itself
58
+ * stayed byte-identical, which the single-file SHA the runtime trusts cannot
59
+ * see at all.
60
+ *
61
+ * Sorted basenames, `<file>\n<sha256-of-file-hex>\n` per file concatenated,
62
+ * then SHA-256 of that string — order and content only, never mtime/size.
63
+ * Returns null if hooks.json can't be read/parsed or any listed file can't be
64
+ * read, so a caller can treat "couldn't compute" the same as "nothing to
65
+ * compare" rather than writing/trusting a bogus digest.
66
+ */
67
+ export function computeHooksDigest(pkgRoot, dir) {
68
+ const cfgRes = readCoreHooksConfig(pkgRoot);
69
+ if (!cfgRes.ok) return null;
70
+ const files = [...deriveCoreHookBasenames(cfgRes.cfg)].sort();
71
+ try {
72
+ let acc = '';
73
+ for (const file of files) {
74
+ acc += `${file}\n${sha256(readFileSync(join(dir, file)))}\n`;
75
+ }
76
+ return sha256(acc);
77
+ } catch {
78
+ return null;
79
+ }
80
+ }
81
+
82
+ export function provenancePath(hooksDir) {
83
+ return join(hooksDir, PROVENANCE_FILENAME);
84
+ }
85
+
86
+ /**
87
+ * Write/refresh the sidecar next to a standalone hooks copy at `hooksDir`.
88
+ * `hooksSrcDir` is the package's own hooks/ (the source hypo-shared.mjs was
89
+ * just copied FROM) — its content is what gets hashed, since that hash is
90
+ * what the copy left at `hooksDir` will actually match.
91
+ *
92
+ * Callers must invoke this every run that touches — or even just re-verifies
93
+ * — the hook file set, EVEN when every individual .mjs file was skipped as
94
+ * already-present. A skipped hook copy that still lets hypo-pkg.json's
95
+ * pkgVersion move on to a newer release is exactly the bug this sidecar
96
+ * exists to catch; refreshing it only when files actually changed would
97
+ * reintroduce that gap under a new name.
98
+ *
99
+ * Returns the sidecar path written, or null if the source couldn't be
100
+ * hashed (leaves any pre-existing sidecar untouched rather than write a
101
+ * broken one).
102
+ *
103
+ * `hooksDigest` (BLOCKER A) is best-effort: computed from the same
104
+ * `hooksSrcDir`, over the file set hooks.json actually wires up. Unlike
105
+ * hypoSharedSha256 above, a failure to compute it does not abort the write —
106
+ * it is a doctor-only freshness signal, not part of the runtime contract
107
+ * (hooks/hypo-shared.mjs never reads this field), so a sidecar the runtime
108
+ * can verify should not be withheld just because the digest step failed.
109
+ */
110
+ export function writeProvenanceSidecar(hooksDir, pkgRoot, pkgVersion, hooksSrcDir, dryRun) {
111
+ let hypoSharedSha256;
112
+ try {
113
+ hypoSharedSha256 = sha256(readFileSync(join(hooksSrcDir, 'hypo-shared.mjs')));
114
+ } catch {
115
+ return null;
116
+ }
117
+ const hooksDigest = computeHooksDigest(pkgRoot, hooksSrcDir);
118
+ const dest = provenancePath(hooksDir);
119
+ const data = {
120
+ pkgRoot,
121
+ pkgVersion,
122
+ hypoSharedSha256,
123
+ ...(hooksDigest ? { [HOOKS_DIGEST_FIELD]: hooksDigest } : {}),
124
+ copiedAt: new Date().toISOString(),
125
+ };
126
+ if (!dryRun) atomicWriteJson(dest, data);
127
+ return dest;
128
+ }
129
+
130
+ // Tmp file in the SAME directory as `dest`, then rename over it (CONCERN 2):
131
+ // a reader (readVerifiedProvenancePkgRoot in hooks/hypo-shared.mjs) that
132
+ // races a plain truncate-then-write can observe a half-written file and
133
+ // JSON.parse throws, degrading PKG_ROOT to null for that hook invocation.
134
+ // Same directory is required for the rename to be atomic — crossing a
135
+ // filesystem boundary would fall back to a non-atomic copy+delete.
136
+ function atomicWriteJson(dest, data) {
137
+ const tmp = `${dest}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
138
+ writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n');
139
+ try {
140
+ renameSync(tmp, dest);
141
+ } catch (err) {
142
+ try {
143
+ unlinkSync(tmp);
144
+ } catch {
145
+ /* best-effort cleanup */
146
+ }
147
+ throw err;
148
+ }
149
+ }
150
+
151
+ /** Remove the sidecar at `hooksDir`, if present. Returns the path removed, or null. */
152
+ export function removeProvenanceSidecar(hooksDir, apply) {
153
+ const dest = provenancePath(hooksDir);
154
+ if (!existsSync(dest)) return null;
155
+ if (apply) unlinkSync(dest);
156
+ return dest;
157
+ }
158
+
159
+ /** Non-mutating read. Returns the parsed sidecar, or null on absence/corruption. */
160
+ export function readProvenanceSidecar(hooksDir) {
161
+ try {
162
+ return JSON.parse(readFileSync(provenancePath(hooksDir), 'utf-8'));
163
+ } catch {
164
+ return null;
165
+ }
166
+ }
@@ -28,7 +28,11 @@ import { resolveHypoRoot, expandHome } from './hypo-root.mjs';
28
28
 
29
29
  const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
30
30
  const PKG_ROOT = join(SCRIPT_DIR, '..', '..');
31
- const TEMPLATE_DIR = join(PKG_ROOT, 'templates', 'projects', '_template');
31
+ // Exported so other project-scaffolding call sites (crystallize.mjs's
32
+ // session-close apply, which fills a MISSING index.md on a project that
33
+ // bypassed createProject entirely) resolve the same template dir instead of
34
+ // re-deriving the path.
35
+ export const TEMPLATE_DIR = join(PKG_ROOT, 'templates', 'projects', '_template');
32
36
 
33
37
  const TEMPLATE_FILES = ['index.md', 'prd.md', 'hot.md', 'session-state.md'];
34
38
 
@@ -0,0 +1,39 @@
1
+ // scripts/lib/rename-marker.mjs
2
+ //
3
+ // The crash-recovery marker for rename.mjs --apply. Shared read-only path/parse
4
+ // logic between the writer (rename.mjs) and the reader (doctor.mjs) so the two
5
+ // never drift on where the marker lives or what shape it is in.
6
+ //
7
+ // Kept under .cache/ deliberately, matching every other runtime marker this repo
8
+ // already writes there (sync-state.json, session-closed-*.marker, proposals/,
9
+ // project-suggestions.json). Two things fall out of that placement for free:
10
+ // - it is JSON, not .md, so every vault walker (scripts/lib/wikilink.mjs) that
11
+ // only ever collects .md files simply never sees it — no lint/rename/graph
12
+ // scan needs to know this file exists.
13
+ // - .cache/ ships in templates/.hypoignore's default pattern list, so a scan
14
+ // that DOES walk directories rather than just filtering by extension
15
+ // (doctor's own checkBrokenLinks walker) is also covered on any vault that
16
+ // carries that baseline — which every vault already needs, since .cache/
17
+ // holds the pre-commit secret-gate's own state.
18
+ // No .hyposcanignore entry or scan-logic change was needed to get this property;
19
+ // it comes from where the file lives, not from a new exclusion rule.
20
+ import { existsSync, readFileSync } from 'fs';
21
+ import { join } from 'path';
22
+
23
+ export const RENAME_MARKER_REL = '.cache/rename-in-progress.json';
24
+
25
+ export function renameMarkerPath(hypoDir) {
26
+ return join(hypoDir, RENAME_MARKER_REL);
27
+ }
28
+
29
+ // Best-effort read: a missing or corrupt marker both mean "nothing to report" —
30
+ // this runs inside doctor's health scan and must never throw.
31
+ export function readRenameMarker(hypoDir) {
32
+ const path = renameMarkerPath(hypoDir);
33
+ if (!existsSync(path)) return null;
34
+ try {
35
+ return JSON.parse(readFileSync(path, 'utf-8'));
36
+ } catch {
37
+ return null;
38
+ }
39
+ }