hypomnema 1.6.1 → 1.7.0

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 (62) 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 +7 -1
  11. package/hooks/hypo-auto-minimal-crystallize.mjs +77 -31
  12. package/hooks/hypo-auto-stage.mjs +30 -0
  13. package/hooks/hypo-cwd-change.mjs +31 -2
  14. package/hooks/hypo-file-watch.mjs +21 -2
  15. package/hooks/hypo-first-prompt.mjs +19 -3
  16. package/hooks/hypo-lookup.mjs +86 -29
  17. package/hooks/hypo-personal-check.mjs +24 -3
  18. package/hooks/hypo-session-record.mjs +2 -3
  19. package/hooks/hypo-session-start.mjs +89 -7
  20. package/hooks/hypo-shared.mjs +944 -147
  21. package/hooks/proposal-store.mjs +513 -0
  22. package/package.json +40 -14
  23. package/scripts/capture.mjs +556 -37
  24. package/scripts/crystallize.mjs +639 -108
  25. package/scripts/doctor.mjs +304 -9
  26. package/scripts/feedback-sync.mjs +515 -44
  27. package/scripts/graph.mjs +9 -2
  28. package/scripts/init.mjs +230 -34
  29. package/scripts/lib/extensions.mjs +656 -1
  30. package/scripts/lib/hypo-ignore.mjs +54 -6
  31. package/scripts/lib/hypo-root.mjs +56 -6
  32. package/scripts/lib/page-usage.mjs +15 -2
  33. package/scripts/lib/pkg-json.mjs +40 -0
  34. package/scripts/lib/plugin-detect.mjs +96 -6
  35. package/scripts/lib/wd-match.mjs +23 -5
  36. package/scripts/lib/wikilink.mjs +32 -6
  37. package/scripts/lint.mjs +20 -4
  38. package/scripts/proposal.mjs +1032 -0
  39. package/scripts/query.mjs +25 -4
  40. package/scripts/resume.mjs +34 -12
  41. package/scripts/stats.mjs +28 -8
  42. package/scripts/uninstall.mjs +141 -6
  43. package/scripts/upgrade.mjs +197 -15
  44. package/skills/crystallize/SKILL.md +44 -7
  45. package/skills/debate/SKILL.md +88 -0
  46. package/skills/debate/references/orchestration-patterns.md +83 -0
  47. package/templates/.hyposcanignore +10 -0
  48. package/templates/SCHEMA.md +12 -0
  49. package/templates/gitignore +5 -0
  50. package/templates/hypo-config.md +1 -1
  51. package/templates/hypo-guide.md +6 -0
  52. package/scripts/.gitkeep +0 -0
  53. package/scripts/check-bilingual.mjs +0 -153
  54. package/scripts/check-readme-version.mjs +0 -126
  55. package/scripts/check-tracker-ids.mjs +0 -426
  56. package/scripts/check-versions.mjs +0 -171
  57. package/scripts/install-git-hooks.mjs +0 -293
  58. package/scripts/lib/changelog-classify.mjs +0 -216
  59. package/scripts/lib/check-bilingual.mjs +0 -244
  60. package/scripts/lib/check-tracker-ids.mjs +0 -217
  61. package/scripts/lib/pre-commit-format.mjs +0 -251
  62. package/scripts/pre-commit-format.mjs +0 -198
@@ -32,6 +32,9 @@ import {
32
32
  collectProjectWorkingDirs,
33
33
  buildVaultOrientation,
34
34
  staleMarkerFor,
35
+ currentDevice,
36
+ scopeVisible,
37
+ readVisibilityScope,
35
38
  } from './hypo-shared.mjs';
36
39
  import {
37
40
  defaultCachePath,
@@ -46,15 +49,45 @@ import {
46
49
  siblingAlreadyNotified,
47
50
  markSiblingNotified,
48
51
  } from './version-check.mjs';
52
+ import { snapshotBase, overwriteTargets } from './base-store.mjs';
53
+ import { listProposals } from './proposal-store.mjs';
49
54
 
50
55
  // Privacy guard: refuse to read+inject .hypoignore-matched
51
56
  // wiki files into additionalContext. Without this, a user who lists
52
57
  // `projects/private/hot.md` in .hypoignore would still see SECRET emit because
53
58
  // session-start reads hot/state paths directly.
59
+ //
60
+ // Visibility guard: a machine-scoped page (visibility_scope: machine:<owner>)
61
+ // must not be injected on a machine other than its owner. hypo-file-watch
62
+ // already filters these very files, so leaving session start unfiltered made the
63
+ // SAME file behave differently depending on which path opened it: the user sets
64
+ // the field, sees it honored on edit, and never learns that session start still
65
+ // ships the body. Read the scope from the RAW content before the maxChars slice:
66
+ // slicing first could cut the frontmatter off and silently fail open.
67
+ // The root hot.md is a frontmatter-less pointer table, so it reads as '' and
68
+ // passes (shared) unchanged.
54
69
  function readIfNotIgnored(path, maxChars, patterns) {
55
70
  if (!path) return null;
56
71
  if (patterns.length > 0 && isIgnored(path, HYPO_DIR, patterns)) return null;
57
- return readFileSync(path, 'utf-8').slice(0, maxChars);
72
+ const raw = readFileSync(path, 'utf-8');
73
+ if (!scopeVisible(readVisibilityScope(raw), currentDevice())) return null;
74
+ return raw.slice(0, maxChars);
75
+ }
76
+
77
+ // Scoped-out is not the same as absent. Both make readIfNotIgnored return null,
78
+ // but telling the model "no snapshot yet / first session" when the snapshot merely
79
+ // belongs to another machine is a lie it will act on. Returns false for an ignored
80
+ // or missing file so only a real machine-scope hide reports true.
81
+ // The caller may name the project and the fact, never the withheld body: a message
82
+ // explaining the hide must not re-leak what it hid.
83
+ function isScopedOut(path, patterns) {
84
+ try {
85
+ if (!path || !existsSync(path)) return false;
86
+ if (patterns.length > 0 && isIgnored(path, HYPO_DIR, patterns)) return false;
87
+ return !scopeVisible(readVisibilityScope(readFileSync(path, 'utf-8')), currentDevice());
88
+ } catch {
89
+ return false;
90
+ }
58
91
  }
59
92
 
60
93
  // Compute the STALE marker for a hot/state file from its RAW content (readIfNotIgnored
@@ -271,6 +304,24 @@ function syncStateNotice(pullOk) {
271
304
  }
272
305
  return `[WIKI: last sync failed: ${last.op || '?'} — ${last.error || 'unknown'}]`;
273
306
  }
307
+ /**
308
+ * Surface the vault-wide count of parked write-proposals (T8). Routed
309
+ * exactly like syncStateNotice: the line joins the `notices` array (→
310
+ * additionalContext) and is also written to stderr, so both the model and the
311
+ * user's transcript see it. NOT a systemMessage banner (that channel is
312
+ * reserved for the update/sibling notices). Pure read (listProposals never
313
+ * mutates); best-effort so a store read failure never breaks SessionStart. '' when
314
+ * there are no pending proposals, so nothing surfaces on the empty path.
315
+ */
316
+ function pendingProposalNotice() {
317
+ try {
318
+ const n = listProposals(HYPO_DIR).length;
319
+ if (n === 0) return '';
320
+ return `[WIKI: 대기 proposal ${n}건 (검토: hypomnema proposal list)]`;
321
+ } catch {
322
+ return '';
323
+ }
324
+ }
274
325
  const GLOBAL_HOT = join(HYPO_DIR, 'hot.md');
275
326
  const HOT_CHARS = 2000;
276
327
  const STATE_CHARS = 2000;
@@ -346,6 +397,7 @@ process.stdin.on('end', () => {
346
397
 
347
398
  const pullOk = gitPull(HYPO_DIR);
348
399
  const syncLine = syncStateNotice(pullOk);
400
+ const proposalLine = pendingProposalNotice();
349
401
  const growthLine = readLastGrowthLine();
350
402
  // On source='clear', surface the dying
351
403
  // session's identity that hypo-session-end stashed so Claude can recover
@@ -364,11 +416,17 @@ process.stdin.on('end', () => {
364
416
  // transcript/--verbose only and out of this banner's scope.)
365
417
  const userMessage = [updateLine, siblingLine].filter(Boolean).join('\n\n');
366
418
  if (userMessage) outExtra = { ...outExtra, systemMessage: userMessage };
367
- const notices = [syncLine, growthLine, clearRecoveryLine, updateLine, siblingLine].filter(
368
- Boolean,
369
- );
419
+ const notices = [
420
+ syncLine,
421
+ proposalLine,
422
+ growthLine,
423
+ clearRecoveryLine,
424
+ updateLine,
425
+ siblingLine,
426
+ ].filter(Boolean);
370
427
  let noticePrefix = notices.length ? `${notices.join('\n\n')}\n\n` : '';
371
428
  if (syncLine) process.stderr.write(`\n\x1b[33m${syncLine}\x1b[0m\n`);
429
+ if (proposalLine) process.stderr.write(`\n\x1b[33m${proposalLine}\x1b[0m\n`);
372
430
  if (growthLine) process.stderr.write(`\n\x1b[36m${growthLine}\x1b[0m\n`);
373
431
  if (clearRecoveryLine)
374
432
  process.stderr.write(`\n\x1b[33m${clearRecoveryLine.split('\n')[0]}\x1b[0m\n`);
@@ -379,6 +437,18 @@ process.stdin.on('end', () => {
379
437
  const MARKER_FILE = sessionMarkerPath(sessionId);
380
438
  const hit = findProjectFiles(cwd);
381
439
 
440
+ // Observed-base snapshot for the write=proposal gate. Deliberately AFTER gitPull: the base must
441
+ // describe the tree this session actually starts from, remote merges
442
+ // included, or the first close would raise a proposal against content the
443
+ // session never had a chance to conflict with. Once per session
444
+ // (existence-check inside snapshotBase), so resume and compact leave it
445
+ // alone. `data.session_id` is used raw rather than the 'default' fallback
446
+ // above. A session with no id has no base and closes down the legacy
447
+ // direct-write path.
448
+ if (data.session_id) {
449
+ snapshotBase(HYPO_DIR, data.session_id, overwriteTargets(hit ? hit.proj : null));
450
+ }
451
+
382
452
  const ignorePatterns = loadHypoIgnore(HYPO_DIR);
383
453
 
384
454
  // When cwd is a project working_dir that is NOT the vault itself, tell the
@@ -425,17 +495,29 @@ process.stdin.on('end', () => {
425
495
  ),
426
496
  );
427
497
  } else {
498
+ // A snapshot that exists but is scoped to another machine must not be
499
+ // reported as "no snapshot yet": the model would treat a resumed project
500
+ // as a first session. Say which it is, and say nothing of the contents.
501
+ const scopedOut =
502
+ isScopedOut(hit.hotPath, ignorePatterns) || isScopedOut(hit.statePath, ignorePatterns);
503
+ const reason = scopedOut ? 'snapshot scoped to another machine' : 'no snapshot yet';
428
504
  process.stderr.write(
429
- `\n\x1b[36m[Hypomnema]\x1b[0m project: \x1b[1m${hit.proj}\x1b[0m (no snapshot yet)\n\n`,
505
+ `\n\x1b[36m[Hypomnema]\x1b[0m project: \x1b[1m${hit.proj}\x1b[0m (${reason})\n\n`,
430
506
  );
507
+ // Carry the reason into the marker, not just this hook's output.
508
+ // hypo-first-prompt derives its resume line from the marker alone, so a
509
+ // marker that says only `hotPath: null` makes the NEXT prompt announce
510
+ // "first session" for a project that merely belongs to another machine.
511
+ // That lie is what invites the model to author a fresh hot.md over one
512
+ // that already exists elsewhere.
431
513
  writeFileSync(
432
514
  MARKER_FILE,
433
- JSON.stringify({ proj: hit.proj, hotPath: null, ts: Date.now() }),
515
+ JSON.stringify({ proj: hit.proj, hotPath: null, scopedOut, ts: Date.now() }),
434
516
  );
435
517
  console.log(
436
518
  JSON.stringify(
437
519
  buildOutput(
438
- `${noticePrefix}${hitPrefix}[WIKI HOT CACHE: project=${sanitizeProjForPrompt(hit.proj)}, no snapshot yet]`,
520
+ `${noticePrefix}${hitPrefix}[WIKI HOT CACHE: project=${sanitizeProjForPrompt(hit.proj)}, ${reason}]`,
439
521
  outExtra,
440
522
  ),
441
523
  ),