hypomnema 1.7.0 → 1.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -19,6 +19,8 @@ import {
19
19
  formatGrowthMetrics,
20
20
  readSyncState,
21
21
  clearSyncState,
22
+ recordSyncSuccess,
23
+ classifySyncOp,
22
24
  readClearMarker,
23
25
  clearClearMarker,
24
26
  loadHypoIgnore,
@@ -35,6 +37,7 @@ import {
35
37
  currentDevice,
36
38
  scopeVisible,
37
39
  readVisibilityScope,
40
+ pkgRootDriftStatus,
38
41
  } from './hypo-shared.mjs';
39
42
  import {
40
43
  defaultCachePath,
@@ -48,6 +51,9 @@ import {
48
51
  computeSiblingNotice,
49
52
  siblingAlreadyNotified,
50
53
  markSiblingNotified,
54
+ pkgRootDriftAlreadyNotified,
55
+ markPkgRootDriftNotified,
56
+ clearPkgRootDriftNotified,
51
57
  } from './version-check.mjs';
52
58
  import { snapshotBase, overwriteTargets } from './base-store.mjs';
53
59
  import { listProposals } from './proposal-store.mjs';
@@ -214,6 +220,52 @@ function buildSiblingNotice() {
214
220
  }
215
221
  }
216
222
 
223
+ /**
224
+ * pkgRoot drift notice. hypo-shared.mjs's resolvePkgRoot() already
225
+ * self-corrects PKG_ROOT in memory whenever the code's own resolved location
226
+ * disagrees with the cached hypo-pkg.json — but silent self-correction is the
227
+ * exact failure this closes: the user's own `upgrade` habit stops mattering
228
+ * and nothing ever tells them hypo-pkg.json fell behind. Surfaced once per
229
+ * (cached → self-location) pair via the same notify-once cache the sibling
230
+ * notice above uses — a fresh drift (new self-location) re-notifies, but
231
+ * staying on the same drifted state doesn't nag every session.
232
+ *
233
+ * Tri-state (pkgRootDriftStatus): 'match' CLEARS any earlier mark (checked
234
+ * FIRST, unconditionally — even under opt-out, so a drift that resolves while
235
+ * opted out doesn't leave a stale mark that then suppresses a genuine
236
+ * recurrence once opt-out is lifted); 'unknown' touches nothing (self-location
237
+ * could not be resolved this session — the permanent steady state for the
238
+ * npm/manual channel, not evidence either way); only 'drift' can produce a
239
+ * banner, and opt-out is checked there so an opted-out session never marks a
240
+ * pair as notified it never actually showed.
241
+ */
242
+ function buildPkgRootDriftNotice() {
243
+ try {
244
+ const status = pkgRootDriftStatus();
245
+ const cachePath = defaultCachePath();
246
+ if (status.status === 'match') {
247
+ clearPkgRootDriftNotified(cachePath);
248
+ return '';
249
+ }
250
+ if (status.status === 'unknown') return '';
251
+ if (isOptedOut()) return '';
252
+ const key = `${status.cached || '(none)'}->${status.self}`;
253
+ const cache = readCache(cachePath);
254
+ if (pkgRootDriftAlreadyNotified(cache, key)) return '';
255
+ markPkgRootDriftNotified(cachePath, key);
256
+ return (
257
+ `[Hypomnema] Package metadata drift: hypo-pkg.json still points at ` +
258
+ `\`${status.cached || '(none)'}\`, but the code actually running resolves to ` +
259
+ `\`${status.self}\`.\n` +
260
+ ` Hooks already resolved the correct root for this session — this is a ` +
261
+ `heads-up, not a blocker.\n` +
262
+ ` → run \`/hypo:upgrade --apply\` to bring hypo-pkg.json back in sync.`
263
+ );
264
+ } catch {
265
+ return '';
266
+ }
267
+ }
268
+
217
269
  const PROJECTS_DIR = join(HYPO_DIR, 'projects');
218
270
  const GROWTH_CACHE = join(HYPO_DIR, '.cache', 'last-session-growth.json');
219
271
 
@@ -253,14 +305,22 @@ function buildClearRecoveryLine(source) {
253
305
  );
254
306
  }
255
307
 
256
- /** Pull the wiki repo. Returns true only when the pull actually succeeded. */
308
+ /**
309
+ * Pull the wiki repo. Returns true only when the pull actually succeeded. On
310
+ * success, also records the last-success timestamp (silently — no notice; the
311
+ * existing failure notice below is unchanged) so doctor never reports "never
312
+ * synced" right after a healthy startup pull, even when no auto-commit Stop
313
+ * hook has run yet this session.
314
+ */
257
315
  function gitPull(dir) {
258
316
  if (!existsSync(join(dir, '.git'))) return false;
259
317
  const r = spawnSync('git', ['-C', dir, 'pull', '--ff-only', '--quiet'], {
260
318
  stdio: 'pipe',
261
319
  timeout: 10000,
262
320
  });
263
- return r.status === 0;
321
+ const ok = r.status === 0;
322
+ if (ok) recordSyncSuccess(dir, 'pull');
323
+ return ok;
264
324
  }
265
325
 
266
326
  /**
@@ -274,7 +334,9 @@ function gitPull(dir) {
274
334
  * fresh `hypo init` wiki does not git-ignore `.cache/`, so a broader cleanliness
275
335
  * check would see the sync-state file itself and never clear.
276
336
  *
277
- * @returns {string} a `[WIKI: last sync failed: ...]` line, or '' when clear.
337
+ * @returns {string} a `[WIKI: last sync failed: ...]` (or, for a conflict/
338
+ * conflict-unresolved entry, dedicated manual-merge guidance) line, or ''
339
+ * when clear.
278
340
  */
279
341
  function syncStateNotice(pullOk) {
280
342
  const { entries, parseError } = readSyncState(HYPO_DIR);
@@ -295,13 +357,44 @@ function syncStateNotice(pullOk) {
295
357
  return '';
296
358
  }
297
359
  const last = entries[entries.length - 1];
298
- if (last.op === 'conflict') {
360
+ // classifySyncOp (hypo-shared.mjs) is the single judgment both this hook
361
+ // and doctor.mjs's checkSyncState branch on, so the two surfaces cannot
362
+ // silently diverge on WHICH op gets which treatment: this exact check used
363
+ // to be an exact `=== 'conflict'` comparison here that missed
364
+ // 'conflict-unresolved' — the MORE dangerous op, since the abort itself
365
+ // failed and the tree may still be half-merged — while doctor already
366
+ // caught it via startsWith('conflict').
367
+ const cls = classifySyncOp(last.op);
368
+ if (cls === 'conflict-unresolved') {
369
+ return (
370
+ `[WIKI: remote diverged AND the automatic merge-abort failed — the working ` +
371
+ `tree may still be half-merged (unmerged paths or an in-progress merge). ` +
372
+ `Do NOT commit or push yet. Inspect \`git -C ${HYPO_DIR} status\` first: if a ` +
373
+ `merge is in progress, resolve the conflicts, then \`git -C ${HYPO_DIR} add <resolved paths>\` ` +
374
+ `and \`git -C ${HYPO_DIR} commit\` (git refuses a commit while unmerged entries remain staged) ` +
375
+ `— or run \`git -C ${HYPO_DIR} merge --abort\` to discard it instead, before continuing.]`
376
+ );
377
+ }
378
+ if (cls === 'conflict') {
299
379
  return (
300
380
  `[WIKI: remote diverged — auto-merge was aborted to protect your edits ` +
301
381
  `(your local work is committed and safe; the other machine's version is on the remote). ` +
302
382
  `Resolve manually: \`git -C ${HYPO_DIR} pull --no-rebase\`, fix conflicts, then push.]`
303
383
  );
304
384
  }
385
+ // An unrecognized `conflict*` op — some future syncRemote failure mode this
386
+ // hook has no dedicated branch for. Neither the clean-conflict claim above
387
+ // ("committed and safe") nor the conflict-unresolved claim ("the abort
388
+ // failed") is known to be true here, so assert neither: say plainly that
389
+ // the state is unknown and treat it as unresolved until a human checks.
390
+ if (cls === 'unknown-conflict') {
391
+ return (
392
+ `[WIKI: remote diverged — an unrecognized conflict-related sync failure was recorded ` +
393
+ `(op='${last.op}'). Its resolution state cannot be confirmed automatically, so treat it ` +
394
+ `as unresolved: do NOT commit or push yet. Inspect \`git -C ${HYPO_DIR} status\` first for ` +
395
+ `unmerged paths or an in-progress merge before continuing.]`
396
+ );
397
+ }
305
398
  return `[WIKI: last sync failed: ${last.op || '?'} — ${last.error || 'unknown'}]`;
306
399
  }
307
400
  /**
@@ -406,15 +499,17 @@ process.stdin.on('end', () => {
406
499
  const clearRecoveryLine = buildClearRecoveryLine(data.source);
407
500
  const updateLine = buildUpdateNotice();
408
501
  const siblingLine = buildSiblingNotice();
409
- // The update + stale-sibling banners must reach the USER. On a
410
- // SessionStart hook that exits 0, stderr is invisible in the normal TUI
411
- // (only shown on exit 2 / --verbose) and additionalContext is model-only —
412
- // `systemMessage` is the documented user-visible channel. Route those two
413
- // banners there. They ALSO stay in noticePrefix → additionalContext below,
414
- // so the model and the user start the session looking at the same state.
415
- // (The other stderr notices — sync/growth/clear/suggest — are intentionally
416
- // transcript/--verbose only and out of this banner's scope.)
417
- const userMessage = [updateLine, siblingLine].filter(Boolean).join('\n\n');
502
+ const pkgDriftLine = buildPkgRootDriftNotice();
503
+ // The update + stale-sibling + pkgRoot-drift banners must reach the USER.
504
+ // On a SessionStart hook that exits 0, stderr is invisible in the normal
505
+ // TUI (only shown on exit 2 / --verbose) and additionalContext is
506
+ // model-only — `systemMessage` is the documented user-visible channel.
507
+ // Route those banners there. They ALSO stay in noticePrefix →
508
+ // additionalContext below, so the model and the user start the session
509
+ // looking at the same state. (The other stderr notices —
510
+ // sync/growth/clear/suggest — are intentionally transcript/--verbose only
511
+ // and out of this banner's scope.)
512
+ const userMessage = [updateLine, siblingLine, pkgDriftLine].filter(Boolean).join('\n\n');
418
513
  if (userMessage) outExtra = { ...outExtra, systemMessage: userMessage };
419
514
  const notices = [
420
515
  syncLine,
@@ -423,6 +518,7 @@ process.stdin.on('end', () => {
423
518
  clearRecoveryLine,
424
519
  updateLine,
425
520
  siblingLine,
521
+ pkgDriftLine,
426
522
  ].filter(Boolean);
427
523
  let noticePrefix = notices.length ? `${notices.join('\n\n')}\n\n` : '';
428
524
  if (syncLine) process.stderr.write(`\n\x1b[33m${syncLine}\x1b[0m\n`);
@@ -432,6 +528,7 @@ process.stdin.on('end', () => {
432
528
  process.stderr.write(`\n\x1b[33m${clearRecoveryLine.split('\n')[0]}\x1b[0m\n`);
433
529
  if (updateLine) process.stderr.write(`\n\x1b[33m${updateLine}\x1b[0m\n`);
434
530
  if (siblingLine) process.stderr.write(`\n\x1b[33m${siblingLine}\x1b[0m\n`);
531
+ if (pkgDriftLine) process.stderr.write(`\n\x1b[33m${pkgDriftLine}\x1b[0m\n`);
435
532
  const cwd = data.cwd || data.directory || process.cwd();
436
533
  const sessionId = data.session_id || 'default';
437
534
  const MARKER_FILE = sessionMarkerPath(sessionId);