hypomnema 1.0.1 → 1.2.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 (76) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.ko.md +12 -5
  4. package/README.md +12 -5
  5. package/commands/audit.md +46 -0
  6. package/commands/crystallize.md +113 -23
  7. package/commands/feedback.md +40 -26
  8. package/commands/ingest.md +31 -9
  9. package/commands/upgrade.md +2 -2
  10. package/docs/ARCHITECTURE.md +83 -9
  11. package/docs/CONTRIBUTING.md +2 -2
  12. package/hooks/hooks.json +39 -1
  13. package/hooks/hypo-auto-commit.mjs +23 -4
  14. package/hooks/hypo-auto-minimal-crystallize.mjs +145 -0
  15. package/hooks/hypo-auto-stage.mjs +9 -5
  16. package/hooks/hypo-compact-guard.mjs +33 -24
  17. package/hooks/hypo-cwd-change.mjs +107 -24
  18. package/hooks/hypo-file-watch.mjs +23 -10
  19. package/hooks/hypo-first-prompt.mjs +37 -23
  20. package/hooks/hypo-hot-rebuild.mjs +31 -8
  21. package/hooks/hypo-lookup.mjs +171 -65
  22. package/hooks/hypo-personal-check.mjs +207 -112
  23. package/hooks/hypo-pre-commit.mjs +46 -0
  24. package/hooks/hypo-session-end.mjs +58 -0
  25. package/hooks/hypo-session-record.mjs +60 -0
  26. package/hooks/hypo-session-start.mjs +312 -44
  27. package/hooks/hypo-shared.mjs +880 -28
  28. package/hooks/hypo-web-fetch-ingest.mjs +121 -0
  29. package/hooks/version-check-fetch.mjs +74 -0
  30. package/hooks/version-check.mjs +184 -0
  31. package/package.json +17 -3
  32. package/scripts/crystallize.mjs +623 -18
  33. package/scripts/doctor.mjs +739 -46
  34. package/scripts/feedback-sync.mjs +974 -0
  35. package/scripts/feedback.mjs +253 -44
  36. package/scripts/graph.mjs +35 -22
  37. package/scripts/ingest.mjs +89 -16
  38. package/scripts/init.mjs +442 -114
  39. package/scripts/lib/design-history-stale.mjs +83 -0
  40. package/scripts/lib/extensions.mjs +749 -0
  41. package/scripts/lib/frontmatter.mjs +5 -1
  42. package/scripts/lib/hypo-ignore.mjs +12 -10
  43. package/scripts/lib/pkg-json.mjs +23 -5
  44. package/scripts/lib/project-create.mjs +225 -0
  45. package/scripts/lib/schema-vocab.mjs +96 -0
  46. package/scripts/lint.mjs +238 -31
  47. package/scripts/query.mjs +26 -10
  48. package/scripts/resume.mjs +11 -5
  49. package/scripts/session-audit.mjs +277 -0
  50. package/scripts/smoke-pack.mjs +224 -0
  51. package/scripts/stats.mjs +24 -10
  52. package/scripts/uninstall.mjs +369 -48
  53. package/scripts/upgrade.mjs +766 -195
  54. package/scripts/verify.mjs +24 -14
  55. package/scripts/weekly-report.mjs +211 -0
  56. package/skills/crystallize/SKILL.md +24 -7
  57. package/skills/graph/SKILL.md +4 -0
  58. package/skills/ingest/SKILL.md +29 -5
  59. package/skills/lint/SKILL.md +4 -0
  60. package/skills/query/SKILL.md +4 -0
  61. package/skills/verify/SKILL.md +4 -0
  62. package/templates/.hypoignore +19 -2
  63. package/templates/Home.md +2 -0
  64. package/templates/SCHEMA.md +61 -6
  65. package/templates/extensions/agents/.gitkeep +0 -0
  66. package/templates/extensions/commands/.gitkeep +0 -0
  67. package/templates/extensions/hooks/.gitkeep +0 -0
  68. package/templates/extensions/skills/.gitkeep +0 -0
  69. package/templates/gitignore +5 -0
  70. package/templates/hot.md +2 -0
  71. package/templates/hypo-config.md +1 -1
  72. package/templates/hypo-guide.md +63 -1
  73. package/templates/hypo-help.md +1 -1
  74. package/templates/pages/observability/_index.md +77 -0
  75. package/templates/projects/_template/index.md +2 -2
  76. package/templates/projects/_template/prd.md +1 -1
@@ -6,21 +6,41 @@
6
6
  * project hot.md. Skips if still within the same project subtree.
7
7
  */
8
8
 
9
- import { readFileSync, existsSync, readdirSync, statSync } from 'fs';
9
+ import { readFileSync, writeFileSync, existsSync, readdirSync, statSync } from 'fs';
10
10
  import { homedir } from 'os';
11
11
  import { join } from 'path';
12
- import { HYPO_DIR, buildOutput } from './hypo-shared.mjs';
12
+ import {
13
+ HYPO_DIR,
14
+ buildOutput,
15
+ loadHypoIgnore,
16
+ isIgnored,
17
+ sessionMarkerPath,
18
+ shouldSuggestProjectCreation,
19
+ buildProjectSuggestionLine,
20
+ recordSuggestionCooldown,
21
+ } from './hypo-shared.mjs';
13
22
 
14
23
  const PROJECTS_DIR = join(HYPO_DIR, 'projects');
15
- const GLOBAL_HOT = join(HYPO_DIR, 'hot.md');
16
- const MAX_CHARS = 3000;
24
+ const GLOBAL_HOT = join(HYPO_DIR, 'hot.md');
25
+ const MAX_CHARS = 3000;
26
+
27
+ // Privacy guard: a .hypoignore-matched hot.md must not be
28
+ // re-emitted into additionalContext on cwd change.
29
+ function readIfNotIgnored(path, patterns) {
30
+ if (!path) return null;
31
+ if (patterns.length > 0 && isIgnored(path, HYPO_DIR, patterns)) return null;
32
+ return readFileSync(path, 'utf-8').slice(0, MAX_CHARS);
33
+ }
17
34
 
18
35
  function parseFrontmatterField(content, key) {
19
36
  const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
20
37
  if (!match) return null;
21
- const line = match[1].split('\n').find(l => l.startsWith(`${key}:`));
38
+ const line = match[1].split('\n').find((l) => l.startsWith(`${key}:`));
22
39
  if (!line) return null;
23
- return line.slice(key.length + 1).trim().replace(/^['"]|['"]$/g, '');
40
+ return line
41
+ .slice(key.length + 1)
42
+ .trim()
43
+ .replace(/^['"]|['"]$/g, '');
24
44
  }
25
45
 
26
46
  function findProjectHot(cwd) {
@@ -30,7 +50,7 @@ function findProjectHot(cwd) {
30
50
  if (!statSync(projDir).isDirectory()) continue;
31
51
  const indexPath = join(projDir, 'index.md');
32
52
  if (!existsSync(indexPath)) continue;
33
- const content = readFileSync(indexPath, 'utf-8');
53
+ const content = readFileSync(indexPath, 'utf-8');
34
54
  const workingDir = parseFrontmatterField(content, 'working_dir');
35
55
  if (!workingDir) continue;
36
56
  const resolved = workingDir.startsWith('~/')
@@ -38,7 +58,13 @@ function findProjectHot(cwd) {
38
58
  : workingDir;
39
59
  if (cwd === resolved || cwd.startsWith(resolved + '/')) {
40
60
  const hotPath = join(projDir, 'hot.md');
41
- return { proj, hotPath: existsSync(hotPath) ? hotPath : null, resolved };
61
+ const statePath = join(projDir, 'session-state.md');
62
+ return {
63
+ proj,
64
+ hotPath: existsSync(hotPath) ? hotPath : null,
65
+ statePath: existsSync(statePath) ? statePath : null,
66
+ resolved,
67
+ };
42
68
  }
43
69
  }
44
70
  return null;
@@ -46,14 +72,17 @@ function findProjectHot(cwd) {
46
72
 
47
73
  let raw = '';
48
74
  process.stdin.setEncoding('utf-8');
49
- process.stdin.on('data', chunk => raw += chunk);
75
+ process.stdin.on('data', (chunk) => (raw += chunk));
50
76
  process.stdin.on('end', () => {
51
77
  try {
52
78
  let data = {};
53
- try { data = JSON.parse(raw); } catch {}
79
+ try {
80
+ data = JSON.parse(raw);
81
+ } catch {}
54
82
 
55
83
  const newCwd = data.new_cwd || data.new_directory || data.cwd || process.cwd();
56
84
  const oldCwd = data.old_cwd || data.old_directory || data.previous_cwd || '';
85
+ const sessionId = data.session_id || 'default';
57
86
 
58
87
  // Skip re-injection if still in the same project
59
88
  const oldHit = oldCwd ? findProjectHot(oldCwd) : null;
@@ -64,28 +93,82 @@ process.stdin.on('end', () => {
64
93
  return;
65
94
  }
66
95
 
96
+ const ignorePatterns = loadHypoIgnore(HYPO_DIR);
97
+
67
98
  if (newHit) {
68
- const content = newHit.hotPath
69
- ? readFileSync(newHit.hotPath, 'utf-8').slice(0, MAX_CHARS)
70
- : '(no hot.md yet will be created at session close)';
71
- console.log(JSON.stringify(
72
- buildOutput(`[WIKI: cwd changed project=${newHit.proj}]\n\n${content}`, { continue: true, suppressOutput: true })
73
- ));
99
+ const fromFile = readIfNotIgnored(newHit.hotPath, ignorePatterns);
100
+ const content = fromFile ?? '(no hot.md yet — will be created at session close)';
101
+ // fix #13: arm the first-prompt marker so the NEXT user prompt re-triggers
102
+ // hypo-first-prompt, which forces a "Resuming <project>" summary line.
103
+ // Only arm when real hot content was actually injected if hot.md is
104
+ // missing or .hypoignore'd (fromFile null), there is nothing for the LLM
105
+ // to summarize, so forcing "Resuming" would be empty noise (codex review).
106
+ if (fromFile) {
107
+ try {
108
+ writeFileSync(
109
+ sessionMarkerPath(sessionId),
110
+ JSON.stringify({
111
+ proj: newHit.proj,
112
+ hotPath: newHit.hotPath,
113
+ statePath: newHit.statePath,
114
+ hasSnapshot: true,
115
+ source: 'cwd-change',
116
+ ts: Date.now(),
117
+ }),
118
+ );
119
+ } catch (err) {
120
+ process.stderr.write(
121
+ `[hypo-cwd-change] marker write failed: ${err?.message ?? String(err)}\n`,
122
+ );
123
+ }
124
+ }
125
+ console.log(
126
+ JSON.stringify(
127
+ buildOutput(`[WIKI: cwd changed → project=${newHit.proj}]\n\n${content}`, {
128
+ continue: true,
129
+ suppressOutput: true,
130
+ }),
131
+ ),
132
+ );
74
133
  return;
75
134
  }
76
135
 
77
- if (!existsSync(GLOBAL_HOT)) {
78
- console.log(JSON.stringify({ continue: true, suppressOutput: true }));
79
- return;
136
+ // MISS: cwd matches no project. fix #23 / ADR 0023 — offer to create one
137
+ // when the trigger conditions hold. Same nudge-only model as session-start.
138
+ let suggestPrefix = '';
139
+ if (shouldSuggestProjectCreation(newCwd, HYPO_DIR)) {
140
+ const suggestLine = buildProjectSuggestionLine(newCwd);
141
+ suggestPrefix = `${suggestLine}\n\n`;
142
+ recordSuggestionCooldown(HYPO_DIR, newCwd);
143
+ process.stderr.write(`\n\x1b[33m${suggestLine}\x1b[0m\n`);
80
144
  }
81
145
 
82
- const globalContent = readFileSync(GLOBAL_HOT, 'utf-8').slice(0, MAX_CHARS);
83
- console.log(JSON.stringify(
84
- buildOutput(`[WIKI: cwd changed → no project match, injecting global hot]\n\n${globalContent}`, { continue: true, suppressOutput: true })
85
- ));
146
+ const globalContent = existsSync(GLOBAL_HOT)
147
+ ? readIfNotIgnored(GLOBAL_HOT, ignorePatterns)
148
+ : null;
86
149
 
150
+ if (!globalContent) {
151
+ if (suggestPrefix) {
152
+ console.log(
153
+ JSON.stringify(
154
+ buildOutput(suggestPrefix.trimEnd(), { continue: true, suppressOutput: true }),
155
+ ),
156
+ );
157
+ } else {
158
+ console.log(JSON.stringify({ continue: true, suppressOutput: true }));
159
+ }
160
+ return;
161
+ }
162
+ console.log(
163
+ JSON.stringify(
164
+ buildOutput(
165
+ `${suggestPrefix}[WIKI: cwd changed → no project match, injecting global hot]\n\n${globalContent}`,
166
+ { continue: true, suppressOutput: true },
167
+ ),
168
+ ),
169
+ );
87
170
  } catch (err) {
88
- process.stderr.write(`[wiki-cwd-change] error: ${err.message}\n`);
171
+ process.stderr.write(`[hypo-cwd-change] error: ${err?.message ?? String(err)}\n`);
89
172
  console.log(JSON.stringify({ continue: true, suppressOutput: true }));
90
173
  }
91
174
  });
@@ -8,17 +8,19 @@
8
8
 
9
9
  import { readFileSync, existsSync } from 'fs';
10
10
  import { join } from 'path';
11
- import { HYPO_DIR } from './hypo-shared.mjs';
11
+ import { HYPO_DIR, loadHypoIgnore, isIgnored } from './hypo-shared.mjs';
12
12
 
13
13
  const MAX_CHARS = 2000;
14
14
 
15
15
  let raw = '';
16
16
  process.stdin.setEncoding('utf-8');
17
- process.stdin.on('data', chunk => raw += chunk);
17
+ process.stdin.on('data', (chunk) => (raw += chunk));
18
18
  process.stdin.on('end', () => {
19
19
  try {
20
20
  let data = {};
21
- try { data = JSON.parse(raw); } catch {}
21
+ try {
22
+ data = JSON.parse(raw);
23
+ } catch {}
22
24
 
23
25
  const filePath = data.file_path || data.path || '';
24
26
 
@@ -27,6 +29,15 @@ process.stdin.on('end', () => {
27
29
  return;
28
30
  }
29
31
 
32
+ // Privacy guard: refuse to inject
33
+ // .hypoignore-matched paths. Without this, `.env*` or other secrets under
34
+ // HYPO_DIR are re-emitted as additionalContext to the Claude provider.
35
+ const patterns = loadHypoIgnore(HYPO_DIR);
36
+ if (patterns.length > 0 && isIgnored(filePath, HYPO_DIR, patterns)) {
37
+ console.log(JSON.stringify({ continue: true, suppressOutput: true }));
38
+ return;
39
+ }
40
+
30
41
  if (!existsSync(filePath)) {
31
42
  console.log(JSON.stringify({ continue: true, suppressOutput: true }));
32
43
  return;
@@ -35,13 +46,15 @@ process.stdin.on('end', () => {
35
46
  const content = readFileSync(filePath, 'utf-8').slice(0, MAX_CHARS);
36
47
  const relPath = filePath.replace(HYPO_DIR + '/', '');
37
48
 
38
- console.log(JSON.stringify({
39
- continue: true,
40
- suppressOutput: true,
41
- additionalContext: `[WIKI FILE UPDATED: ${relPath}]\n\n${content}`,
42
- }));
43
-
44
- } catch {
49
+ console.log(
50
+ JSON.stringify({
51
+ continue: true,
52
+ suppressOutput: true,
53
+ additionalContext: `[WIKI FILE UPDATED: ${relPath}]\n\n${content}`,
54
+ }),
55
+ );
56
+ } catch (err) {
57
+ process.stderr.write(`[hypo-file-watch] error: ${err?.message ?? String(err)}\n`);
45
58
  console.log(JSON.stringify({ continue: true, suppressOutput: true }));
46
59
  }
47
60
  });
@@ -2,31 +2,33 @@
2
2
  /**
3
3
  * hypo-first-prompt.mjs — UserPromptSubmit hook
4
4
  *
5
- * Consumes the marker written by wiki-session-start.mjs.
6
- * On the FIRST user prompt of a new session, injects a lightweight decision
7
- * instruction so the LLM can decide whether to announce the resume context.
5
+ * Consumes the marker written by hypo-session-start.mjs (source omitted /
6
+ * 'session-start') or hypo-cwd-change.mjs (source 'cwd-change', fix #13).
7
+ * On the FIRST user prompt after the marker is written, FORCES a one-line
8
+ * resume summary into the reply (fix #3 — the old "answer only if related"
9
+ * conditional is removed; the line is injected unconditionally).
8
10
  *
9
- * hot.md content is NOT re-injected here — wiki-session-start.mjs already
10
- * injected it on SessionStart. Only the decision hint is added.
11
+ * hot.md / session-state.md content is NOT re-injected here — the upstream
12
+ * hook already placed it in additionalContext. This hook only forces the LLM
13
+ * to lead with the summary line drawn from that context.
11
14
  * Marker expires after 10 minutes.
12
15
  */
13
16
 
14
17
  import { readFileSync, unlinkSync, existsSync } from 'fs';
15
- import { tmpdir } from 'os';
16
- import { join } from 'path';
17
- import { buildOutput } from './hypo-shared.mjs';
18
+ import { buildOutput, sessionMarkerPath } from './hypo-shared.mjs';
18
19
 
19
20
  const MARKER_TTL = 10 * 60 * 1000; // 10 min
20
21
 
21
22
  let raw = '';
22
23
  process.stdin.setEncoding('utf-8');
23
- process.stdin.on('data', chunk => raw += chunk);
24
+ process.stdin.on('data', (chunk) => (raw += chunk));
24
25
  process.stdin.on('end', () => {
25
26
  try {
26
27
  let data = {};
27
- try { data = JSON.parse(raw); } catch {}
28
- const sessionId = data.session_id || 'default';
29
- const MARKER_FILE = join(tmpdir(), `hypo-session-marker-${sessionId}.json`);
28
+ try {
29
+ data = JSON.parse(raw);
30
+ } catch {}
31
+ const MARKER_FILE = sessionMarkerPath(data.session_id);
30
32
 
31
33
  if (!existsSync(MARKER_FILE)) {
32
34
  console.log(JSON.stringify({ continue: true, suppressOutput: true }));
@@ -34,26 +36,38 @@ process.stdin.on('end', () => {
34
36
  }
35
37
 
36
38
  const marker = JSON.parse(readFileSync(MARKER_FILE, 'utf-8'));
37
- const age = Date.now() - (marker.ts || 0);
39
+ const age = Date.now() - (marker.ts || 0);
38
40
 
39
- try { unlinkSync(MARKER_FILE); } catch {}
41
+ try {
42
+ unlinkSync(MARKER_FILE);
43
+ } catch {}
40
44
 
41
45
  if (age > MARKER_TTL) {
42
46
  console.log(JSON.stringify({ continue: true, suppressOutput: true }));
43
47
  return;
44
48
  }
45
49
 
46
- const hasSnapshot = marker.hasSnapshot ?? (marker.hotPath && existsSync(marker.hotPath));
50
+ const hasSnapshot = marker.hasSnapshot ?? (marker.hotPath && existsSync(marker.hotPath));
47
51
  const snapshotNote = hasSnapshot ? '' : ' (no snapshot yet — first session)';
52
+ // fix #13: a cwd-change re-trigger says "Resuming"; a fresh session start
53
+ // (default source) says "Previously working on".
54
+ const verb = marker.source === 'cwd-change' ? 'Resuming' : 'Previously working on';
48
55
 
49
- console.log(JSON.stringify(
50
- buildOutput(
51
- `[WIKI SESSION START: project=${marker.proj}${snapshotNote}]\nDecision hint: if the first message relates to this project → answer first, then add one line "Previously working on ${marker.proj} — continue?" If unrelated / simple Q&A → answer only, no mention.`,
52
- { continue: true, suppressOutput: true }
53
- )
54
- ));
55
-
56
- } catch {
56
+ console.log(
57
+ JSON.stringify(
58
+ buildOutput(
59
+ `[WIKI SESSION START: project=${marker.proj}${snapshotNote}]\n` +
60
+ `Before addressing the user's message, lead your FIRST reply with exactly one line:\n` +
61
+ `"${verb} ${marker.proj}: <one-line summary>. Continue with <next task>?"\n` +
62
+ `Draw <one-line summary> and <next task> from the [HOT] / [SESSION STATE] ` +
63
+ `context already injected this session. Inject this line unconditionally — ` +
64
+ `even if the user's first message is unrelated or a simple question — then answer normally.`,
65
+ { continue: true, suppressOutput: true },
66
+ ),
67
+ ),
68
+ );
69
+ } catch (err) {
70
+ process.stderr.write(`[hypo-first-prompt] error: ${err?.message ?? String(err)}\n`);
57
71
  console.log(JSON.stringify({ continue: true, suppressOutput: true }));
58
72
  }
59
73
  });
@@ -10,11 +10,12 @@
10
10
  * This script manages: frontmatter, H2 structure, date fields.
11
11
  */
12
12
 
13
- import { readFileSync, writeFileSync, existsSync } from 'fs';
13
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
14
14
  import { join } from 'path';
15
- import { HYPO_DIR } from './hypo-shared.mjs';
15
+ import { HYPO_DIR, computeSessionGrowth, formatGrowthMetrics } from './hypo-shared.mjs';
16
16
 
17
17
  const HOT_PATH = join(HYPO_DIR, 'hot.md');
18
+ const GROWTH_CACHE = join(HYPO_DIR, '.cache', 'last-session-growth.json');
18
19
 
19
20
  function parseFrontmatter(content) {
20
21
  const m = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
@@ -55,10 +56,12 @@ function rebuild() {
55
56
 
56
57
  const today = new Date().toISOString().slice(0, 10);
57
58
 
58
- const tableRows = rows.map(({ name, slug }) => {
59
- const date = getProjectDate(slug) || today;
60
- return `| ${name} | ${date} | [[projects/${slug}/hot]] |`;
61
- }).join('\n');
59
+ const tableRows = rows
60
+ .map(({ name, slug }) => {
61
+ const date = getProjectDate(slug) || today;
62
+ return `| ${name} | ${date} | [[projects/${slug}/hot]] |`;
63
+ })
64
+ .join('\n');
62
65
 
63
66
  const canonical = `---
64
67
  title: Hot Cache — Pointer
@@ -88,8 +91,28 @@ ${tableRows}
88
91
  if (canonical !== current) writeFileSync(HOT_PATH, canonical);
89
92
  }
90
93
 
94
+ function emitGrowth() {
95
+ if (!existsSync(HYPO_DIR)) return;
96
+ const stats = computeSessionGrowth(HYPO_DIR);
97
+ const line = formatGrowthMetrics('stop', stats);
98
+ if (line) process.stderr.write(`${line}\n`);
99
+ try {
100
+ mkdirSync(join(HYPO_DIR, '.cache'), { recursive: true });
101
+ writeFileSync(GROWTH_CACHE, JSON.stringify({ ...stats, ts: Date.now() }));
102
+ } catch {}
103
+ }
104
+
91
105
  try {
92
106
  rebuild();
93
- } catch {}
107
+ } catch (err) {
108
+ process.stderr.write(`[hypo-hot-rebuild] error: ${err?.message ?? String(err)}\n`);
109
+ }
110
+ try {
111
+ emitGrowth();
112
+ } catch (err) {
113
+ process.stderr.write(`[hypo-hot-rebuild] error: ${err?.message ?? String(err)}\n`);
114
+ }
94
115
 
95
- try { console.log(JSON.stringify({ continue: true, suppressOutput: true })); } catch {}
116
+ try {
117
+ console.log(JSON.stringify({ continue: true, suppressOutput: true }));
118
+ } catch {}