hypomnema 1.8.2 → 1.8.4

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.
@@ -1953,7 +1953,21 @@ function atomicWriteShared(path, content) {
1953
1953
  mkdirSync(dirname(path), { recursive: true });
1954
1954
  const tmp = `${path}.${process.pid}.${Math.random().toString(36).slice(2, 10)}.tmp`;
1955
1955
  writeFileSync(tmp, content);
1956
- renameSync(tmp, path);
1956
+ try {
1957
+ renameSync(tmp, path);
1958
+ } catch (err) {
1959
+ // The rename is what makes this atomic, so a failure here leaves the
1960
+ // target untouched, which is the point. What it also leaves is the tmp
1961
+ // file, and nothing else ever looks at that name again: the suffix
1962
+ // carries this pid and a fresh random, so the next run picks a
1963
+ // different one and this one sits in the vault forever, close after
1964
+ // close. Take it back out before rethrowing, and do not let the
1965
+ // cleanup hide the real error.
1966
+ try {
1967
+ rmSync(tmp, { force: true });
1968
+ } catch {}
1969
+ throw err;
1970
+ }
1957
1971
  }
1958
1972
 
1959
1973
  // Read the pid the current holder recorded in its lockfile (see withFileLock).
@@ -3391,7 +3405,7 @@ export function normalizeVerifiedScope(verifiedScope) {
3391
3405
  * @param {{project?: string, scope?: string, transcript_path?: string, verifiedScope?: {kind: 'log-only'|'project'|'global', projects?: string[]}}} info
3392
3406
  */
3393
3407
  export function writeSessionClosedMarker(hypoDir, sessionId, info = {}) {
3394
- if (!sessionId) return;
3408
+ if (!sessionId) return false;
3395
3409
  try {
3396
3410
  const cacheDir = join(hypoDir, '.cache');
3397
3411
  if (!existsSync(cacheDir)) mkdirSync(cacheDir, { recursive: true });
@@ -3427,9 +3441,24 @@ export function writeSessionClosedMarker(hypoDir, sessionId, info = {}) {
3427
3441
  // doctor's reader treats "field absent" as "no additional scope check".
3428
3442
  ...(verifiedScope ? { verified_scope: verifiedScope } : {}),
3429
3443
  };
3430
- writeFileSync(sessionClosedMarkerPath(hypoDir, sessionId), JSON.stringify(payload) + '\n');
3444
+ // Atomic, and it reports. Two reasons, and the caller needs both.
3445
+ //
3446
+ // A plain writeFileSync can leave a truncated file behind, and the reader
3447
+ // below drops a marker it cannot parse. That combination turns one failed
3448
+ // write into a session that can never close: the caller sees a file, calls
3449
+ // the close signal spent, and the next Stop deletes the unparseable marker.
3450
+ // temp+rename means the target is either the old bytes or the whole new
3451
+ // ones, never half.
3452
+ //
3453
+ // And existsSync cannot answer the question the caller is really asking.
3454
+ // "A marker file is there" is not "this run put it there" — a corrupt
3455
+ // marker from an earlier attempt satisfies it just as well. So say whether
3456
+ // THIS write landed and let the caller key on that.
3457
+ atomicWriteShared(sessionClosedMarkerPath(hypoDir, sessionId), JSON.stringify(payload) + '\n');
3458
+ return true;
3431
3459
  } catch (err) {
3432
3460
  process.stderr.write(`[hypo] session-closed marker write failed: ${err?.message || err}\n`);
3461
+ return false;
3433
3462
  }
3434
3463
  }
3435
3464
 
@@ -4024,21 +4053,25 @@ export function precompactGateStatus(hypoDir, opts = {}) {
4024
4053
  }
4025
4054
  }
4026
4055
 
4027
- // 1. wiki git state. Uncommitted changes (real unsaved work) BLOCK, but only
4028
- // the ones inside closeAccountableScope, and only when sessionTouchTrusted
4029
- // (above) says that scope can actually be trusted: a session's own scoped
4030
- // auto-commit (commitWikiChanges, PR #222) can leave the working tree
4031
- // non-empty when a DIFFERENT session sharing this vault still has its own
4032
- // file dirty, the 2026-08-03 multi-session block. That dirty file is
4033
- // human-fixable by whoever owns it, not by this session, so it demotes to
4034
- // a notice (listed by path, never silently dropped) instead of refusing
4035
- // this session's marker. A dirty file THIS session owns still blocks
4036
- // unconditionally (fail-closed is unchanged for scope this session
4037
- // actually touched), and so does an unattributable state: a git failure
4038
- // gitDirtyFiles can't enumerate (dirty.length === 0 despite
4039
- // git.uncommitted === true), OR a scope we cannot trust
4040
- // (!sessionTouchTrusted) both fall back to the original unscoped blocker:
4041
- // "cannot attribute" is not "clean".
4056
+ // 1. wiki git state. Uncommitted changes (real unsaved work) BLOCK, but a
4057
+ // file this session cannot be held to still demotes to a notice: a
4058
+ // session's own scoped auto-commit (commitWikiChanges, PR #222) can
4059
+ // leave the working tree non-empty when a DIFFERENT session sharing
4060
+ // this vault still has its own file dirty, the 2026-08-03 multi-session
4061
+ // block. That dirty file is human-fixable by whoever owns it, not by
4062
+ // this session, so it demotes to a notice (listed by path, never
4063
+ // silently dropped) instead of refusing this session's marker. Which
4064
+ // set decides "not mine" depends on what the caller told us: a
4065
+ // caller-provided project (--project, or the cwd-derived
4066
+ // attributionScope) demotes by PATH STRUCTURE alone
4067
+ // (isForeignProjectFile below), independent of transcript trust; with
4068
+ // no such scope, only a trusted transcript's closeAccountableScope can
4069
+ // tell mine from foreign, so an untrusted,
4070
+ // unscoped session falls back to the original unscoped blocker:
4071
+ // "cannot attribute" is not "clean". A dirty file THIS session owns
4072
+ // still blocks unconditionally either way, and so does the
4073
+ // enumeration-failed case below (dirty.length === 0 despite
4074
+ // git.uncommitted === true).
4042
4075
  // Unpushed commits (ahead) DEMOTE to a notice regardless of scope: push is
4043
4076
  // automatic (auto-commit Stop hook) and its failures are already non-fatal, so
4044
4077
  // "ahead" is a transient sync state, not a human-fixable blocker. Demoting it
@@ -4053,17 +4086,26 @@ export function precompactGateStatus(hypoDir, opts = {}) {
4053
4086
  // porcelain status): "cannot attribute" is not "clean", block exactly
4054
4087
  // as before, override or not.
4055
4088
  blockers.push({ type: 'git', reason: git.reason });
4056
- } else if (!sessionTouchTrusted && (opts.projectOverride || opts.attributionScope)) {
4057
- // session-close-scope-boundary spec §2b: a broken/missing transcript
4058
- // normally means "cannot attribute, fail closed" (the branch below).
4059
- // But when the CALLER told us which project is ours (an explicit
4060
- // --project, or the cwd-derived attributionScope), a dirty file that
4061
- // lives structurally under a DIFFERENT eligible project's own directory
4062
- // needs no attribution inference at all: the path alone proves it is
4063
- // not this session's file. Everything else (this session's own
4064
- // project, pages/, an unregistered or _template project dir) keeps the
4065
- // pre-existing fail-closed behavior; only a provably-foreign path is
4066
- // demoted.
4089
+ } else if (opts.projectOverride || opts.attributionScope) {
4090
+ // session-close-scope-boundary spec §2b, revised 2026-09-11: a
4091
+ // caller-provided scope (an explicit --project, or the cwd-derived
4092
+ // attributionScope) proves which project is ours PATH-STRUCTURALLY,
4093
+ // regardless of transcript trust. This used to run only when
4094
+ // !sessionTouchTrusted, on the theory that a trusted transcript could
4095
+ // fall back to closeAccountableScope instead. But closeAccountableScope
4096
+ // is `closeFileTargetsGlobal` whenever opts.projectOverride is unset
4097
+ // (every caller here passes attributionScope, never projectOverride;
4098
+ // see crystallize-close-apply.mjs), which unions in every OTHER
4099
+ // today-active project's own mandatory close files too. That let a
4100
+ // different session's still-dirty close files (session-state.md,
4101
+ // project hot.md, today's session-log shard) block THIS session's
4102
+ // marker even with --project set. A dirty file that lives structurally
4103
+ // under a DIFFERENT eligible project's own
4104
+ // directory needs no attribution inference at all: the path alone
4105
+ // proves it is not this session's file. Everything else (this
4106
+ // session's own project, pages/, an unregistered or _template project
4107
+ // dir) keeps the pre-existing fail-closed behavior; only a
4108
+ // provably-foreign path is demoted.
4067
4109
  const effectiveOverride = opts.projectOverride || opts.attributionScope;
4068
4110
  // Computed once per gate call, never per file (collectProjectWorkingDirs
4069
4111
  // walks the projects/ dir and reads every index.md). A throw here (a
@@ -4085,6 +4127,20 @@ export function precompactGateStatus(hypoDir, opts = {}) {
4085
4127
  const isForeign = (f) =>
4086
4128
  isForeignProjectFile(f, { eligibleSlugs, effectiveOverride, transcriptTouched });
4087
4129
  const foreign = dirty.filter(isForeign);
4130
+ // A dirty file inside the scoped project's OWN directory blocks, even one
4131
+ // this close does not write. An earlier revision demoted those to notices
4132
+ // to escape a deadlock: a close whose commit fails leaves
4133
+ // `projects/<p>/index.md` (seeded by ensureProjectIndex) uncommitted, the
4134
+ // retry skips every payload field as already-current without re-staging
4135
+ // it, and the marker can then never land again no matter how often the
4136
+ // user retries.
4137
+ //
4138
+ // That demotion was far wider than the deadlock it answered. It waved
4139
+ // through every unsaved file in the project, which is exactly the work a
4140
+ // close is supposed to refuse to walk away from. The deadlock is fixed at
4141
+ // its source instead: applyOverwrites re-stages index.md on the retry
4142
+ // path (crystallize-close-apply.mjs), so the file this branch used to
4143
+ // demote is now in the close's own commit scope and never reaches here.
4088
4144
  const rest = dirty.filter((f) => !isForeign(f));
4089
4145
  if (rest.length > 0) {
4090
4146
  blockers.push({
@@ -5201,14 +5257,25 @@ export function walkCloseGate(transcriptPath) {
5201
5257
  // `attachment` of type queued_command with the prompt verbatim). This opens
5202
5258
  // the gate ONLY with an audited human producer — origin.kind "human", present
5203
5259
  // on every 2.1.181+ user delivery (measured). A legacy origin-absent delivery
5204
- // cannot attest a producer, so it does not open (fail-closed). A NON-close
5205
- // queued command (e.g. "keep working") is a fresh user intent and CLOSES
5206
- // a prior open regardless of origin — that is what closes the re-close hole
5207
- // where a queued "continue" after a close leaves the stale open live. A
5208
- // task notification is not that: `modelCaused` below filters it out before
5209
- // this reaches the change-of-mind close, because the model, not the user,
5210
- // produced it. This delivery path used to skip that filter and let an
5211
- // unrelated background-task notification flip a just-opened gate shut.
5260
+ // cannot attest a producer, so it does not open (fail-closed). Closing asks
5261
+ // for the same proof: a NON-close queued command (e.g. "keep working") is a
5262
+ // fresh user intent and retracts a prior open, but only with that same
5263
+ // audited human producer. This branch used to close by default instead,
5264
+ // on the theory that anything the model-caused list did not recognise had
5265
+ // to be the user. That default is what let an unrelated background-task
5266
+ // notification flip a just-opened gate shut, and every new machine-minted
5267
+ // event would have needed its own line in that list to stay safe. Requiring
5268
+ // a positive producer means an event this filter has never seen is neutral
5269
+ // rather than hostile. `modelCaused` below still runs first, so a
5270
+ // recognised notification never reaches either decision.
5271
+ //
5272
+ // The enqueue branch above does NOT share this rule: those records carry no
5273
+ // `origin` at all, so requiring one would stop them closing anything. It
5274
+ // keeps the old enumerate-the-machine default, and the re-close hole (a
5275
+ // queued "continue" after a close leaving the stale open live) is closed
5276
+ // there rather than here. The two delivery shapes of one user action are
5277
+ // therefore judged differently on purpose; see the residual noted on the
5278
+ // queue-operation branch.
5212
5279
  //
5213
5280
  // Scope of "both shapes classify alike": it holds for task notifications and
5214
5281
  // for empty content, which is what this fix is about. It does not hold for
@@ -5277,9 +5344,20 @@ export function walkCloseGate(transcriptPath) {
5277
5344
  open = true;
5278
5345
  openedAtIndex = i;
5279
5346
  }
5280
- } else {
5281
- open = false;
5347
+ } else if (humanOrigin) {
5348
+ open = false; // an audited human change of mind → close
5282
5349
  }
5350
+ // else: not caught by the known-machine shapes above, not a close
5351
+ // phrase, and no audited human producer either — a host event this
5352
+ // filter does not yet recognize. The old rule closed here by default
5353
+ // (anything not on the model-caused list must be the user), which is
5354
+ // exactly the shape of bug #289 fixed for <task-notification>: a new
5355
+ // kind of unattributed host event would silently retract a close the
5356
+ // user already granted, and every future one would need its own line
5357
+ // in isModelCausedQueueContent to avoid repeating that. Requiring a
5358
+ // POSITIVE human producer to close, instead of enumerating every way to
5359
+ // recognize a machine one, means an event this filter has never seen
5360
+ // still cannot close the gate on its own.
5283
5361
  continue;
5284
5362
  }
5285
5363
 
@@ -228,13 +228,37 @@ export function deleteProposal(hypoDir, id) {
228
228
  * @param {string} fields.sessionId owning session
229
229
  * @param {string} fields.device machine identifier (crystallize passes currentDevice())
230
230
  * @param {string} [fields.createdAt] ISO timestamp; defaults to now
231
+ * @param {string} [fields.parkReason] human-readable cause of the park (crystallize's
232
+ * conflictWhy(c)) — this is the artifact's own copy of the same string a `--json`
233
+ * close now also puts in that close's conflicts[].why, kept here because the T7
234
+ * CLI's list/apply/discard/challenge/resolve actions read only the artifact and
235
+ * never that close's own stdout. Additive: absent on artifacts written before this
236
+ * field existed, and every reader (this module's own readArtifactFile, and the T7
237
+ * CLI's actions above) treats an absent value as "no cause recorded", never as
238
+ * malformed — none of them requires this key to be present.
239
+ * @param {string[]} [fields.lostSections] the `##` headings a section-loss park
240
+ * dropped (crystallize-close-apply.mjs's sectionLossReason). Present only when
241
+ * `parkReason` names that cause; omitted for every other park reason, same as
242
+ * `diskSectionCount` below.
243
+ * @param {number} [fields.diskSectionCount] how many `##` headings disk had at
244
+ * park time, alongside `lostSections`.
231
245
  * @returns {{id: string, target: string, path: string, supersedeWarnings: string[]}}
232
246
  * `supersedeWarnings` is non-empty only when the new artifact WAS written but an
233
247
  * older same-target sibling could not be removed — a non-fatal condition (the
234
248
  * payload is parked), reported separately from a write failure (which throws).
235
249
  */
236
250
  export function writeProposal(hypoDir, fields) {
237
- const { target, baseHash, currentAtProposalHash, proposedContent, sessionId, device } = fields;
251
+ const {
252
+ target,
253
+ baseHash,
254
+ currentAtProposalHash,
255
+ proposedContent,
256
+ sessionId,
257
+ device,
258
+ parkReason,
259
+ lostSections,
260
+ diskSectionCount,
261
+ } = fields;
238
262
  const createdAt = fields.createdAt || new Date().toISOString();
239
263
 
240
264
  // Match on the parsed `target` field, never on the filename slug: two distinct
@@ -266,6 +290,16 @@ export function writeProposal(hypoDir, fields) {
266
290
  sessionId: sessionId != null ? String(sessionId) : null,
267
291
  device: device != null ? String(device) : null,
268
292
  createdAt,
293
+ // Additive fields (see this function's own doc comment): omitted rather
294
+ // than stored as `null` when the caller does not pass them, so an artifact
295
+ // written before this field existed and one written by a caller that
296
+ // genuinely has no cause to report look identical on disk — both simply
297
+ // lack the key, and every reader already treats an absent key as "not
298
+ // recorded", never as a malformed body (readArtifactFile only requires
299
+ // `id` and `target`; nothing here reads these three as mandatory).
300
+ ...(parkReason != null ? { parkReason: String(parkReason) } : {}),
301
+ ...(Array.isArray(lostSections) ? { lostSections } : {}),
302
+ ...(typeof diskSectionCount === 'number' ? { diskSectionCount } : {}),
269
303
  };
270
304
  // Write the new artifact DURABLY before removing the old one: a crash in the
271
305
  // gap leaves a stale sibling (superseded next close), never a lost payload.
@@ -0,0 +1,9 @@
1
+ [
2
+ "hypo-shared.mjs",
3
+ "version-check.mjs",
4
+ "version-check-fetch.mjs",
5
+ "base-store.mjs",
6
+ "proposal-store.mjs",
7
+ "close-gate-store.mjs",
8
+ "close-journal.mjs"
9
+ ]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hypomnema",
3
- "version": "1.8.2",
3
+ "version": "1.8.4",
4
4
  "description": "LLM-native personal wiki system for Claude Code",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -39,6 +39,7 @@
39
39
  "scripts/lib/feedback-scope.mjs",
40
40
  "scripts/lib/frontmatter.mjs",
41
41
  "scripts/lib/git-hooks-dir.mjs",
42
+ "scripts/lib/hook-inventory.mjs",
42
43
  "scripts/lib/hypo-ignore.mjs",
43
44
  "scripts/lib/hypo-root.mjs",
44
45
  "scripts/lib/page-usage.mjs",
@@ -18,6 +18,7 @@ import { homedir } from 'os';
18
18
  import { spawnSync } from 'child_process';
19
19
  import { fileURLToPath } from 'url';
20
20
  import { resolveHypoRoot, expandHome } from './lib/hypo-root.mjs';
21
+ import { loadHookInventory } from './lib/hook-inventory.mjs';
21
22
  import { loadHypoIgnore, isScanIgnored } from './lib/hypo-ignore.mjs';
22
23
  import { readRenameMarker, renameMarkerPath, RENAME_MARKER_REL } from './lib/rename-marker.mjs';
23
24
  import {
@@ -154,94 +155,22 @@ function fail(label, detail = '') {
154
155
  }
155
156
 
156
157
  // ── hook map (loaded from hooks/hooks.json — single source of truth) ─────────
157
-
158
- let _hookConfig;
159
- try {
160
- _hookConfig = JSON.parse(readFileSync(join(PKG_ROOT, 'hooks', 'hooks.json'), 'utf-8'));
161
- } catch {
162
- console.error(`Error: cannot read hooks/hooks.json from package root: ${PKG_ROOT}`);
163
- console.error(PKG_INTEGRITY_HINT);
164
- process.exit(1);
165
- }
166
- if (!_hookConfig || typeof _hookConfig !== 'object' || Array.isArray(_hookConfig)) {
167
- console.error('Error: hooks/hooks.json must be a JSON object');
168
- console.error(PKG_INTEGRITY_HINT);
169
- process.exit(1);
170
- }
171
- if (
172
- !_hookConfig.hooks ||
173
- typeof _hookConfig.hooks !== 'object' ||
174
- Array.isArray(_hookConfig.hooks)
175
- ) {
176
- console.error('Error: hooks/hooks.json must contain a "hooks" object');
177
- console.error(PKG_INTEGRITY_HINT);
178
- process.exit(1);
179
- }
180
- function _extractCommandFileName(command) {
181
- if (typeof command !== 'string') return null;
182
- const matches = [...command.matchAll(/(?:^|[\/\\])([^\/\\\s"'`]+\.mjs)(?=$|[\s"'`])/g)];
183
- if (matches.length > 0) return matches[matches.length - 1][1];
184
- const bare = command.match(/(?:^|\s)([^\/\\\s"'`]+\.mjs)(?=$|[\s"'`])/);
185
- return bare ? bare[1] : null;
186
- }
187
-
188
- function _isHookFileName(file) {
189
- return typeof file === 'string' && /^[^/\\\s]+\.mjs$/.test(file.trim());
190
- }
191
-
192
- function _isHookGroup(group) {
193
- return (
194
- group &&
195
- typeof group === 'object' &&
196
- !Array.isArray(group) &&
197
- Array.isArray(group.hooks) &&
198
- group.hooks.length > 0 &&
199
- group.hooks.every(
200
- (hook) =>
201
- hook &&
202
- typeof hook === 'object' &&
203
- !Array.isArray(hook) &&
204
- hook.type === 'command' &&
205
- _extractCommandFileName(hook.command),
206
- )
207
- );
208
- }
209
-
210
- // Extract .mjs file names from both old format (string[]) and new format (hook-group object[])
211
- function _extractFileNames(groups) {
212
- return groups.flatMap((group) => {
213
- if (typeof group === 'string') return [group.trim()];
214
- return group.hooks.map((hook) => _extractCommandFileName(hook.command));
215
- });
216
- }
217
-
218
- for (const [event, groups] of Object.entries(_hookConfig.hooks)) {
219
- const valid =
220
- Array.isArray(groups) &&
221
- groups.length > 0 &&
222
- groups.every((group) => _isHookFileName(group) || _isHookGroup(group)) &&
223
- _extractFileNames(groups).length > 0;
224
- if (!valid) {
225
- console.error(
226
- `Error: hooks/hooks.json "hooks.${event}" must be a non-empty array of .mjs file names or Claude hook groups`,
227
- );
228
- console.error(PKG_INTEGRITY_HINT);
229
- process.exit(1);
230
- }
231
- }
232
- if (
233
- _hookConfig.shared !== undefined &&
234
- (!Array.isArray(_hookConfig.shared) || !_hookConfig.shared.every((f) => _isHookFileName(f)))
235
- ) {
236
- console.error('Error: hooks/hooks.json "shared" must be an array of .mjs file names');
158
+ //
159
+ // loadHookInventory (scripts/lib/hook-inventory.mjs) is the one parser every
160
+ // hooks.json/shared.json consumer reads through — smoke-plugin, init, upgrade,
161
+ // and uninstall all call the same function. It existence-checks every named
162
+ // file against THIS package's own hooks/ before returning ok:true, so a
163
+ // corrupt or incomplete doctor.mjs's OWN package (never the vault being
164
+ // audited — that stays checkHooks()'s job below) fails here rather than
165
+ // auditing an install against a hook list doctor cannot trust.
166
+ const _hookInventory = loadHookInventory(PKG_ROOT);
167
+ if (!_hookInventory.ok) {
168
+ console.error(`Error: ${_hookInventory.error}`);
237
169
  console.error(PKG_INTEGRITY_HINT);
238
170
  process.exit(1);
239
171
  }
240
-
241
- const HOOK_MAP = Object.fromEntries(
242
- Object.entries(_hookConfig.hooks).map(([e, gs]) => [e, _extractFileNames(gs)]),
243
- );
244
- const SHARED_FILES = _hookConfig.shared ?? [];
172
+ const HOOK_MAP = _hookInventory.hookMap;
173
+ const SHARED_FILES = _hookInventory.shared;
245
174
 
246
175
  // ── checks ───────────────────────────────────────────────────────────────────
247
176
 
@@ -543,15 +472,38 @@ function checkGit(hypoDir) {
543
472
  // plugin-cache leaf-drift precedent: reporting must never turn into
544
473
  // filtering, or a still-real, still-resolvable root looks like it vanished).
545
474
  const parsedRoot = parseWikiPreCommitRoot(content);
546
- const wantRoot = resolveDurableHookRoot();
547
- if (parsedRoot.ok && wantRoot !== null && wantRoot !== parsedRoot.root) {
475
+ if (parsedRoot.ok && parsedRoot.root === null) {
476
+ // The runtime-resolving form: the hook looks up its own
477
+ // install root at commit time, so there is no baked value to compare
478
+ // against the active install — it cannot structurally go stale the way
479
+ // the old, version-pinned form could. Report that fact in place of the
480
+ // stale-root comparison below, rather than falling silent.
481
+ pass(`${label} root`, 'Resolved at commit time by the hook itself — never goes stale');
482
+ } else if (!parsedRoot.ok) {
483
+ // The pass above only checked that our START marker is present, not
484
+ // that the body it wraps is one this codebase can actually run. This is
485
+ // the fail-open this branch used to leave: a hand-edited body, a
486
+ // corrupted one, or (before the rewrite-safe root check) an OLD-form
487
+ // hook whose baked-in install root no longer existed all read as a
488
+ // clean "guard installed" pass while the hook fails MODULE_NOT_FOUND on
489
+ // every real commit.
548
490
  warn(
549
491
  `${label} root`,
550
- `Points at an old install root (${parsedRoot.root}) — the active install is ` +
551
- `${wantRoot}. To repoint it, run ${upgradeApplyHint(
552
- pluginMode || hypomnemaPluginEnabled,
553
- )}.`,
492
+ 'Guard marker present but the hook body is not recognized, it may be failing on ' +
493
+ `every commit. Run \`hypomnema init --force-commands\` (or the plugin's ` +
494
+ `\`/hypo:init --force-commands\`) to reinstall it.`,
554
495
  );
496
+ } else {
497
+ const wantRoot = resolveDurableHookRoot();
498
+ if (wantRoot !== null && wantRoot !== parsedRoot.root) {
499
+ warn(
500
+ `${label} root`,
501
+ `Points at an old install root (${parsedRoot.root}) — the active install is ` +
502
+ `${wantRoot}. To repoint it, run ${upgradeApplyHint(
503
+ pluginMode || hypomnemaPluginEnabled,
504
+ )}.`,
505
+ );
506
+ }
555
507
  }
556
508
  } else {
557
509
  warn(label, 'Exists but not managed by Hypomnema — manual git add can bypass .hypoignore');