hypomnema 1.6.2 → 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.
Files changed (70) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.ko.md +39 -14
  4. package/README.md +39 -14
  5. package/commands/capture.md +8 -6
  6. package/commands/crystallize.md +39 -20
  7. package/docs/ARCHITECTURE.md +49 -14
  8. package/docs/CONTRIBUTING.md +31 -29
  9. package/hooks/base-store.mjs +265 -0
  10. package/hooks/hooks.json +18 -1
  11. package/hooks/hypo-auto-commit.mjs +92 -15
  12. package/hooks/hypo-auto-minimal-crystallize.mjs +63 -20
  13. package/hooks/hypo-auto-stage.mjs +41 -1
  14. package/hooks/hypo-close-guard.mjs +246 -0
  15. package/hooks/hypo-cwd-change.mjs +31 -2
  16. package/hooks/hypo-file-watch.mjs +21 -2
  17. package/hooks/hypo-first-prompt.mjs +19 -3
  18. package/hooks/hypo-hot-rebuild.mjs +43 -5
  19. package/hooks/hypo-lookup.mjs +86 -29
  20. package/hooks/hypo-personal-check.mjs +24 -3
  21. package/hooks/hypo-session-record.mjs +2 -3
  22. package/hooks/hypo-session-start.mjs +199 -20
  23. package/hooks/hypo-shared.mjs +2080 -176
  24. package/hooks/proposal-store.mjs +513 -0
  25. package/hooks/version-check.mjs +45 -0
  26. package/package.json +42 -14
  27. package/scripts/capture.mjs +556 -37
  28. package/scripts/crystallize.mjs +751 -110
  29. package/scripts/doctor.mjs +787 -26
  30. package/scripts/feedback-sync.mjs +515 -44
  31. package/scripts/graph.mjs +35 -13
  32. package/scripts/init.mjs +287 -41
  33. package/scripts/lib/extensions.mjs +656 -1
  34. package/scripts/lib/git-hooks-dir.mjs +229 -0
  35. package/scripts/lib/hypo-ignore.mjs +54 -6
  36. package/scripts/lib/hypo-root.mjs +56 -6
  37. package/scripts/lib/page-usage.mjs +15 -2
  38. package/scripts/lib/pkg-json.mjs +40 -0
  39. package/scripts/lib/plugin-detect.mjs +96 -6
  40. package/scripts/lib/project-create.mjs +5 -1
  41. package/scripts/lib/rename-marker.mjs +39 -0
  42. package/scripts/lib/wd-match.mjs +23 -5
  43. package/scripts/lib/wikilink.mjs +32 -6
  44. package/scripts/lint.mjs +103 -5
  45. package/scripts/proposal.mjs +1032 -0
  46. package/scripts/query.mjs +25 -4
  47. package/scripts/rename.mjs +223 -18
  48. package/scripts/resume.mjs +34 -12
  49. package/scripts/stats.mjs +41 -9
  50. package/scripts/uninstall.mjs +141 -6
  51. package/scripts/upgrade.mjs +197 -15
  52. package/skills/crystallize/SKILL.md +44 -7
  53. package/skills/debate/SKILL.md +88 -0
  54. package/skills/debate/references/orchestration-patterns.md +83 -0
  55. package/templates/.hyposcanignore +10 -0
  56. package/templates/SCHEMA.md +12 -0
  57. package/templates/gitignore +9 -0
  58. package/templates/hypo-config.md +1 -1
  59. package/templates/hypo-guide.md +6 -0
  60. package/scripts/.gitkeep +0 -0
  61. package/scripts/check-bilingual.mjs +0 -153
  62. package/scripts/check-readme-version.mjs +0 -126
  63. package/scripts/check-tracker-ids.mjs +0 -426
  64. package/scripts/check-versions.mjs +0 -171
  65. package/scripts/install-git-hooks.mjs +0 -293
  66. package/scripts/lib/changelog-classify.mjs +0 -216
  67. package/scripts/lib/check-bilingual.mjs +0 -244
  68. package/scripts/lib/check-tracker-ids.mjs +0 -217
  69. package/scripts/lib/pre-commit-format.mjs +0 -251
  70. package/scripts/pre-commit-format.mjs +0 -198
@@ -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,
@@ -32,6 +34,10 @@ import {
32
34
  collectProjectWorkingDirs,
33
35
  buildVaultOrientation,
34
36
  staleMarkerFor,
37
+ currentDevice,
38
+ scopeVisible,
39
+ readVisibilityScope,
40
+ pkgRootDriftStatus,
35
41
  } from './hypo-shared.mjs';
36
42
  import {
37
43
  defaultCachePath,
@@ -45,16 +51,49 @@ import {
45
51
  computeSiblingNotice,
46
52
  siblingAlreadyNotified,
47
53
  markSiblingNotified,
54
+ pkgRootDriftAlreadyNotified,
55
+ markPkgRootDriftNotified,
56
+ clearPkgRootDriftNotified,
48
57
  } from './version-check.mjs';
58
+ import { snapshotBase, overwriteTargets } from './base-store.mjs';
59
+ import { listProposals } from './proposal-store.mjs';
49
60
 
50
61
  // Privacy guard: refuse to read+inject .hypoignore-matched
51
62
  // wiki files into additionalContext. Without this, a user who lists
52
63
  // `projects/private/hot.md` in .hypoignore would still see SECRET emit because
53
64
  // session-start reads hot/state paths directly.
65
+ //
66
+ // Visibility guard: a machine-scoped page (visibility_scope: machine:<owner>)
67
+ // must not be injected on a machine other than its owner. hypo-file-watch
68
+ // already filters these very files, so leaving session start unfiltered made the
69
+ // SAME file behave differently depending on which path opened it: the user sets
70
+ // the field, sees it honored on edit, and never learns that session start still
71
+ // ships the body. Read the scope from the RAW content before the maxChars slice:
72
+ // slicing first could cut the frontmatter off and silently fail open.
73
+ // The root hot.md is a frontmatter-less pointer table, so it reads as '' and
74
+ // passes (shared) unchanged.
54
75
  function readIfNotIgnored(path, maxChars, patterns) {
55
76
  if (!path) return null;
56
77
  if (patterns.length > 0 && isIgnored(path, HYPO_DIR, patterns)) return null;
57
- return readFileSync(path, 'utf-8').slice(0, maxChars);
78
+ const raw = readFileSync(path, 'utf-8');
79
+ if (!scopeVisible(readVisibilityScope(raw), currentDevice())) return null;
80
+ return raw.slice(0, maxChars);
81
+ }
82
+
83
+ // Scoped-out is not the same as absent. Both make readIfNotIgnored return null,
84
+ // but telling the model "no snapshot yet / first session" when the snapshot merely
85
+ // belongs to another machine is a lie it will act on. Returns false for an ignored
86
+ // or missing file so only a real machine-scope hide reports true.
87
+ // The caller may name the project and the fact, never the withheld body: a message
88
+ // explaining the hide must not re-leak what it hid.
89
+ function isScopedOut(path, patterns) {
90
+ try {
91
+ if (!path || !existsSync(path)) return false;
92
+ if (patterns.length > 0 && isIgnored(path, HYPO_DIR, patterns)) return false;
93
+ return !scopeVisible(readVisibilityScope(readFileSync(path, 'utf-8')), currentDevice());
94
+ } catch {
95
+ return false;
96
+ }
58
97
  }
59
98
 
60
99
  // Compute the STALE marker for a hot/state file from its RAW content (readIfNotIgnored
@@ -181,6 +220,52 @@ function buildSiblingNotice() {
181
220
  }
182
221
  }
183
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
+
184
269
  const PROJECTS_DIR = join(HYPO_DIR, 'projects');
185
270
  const GROWTH_CACHE = join(HYPO_DIR, '.cache', 'last-session-growth.json');
186
271
 
@@ -220,14 +305,22 @@ function buildClearRecoveryLine(source) {
220
305
  );
221
306
  }
222
307
 
223
- /** 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
+ */
224
315
  function gitPull(dir) {
225
316
  if (!existsSync(join(dir, '.git'))) return false;
226
317
  const r = spawnSync('git', ['-C', dir, 'pull', '--ff-only', '--quiet'], {
227
318
  stdio: 'pipe',
228
319
  timeout: 10000,
229
320
  });
230
- return r.status === 0;
321
+ const ok = r.status === 0;
322
+ if (ok) recordSyncSuccess(dir, 'pull');
323
+ return ok;
231
324
  }
232
325
 
233
326
  /**
@@ -241,7 +334,9 @@ function gitPull(dir) {
241
334
  * fresh `hypo init` wiki does not git-ignore `.cache/`, so a broader cleanliness
242
335
  * check would see the sync-state file itself and never clear.
243
336
  *
244
- * @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.
245
340
  */
246
341
  function syncStateNotice(pullOk) {
247
342
  const { entries, parseError } = readSyncState(HYPO_DIR);
@@ -262,15 +357,64 @@ function syncStateNotice(pullOk) {
262
357
  return '';
263
358
  }
264
359
  const last = entries[entries.length - 1];
265
- 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') {
266
379
  return (
267
380
  `[WIKI: remote diverged — auto-merge was aborted to protect your edits ` +
268
381
  `(your local work is committed and safe; the other machine's version is on the remote). ` +
269
382
  `Resolve manually: \`git -C ${HYPO_DIR} pull --no-rebase\`, fix conflicts, then push.]`
270
383
  );
271
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
+ }
272
398
  return `[WIKI: last sync failed: ${last.op || '?'} — ${last.error || 'unknown'}]`;
273
399
  }
400
+ /**
401
+ * Surface the vault-wide count of parked write-proposals (T8). Routed
402
+ * exactly like syncStateNotice: the line joins the `notices` array (→
403
+ * additionalContext) and is also written to stderr, so both the model and the
404
+ * user's transcript see it. NOT a systemMessage banner (that channel is
405
+ * reserved for the update/sibling notices). Pure read (listProposals never
406
+ * mutates); best-effort so a store read failure never breaks SessionStart. '' when
407
+ * there are no pending proposals, so nothing surfaces on the empty path.
408
+ */
409
+ function pendingProposalNotice() {
410
+ try {
411
+ const n = listProposals(HYPO_DIR).length;
412
+ if (n === 0) return '';
413
+ return `[WIKI: 대기 proposal ${n}건 (검토: hypomnema proposal list)]`;
414
+ } catch {
415
+ return '';
416
+ }
417
+ }
274
418
  const GLOBAL_HOT = join(HYPO_DIR, 'hot.md');
275
419
  const HOT_CHARS = 2000;
276
420
  const STATE_CHARS = 2000;
@@ -346,6 +490,7 @@ process.stdin.on('end', () => {
346
490
 
347
491
  const pullOk = gitPull(HYPO_DIR);
348
492
  const syncLine = syncStateNotice(pullOk);
493
+ const proposalLine = pendingProposalNotice();
349
494
  const growthLine = readLastGrowthLine();
350
495
  // On source='clear', surface the dying
351
496
  // session's identity that hypo-session-end stashed so Claude can recover
@@ -354,31 +499,53 @@ process.stdin.on('end', () => {
354
499
  const clearRecoveryLine = buildClearRecoveryLine(data.source);
355
500
  const updateLine = buildUpdateNotice();
356
501
  const siblingLine = buildSiblingNotice();
357
- // The update + stale-sibling banners must reach the USER. On a
358
- // SessionStart hook that exits 0, stderr is invisible in the normal TUI
359
- // (only shown on exit 2 / --verbose) and additionalContext is model-only —
360
- // `systemMessage` is the documented user-visible channel. Route those two
361
- // banners there. They ALSO stay in noticePrefix → additionalContext below,
362
- // so the model and the user start the session looking at the same state.
363
- // (The other stderr notices — sync/growth/clear/suggest — are intentionally
364
- // transcript/--verbose only and out of this banner's scope.)
365
- 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');
366
513
  if (userMessage) outExtra = { ...outExtra, systemMessage: userMessage };
367
- const notices = [syncLine, growthLine, clearRecoveryLine, updateLine, siblingLine].filter(
368
- Boolean,
369
- );
514
+ const notices = [
515
+ syncLine,
516
+ proposalLine,
517
+ growthLine,
518
+ clearRecoveryLine,
519
+ updateLine,
520
+ siblingLine,
521
+ pkgDriftLine,
522
+ ].filter(Boolean);
370
523
  let noticePrefix = notices.length ? `${notices.join('\n\n')}\n\n` : '';
371
524
  if (syncLine) process.stderr.write(`\n\x1b[33m${syncLine}\x1b[0m\n`);
525
+ if (proposalLine) process.stderr.write(`\n\x1b[33m${proposalLine}\x1b[0m\n`);
372
526
  if (growthLine) process.stderr.write(`\n\x1b[36m${growthLine}\x1b[0m\n`);
373
527
  if (clearRecoveryLine)
374
528
  process.stderr.write(`\n\x1b[33m${clearRecoveryLine.split('\n')[0]}\x1b[0m\n`);
375
529
  if (updateLine) process.stderr.write(`\n\x1b[33m${updateLine}\x1b[0m\n`);
376
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`);
377
532
  const cwd = data.cwd || data.directory || process.cwd();
378
533
  const sessionId = data.session_id || 'default';
379
534
  const MARKER_FILE = sessionMarkerPath(sessionId);
380
535
  const hit = findProjectFiles(cwd);
381
536
 
537
+ // Observed-base snapshot for the write=proposal gate. Deliberately AFTER gitPull: the base must
538
+ // describe the tree this session actually starts from, remote merges
539
+ // included, or the first close would raise a proposal against content the
540
+ // session never had a chance to conflict with. Once per session
541
+ // (existence-check inside snapshotBase), so resume and compact leave it
542
+ // alone. `data.session_id` is used raw rather than the 'default' fallback
543
+ // above. A session with no id has no base and closes down the legacy
544
+ // direct-write path.
545
+ if (data.session_id) {
546
+ snapshotBase(HYPO_DIR, data.session_id, overwriteTargets(hit ? hit.proj : null));
547
+ }
548
+
382
549
  const ignorePatterns = loadHypoIgnore(HYPO_DIR);
383
550
 
384
551
  // When cwd is a project working_dir that is NOT the vault itself, tell the
@@ -425,17 +592,29 @@ process.stdin.on('end', () => {
425
592
  ),
426
593
  );
427
594
  } else {
595
+ // A snapshot that exists but is scoped to another machine must not be
596
+ // reported as "no snapshot yet": the model would treat a resumed project
597
+ // as a first session. Say which it is, and say nothing of the contents.
598
+ const scopedOut =
599
+ isScopedOut(hit.hotPath, ignorePatterns) || isScopedOut(hit.statePath, ignorePatterns);
600
+ const reason = scopedOut ? 'snapshot scoped to another machine' : 'no snapshot yet';
428
601
  process.stderr.write(
429
- `\n\x1b[36m[Hypomnema]\x1b[0m project: \x1b[1m${hit.proj}\x1b[0m (no snapshot yet)\n\n`,
602
+ `\n\x1b[36m[Hypomnema]\x1b[0m project: \x1b[1m${hit.proj}\x1b[0m (${reason})\n\n`,
430
603
  );
604
+ // Carry the reason into the marker, not just this hook's output.
605
+ // hypo-first-prompt derives its resume line from the marker alone, so a
606
+ // marker that says only `hotPath: null` makes the NEXT prompt announce
607
+ // "first session" for a project that merely belongs to another machine.
608
+ // That lie is what invites the model to author a fresh hot.md over one
609
+ // that already exists elsewhere.
431
610
  writeFileSync(
432
611
  MARKER_FILE,
433
- JSON.stringify({ proj: hit.proj, hotPath: null, ts: Date.now() }),
612
+ JSON.stringify({ proj: hit.proj, hotPath: null, scopedOut, ts: Date.now() }),
434
613
  );
435
614
  console.log(
436
615
  JSON.stringify(
437
616
  buildOutput(
438
- `${noticePrefix}${hitPrefix}[WIKI HOT CACHE: project=${sanitizeProjForPrompt(hit.proj)}, no snapshot yet]`,
617
+ `${noticePrefix}${hitPrefix}[WIKI HOT CACHE: project=${sanitizeProjForPrompt(hit.proj)}, ${reason}]`,
439
618
  outExtra,
440
619
  ),
441
620
  ),