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
@@ -48,7 +48,17 @@ process.stdin.on('end', () => {
48
48
  }
49
49
 
50
50
  const hasSnapshot = marker.hasSnapshot ?? (marker.hotPath && existsSync(marker.hotPath));
51
- const snapshotNote = hasSnapshot ? '' : ' (no snapshot yet — first session)';
51
+ // A snapshot withheld by visibility_scope is NOT an absent one. session-start
52
+ // stamps the marker so this hook can tell them apart: without it, a project
53
+ // resumed on a foreign machine is announced as a first session, and the model
54
+ // then has every reason to author a fresh hot.md over the one that exists on
55
+ // the owning machine. Never name the withheld contents, only the fact.
56
+ const scopedOut = marker.scopedOut === true;
57
+ const snapshotNote = hasSnapshot
58
+ ? ''
59
+ : scopedOut
60
+ ? ' (snapshot scoped to another machine)'
61
+ : ' (no snapshot yet — first session)';
52
62
  // a cwd-change re-trigger says "Resuming"; a fresh session start
53
63
  // (default source) says "Previously working on".
54
64
  const verb = marker.source === 'cwd-change' ? 'Resuming' : 'Previously working on';
@@ -63,11 +73,17 @@ process.stdin.on('end', () => {
63
73
  // first-ever session (codex v2 review 2026-05-26).
64
74
  const exampleLine = hasSnapshot
65
75
  ? `${verb} ${projSafe}: [one-line summary]. Continue with [next task]?`
66
- : `${verb} ${projSafe}: no prior snapshot yet — first session. What would you like to start with?`;
76
+ : scopedOut
77
+ ? `${projSafe}: this project has a prior snapshot, but it is scoped to another machine and is not visible here. What would you like to work on?`
78
+ : `${verb} ${projSafe}: no prior snapshot yet — first session. What would you like to start with?`;
67
79
  const fillNote = hasSnapshot
68
80
  ? `Replace the bracketed placeholders using the [HOT] / [SESSION STATE] ` +
69
81
  `context already injected this session — do NOT emit the literal brackets.`
70
- : `Use the line above verbatim — there is no prior snapshot to summarize.`;
82
+ : scopedOut
83
+ ? `Use the line above verbatim. The project has prior work; its snapshot ` +
84
+ `simply belongs to another machine, so treat it as an existing project ` +
85
+ `whose history you cannot see from here.`
86
+ : `Use the line above verbatim — there is no prior snapshot to summarize.`;
71
87
 
72
88
  console.log(
73
89
  JSON.stringify(
@@ -17,11 +17,31 @@ import {
17
17
  computeSessionGrowth,
18
18
  formatGrowthMetrics,
19
19
  deriveRootLogEntries,
20
+ recordTouchedPaths,
20
21
  } from './hypo-shared.mjs';
21
22
 
22
23
  const HOT_PATH = join(HYPO_DIR, 'hot.md');
23
24
  const GROWTH_CACHE = join(HYPO_DIR, '.cache', 'last-session-growth.json');
24
25
 
26
+ // This Stop hook runs BEFORE hypo-auto-commit and can write hot.md
27
+ // (rebuild) and log.md (deriveRootLogEntries), both hook-generated, not user
28
+ // Write/Edit, so hypo-auto-stage never sees them. Read session_id off stdin so
29
+ // whatever this hook writes still lands in the scoped commit's set; without
30
+ // this, a scope built from Write/Edit alone would silently drop these files
31
+ // from every session's auto-commit.
32
+ let sessionId = null;
33
+ try {
34
+ const raw = await new Promise((r) => {
35
+ let d = '';
36
+ process.stdin.on('data', (c) => (d += c));
37
+ process.stdin.on('end', () => r(d));
38
+ });
39
+ const payload = JSON.parse(raw || '{}') || {};
40
+ sessionId = payload.session_id || payload.sessionId || null;
41
+ } catch {
42
+ sessionId = null;
43
+ }
44
+
25
45
  function parseFrontmatter(content) {
26
46
  const m = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
27
47
  if (!m) return {};
@@ -52,12 +72,13 @@ function parsePointerRows(content) {
52
72
  return rows;
53
73
  }
54
74
 
75
+ /** @returns {boolean} true when hot.md was actually rewritten. */
55
76
  function rebuild() {
56
- if (!existsSync(HOT_PATH)) return;
77
+ if (!existsSync(HOT_PATH)) return false;
57
78
 
58
79
  const current = readFileSync(HOT_PATH, 'utf-8');
59
80
  const rows = parsePointerRows(current);
60
- if (rows.length === 0) return;
81
+ if (rows.length === 0) return false;
61
82
 
62
83
  const today = new Date().toISOString().slice(0, 10);
63
84
 
@@ -93,7 +114,11 @@ ${tableRows}
93
114
  3. Read \`projects/<name>/hot.md\` for project background
94
115
  `;
95
116
 
96
- if (canonical !== current) writeFileSync(HOT_PATH, canonical);
117
+ if (canonical !== current) {
118
+ writeFileSync(HOT_PATH, canonical);
119
+ return true;
120
+ }
121
+ return false;
97
122
  }
98
123
 
99
124
  function emitGrowth() {
@@ -107,16 +132,18 @@ function emitGrowth() {
107
132
  } catch {}
108
133
  }
109
134
 
135
+ let hotWritten = false;
110
136
  try {
111
- rebuild();
137
+ hotWritten = rebuild();
112
138
  } catch (err) {
113
139
  process.stderr.write(`[hypo-hot-rebuild] error: ${err?.message ?? String(err)}\n`);
114
140
  }
115
141
  // Auto-derive the root log.md session entry from each project's session-log
116
142
  // heading (runs AFTER rebuild() so root hot.md is already fresh and isn't itself
117
143
  // counted as the project's open gate problem). Best-effort: own try/catch.
144
+ let logEntriesAdded = 0;
118
145
  try {
119
- deriveRootLogEntries(HYPO_DIR);
146
+ logEntriesAdded = deriveRootLogEntries(HYPO_DIR);
120
147
  } catch (err) {
121
148
  process.stderr.write(`[hypo-hot-rebuild] log-derive error: ${err?.message ?? String(err)}\n`);
122
149
  }
@@ -126,6 +153,17 @@ try {
126
153
  process.stderr.write(`[hypo-hot-rebuild] error: ${err?.message ?? String(err)}\n`);
127
154
  }
128
155
 
156
+ // Feed this hook's own writes into the session's scoped auto-commit
157
+ // set (see the sessionId comment above). No-op without a session_id.
158
+ try {
159
+ const touched = [];
160
+ if (hotWritten) touched.push('hot.md');
161
+ if (logEntriesAdded > 0) touched.push('log.md');
162
+ if (touched.length > 0) recordTouchedPaths(HYPO_DIR, sessionId, touched);
163
+ } catch (err) {
164
+ process.stderr.write(`[hypo-hot-rebuild] touched-paths error: ${err?.message ?? String(err)}\n`);
165
+ }
166
+
129
167
  try {
130
168
  console.log(JSON.stringify({ continue: true, suppressOutput: true }));
131
169
  } catch {}
@@ -19,6 +19,9 @@ import {
19
19
  staleMarkerFor,
20
20
  pageUsageLoggingAllowed,
21
21
  recordLookupUsage,
22
+ currentDevice,
23
+ scopeVisible,
24
+ readVisibilityScope,
22
25
  } from './hypo-shared.mjs';
23
26
 
24
27
  const INDEX_PATH = join(HYPO_DIR, 'index.md');
@@ -32,7 +35,16 @@ function buildPageMap(dir, root = dir, map = {}, ignorePatterns = [], hypoDir =
32
35
  for (const entry of readdirSync(dir)) {
33
36
  const full = join(dir, entry);
34
37
  if (isIgnored(full, hypoDir, ignorePatterns)) continue;
35
- if (statSync(full).isDirectory()) {
38
+ // Skip an entry we can't stat (dangling symlink, race, permission) instead
39
+ // of throwing: the map is now built before the miss branch too, so one bad
40
+ // entry must not sink the whole lookup into the silent outer catch.
41
+ let st;
42
+ try {
43
+ st = statSync(full);
44
+ } catch {
45
+ continue;
46
+ }
47
+ if (st.isDirectory()) {
36
48
  buildPageMap(full, root, map, ignorePatterns, hypoDir);
37
49
  } else if (entry.endsWith('.md')) {
38
50
  const rel = full.slice(root.length + 1).replace(/\.md$/, '');
@@ -208,25 +220,6 @@ process.stdin.on('end', () => {
208
220
  const topScore = scored[0]?.score ?? 0;
209
221
  const matched = scored.filter((e) => e.score >= topScore * 0.5);
210
222
 
211
- if (matched.length === 0) {
212
- const topic = keywords.slice(0, 5).join(', ');
213
- const closest = bm25Score(keywords, entries)
214
- .map((e) => ({ ...e, score: e.score * typePrior(e.slug) }))
215
- .sort((a, c) => c.score - a.score)
216
- .slice(0, 3)
217
- .map((e) => `[[${e.slug}]]`)
218
- .join(', ');
219
- console.log(
220
- JSON.stringify(
221
- buildOutput(`[WIKI LOOKUP: miss] "${topic}" — no match. Closest: ${closest || 'none'}`, {
222
- continue: true,
223
- suppressOutput: true,
224
- }),
225
- ),
226
- );
227
- return;
228
- }
229
-
230
223
  const ignorePatterns = loadHypoIgnore(HYPO_DIR);
231
224
  const pageMap = {
232
225
  ...buildPageMap(
@@ -245,19 +238,81 @@ process.stdin.on('end', () => {
245
238
  ),
246
239
  };
247
240
 
241
+ // Single device snapshot for the whole prompt (currentDevice() itself stays
242
+ // uncached upstream so tests can override via HYPO_DEVICE; this hook only
243
+ // needs one read per lookup, not one per candidate).
244
+ const device = currentDevice();
245
+
246
+ // Resolve a slug to its on-disk path the same way the injection loop does.
247
+ const resolveSlugPath = (slug) =>
248
+ pageMap[slug] ?? pageMap[slug.replace(/^(pages|projects)\//, '')] ?? pageMap[basename(slug)];
249
+
250
+ // Read a page's raw content at most once per lookup: the visibility gate and
251
+ // the injection loop both need it, so memoize to avoid a double read.
252
+ const rawCache = new Map();
253
+ const readRaw = (path) => {
254
+ if (rawCache.has(path)) return rawCache.get(path);
255
+ let raw = null;
256
+ try {
257
+ raw = readFileSync(path, 'utf-8');
258
+ } catch {
259
+ raw = null;
260
+ }
261
+ rawCache.set(path, raw);
262
+ return raw;
263
+ };
264
+
265
+ // Visibility gate: MUST read raw content and go through readVisibilityScope
266
+ // (never a local frontmatter parser) so first-wins + comment-strip stay in
267
+ // step with the write side — a local last-wins/no-comment parser would miss
268
+ // `machine:devA # note` on devA itself. An unresolved/unreadable slug passes
269
+ // through (nothing to leak; the missing-file paths handle it downstream).
270
+ const isSlugVisible = (slug) => {
271
+ const path = resolveSlugPath(slug);
272
+ if (!path || !existsSync(path)) return true;
273
+ const raw = readRaw(path);
274
+ if (raw === null) return true;
275
+ return scopeVisible(readVisibilityScope(raw), device);
276
+ };
277
+
278
+ // Filter BEFORE the miss decision so a device that matches only machine-other
279
+ // pages takes the clean miss path (with closest VISIBLE suggestions), not the
280
+ // "index hit but files missing" branch. Hidden pages never reach injection,
281
+ // the empty-injection slug branch, or the "+N more" count — all read only
282
+ // visibleMatched, so no machine-other slug can leak through any path.
283
+ const visibleMatched = matched.filter((e) => isSlugVisible(e.slug));
284
+
285
+ if (visibleMatched.length === 0) {
286
+ const topic = keywords.slice(0, 5).join(', ');
287
+ const closest = bm25Score(keywords, entries)
288
+ .map((e) => ({ ...e, score: e.score * typePrior(e.slug) }))
289
+ .sort((a, c) => c.score - a.score)
290
+ .filter((e) => isSlugVisible(e.slug))
291
+ .slice(0, 3)
292
+ .map((e) => `[[${e.slug}]]`)
293
+ .join(', ');
294
+ console.log(
295
+ JSON.stringify(
296
+ buildOutput(`[WIKI LOOKUP: miss] "${topic}" — no match. Closest: ${closest || 'none'}`, {
297
+ continue: true,
298
+ suppressOutput: true,
299
+ }),
300
+ ),
301
+ );
302
+ return;
303
+ }
304
+
248
305
  // UTC to match doctor.mjs' overdue set (D1/D2). STALE is advisory, so a
249
306
  // one-day UTC/local skew never misleads.
250
307
  const TODAY = new Date().toISOString().slice(0, 10);
251
308
 
252
309
  const injected = [];
253
310
  const injectedSlugs = [];
254
- for (const { slug } of matched.slice(0, MAX_HITS)) {
255
- const path =
256
- pageMap[slug] ??
257
- pageMap[slug.replace(/^(pages|projects)\//, '')] ??
258
- pageMap[basename(slug)];
311
+ for (const { slug } of visibleMatched.slice(0, MAX_HITS)) {
312
+ const path = resolveSlugPath(slug);
259
313
  if (path && existsSync(path)) {
260
- const raw = readFileSync(path, 'utf-8');
314
+ const raw = readRaw(path);
315
+ if (raw === null) continue;
261
316
  // Compute the marker on raw (pre-slice) so a long body can't truncate
262
317
  // frontmatter out of reach. fail-open: a marker failure drops the marker,
263
318
  // never the page.
@@ -275,7 +330,7 @@ process.stdin.on('end', () => {
275
330
  }
276
331
 
277
332
  if (injected.length === 0) {
278
- const slugs = matched
333
+ const slugs = visibleMatched
279
334
  .slice(0, MAX_HITS)
280
335
  .map((e) => e.slug)
281
336
  .join(', ');
@@ -297,9 +352,11 @@ process.stdin.on('end', () => {
297
352
  recordLookupUsage(HYPO_DIR, { sessionId, slugs: injectedSlugs });
298
353
  }
299
354
 
355
+ // visibleMatched (not matched): a hidden count would still name a "+N more"
356
+ // total that includes machine-other pages this device can never see.
300
357
  const overflow =
301
- matched.length > MAX_HITS
302
- ? `\n(+${matched.length - MAX_HITS} more matches — search wiki index for more)`
358
+ visibleMatched.length > MAX_HITS
359
+ ? `\n(+${visibleMatched.length - MAX_HITS} more matches — search wiki index for more)`
303
360
  : '';
304
361
 
305
362
  console.log(
@@ -42,6 +42,7 @@ process.stdin.on('data', (chunk) => (raw += chunk));
42
42
  process.stdin.on('end', () => {
43
43
  let transcriptPath = null;
44
44
  let sessionId = null;
45
+ let sessionCwd = null;
45
46
  try {
46
47
  const input = JSON.parse(raw || '{}');
47
48
  transcriptPath = input.transcript_path ?? null;
@@ -49,6 +50,11 @@ process.stdin.on('end', () => {
49
50
  // semantics (no project attribution) so /compact does not block a closed
50
51
  // non-project session on the active/phantom project's files.
51
52
  sessionId = input.session_id ?? input.sessionId ?? null;
53
+ // Authoritative session cwd for the session-cwd close check. This is the one
54
+ // verified cwd source (mirrors hypo-session-record); it lets /compact block a
55
+ // session whose own project close was never started, which the recency-based
56
+ // global status cannot see.
57
+ sessionCwd = input.cwd ?? null;
52
58
  } catch {
53
59
  /* fail-open */
54
60
  }
@@ -107,6 +113,7 @@ process.stdin.on('end', () => {
107
113
  gate = precompactGateStatus(HYPO_DIR, {
108
114
  transcriptPath,
109
115
  ...(sessionId ? { sessionId } : {}),
116
+ ...(sessionCwd ? { sessionCwd } : {}),
110
117
  });
111
118
  } catch (err) {
112
119
  // Defense-in-depth: precompactGateStatus fails open per-check, but if it ever
@@ -122,9 +129,11 @@ process.stdin.on('end', () => {
122
129
  // turn the (otherwise non-blocking) drift into a blocker, since real drift is
123
130
  // confirmed and silently passing it would defeat the gate. --write only applies
124
131
  // when no target conflicts/over-caps (code===0 across ALL targets), so a late
125
- // race exits non-zero and blocks here. It is semantic-preflight atomic but not
126
- // filesystem-atomic (concurrent edit / mid-write I/O fault is best-effort) —
127
- // pre-existing engine behavior, not introduced by the self-heal.
132
+ // race exits non-zero and blocks here. Each FILE it writes is atomic (tmp+
133
+ // rename, see feedback-sync's atomicWrite), so a mid-write fault can no longer
134
+ // truncate a target; what is still not atomic is the write ACROSS targets (one
135
+ // file can land before another fails), which the preflight narrows to genuine
136
+ // fs errors and no further.
128
137
  let feedbackHealed = '';
129
138
  if (gate.ok && gate.driftTargets.length > 0) {
130
139
  const feedbackPath = PKG_ROOT ? join(PKG_ROOT, 'scripts', 'feedback-sync.mjs') : null;
@@ -179,6 +188,18 @@ process.stdin.on('end', () => {
179
188
  const fold = `+${otherDebtCount} pre-existing lint issue(s) elsewhere in the vault (other projects / shared pages, not blocking) — run \`/hypo:lint\` for the full list.`;
180
189
  noticeText = noticeText ? `${noticeText}\n${fold}` : `[WIKI CHECK] ${fold}`;
181
190
  }
191
+ // A demoted close: some OTHER session left a project's close incomplete. It no
192
+ // longer blocks this compact, but it must still be SEEN — a notice list that the
193
+ // gate silently swallows (suppressOutput when nothing else surfaced) would turn the
194
+ // demotion into a disappearance, and nobody would ever fix the dangling close
195
+ // (codex design BLOCKER).
196
+ const closeDebt = gate.notices.filter((n) => n.type === 'close-debt');
197
+ if (closeDebt.length > 0) {
198
+ const line = `[WIKI CHECK] ${closeDebt.length} project(s) with an incomplete session close from another session (not blocking this compact): ${closeDebt
199
+ .map((n) => n.project)
200
+ .join(', ')} — each is fixed by that project's next close.`;
201
+ noticeText = noticeText ? `${noticeText}\n${line}` : line;
202
+ }
182
203
  // Surface the self-heal so a re-synced projection is not a silent mutation of
183
204
  // the user's MEMORY.md / CLAUDE.md (transparency).
184
205
  if (feedbackHealed) noticeText = noticeText ? `${noticeText}\n${feedbackHealed}` : feedbackHealed;
@@ -12,8 +12,7 @@
12
12
 
13
13
  import { existsSync, mkdirSync, appendFileSync } from 'fs';
14
14
  import { dirname, join } from 'path';
15
- import { hostname } from 'os';
16
- import { HYPO_DIR } from './hypo-shared.mjs';
15
+ import { HYPO_DIR, currentDevice } from './hypo-shared.mjs';
17
16
 
18
17
  const INDEX_PATH = join(HYPO_DIR, '.cache', 'sessions', 'index.jsonl');
19
18
 
@@ -54,7 +53,7 @@ process.stdin.on('end', () => {
54
53
  // machine identity for multi-machine audit. index.jsonl lives
55
54
  // under .cache/ (gitignored in a normal vault), so this is a LOCAL-only
56
55
  // per-session record — accurate for every session, no sync/privacy cost.
57
- device: hostname() || 'unknown',
56
+ device: currentDevice(),
58
57
  };
59
58
  appendFileSync(INDEX_PATH, JSON.stringify(entry) + '\n');
60
59
  } catch (err) {