hypomnema 1.0.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.
- package/.claude-plugin/plugin.json +11 -0
- package/LICENSE +21 -0
- package/README.ko.md +160 -0
- package/README.md +160 -0
- package/commands/.gitkeep +0 -0
- package/commands/crystallize.md +116 -0
- package/commands/doctor.md +66 -0
- package/commands/feedback.md +67 -0
- package/commands/graph.md +54 -0
- package/commands/ingest.md +85 -0
- package/commands/init.md +101 -0
- package/commands/lint.md +55 -0
- package/commands/query.md +55 -0
- package/commands/resume.md +48 -0
- package/commands/stats.md +39 -0
- package/commands/uninstall.md +52 -0
- package/commands/upgrade.md +63 -0
- package/commands/verify.md +60 -0
- package/docs/.gitkeep +0 -0
- package/docs/ARCHITECTURE.md +183 -0
- package/docs/CONTRIBUTING.md +115 -0
- package/docs/TEST-CASES.md +580 -0
- package/hooks/.gitkeep +0 -0
- package/hooks/hooks.json +109 -0
- package/hooks/hypo-auto-commit.mjs +36 -0
- package/hooks/hypo-auto-stage.mjs +30 -0
- package/hooks/hypo-compact-guard.mjs +71 -0
- package/hooks/hypo-cwd-change.mjs +91 -0
- package/hooks/hypo-file-watch.mjs +47 -0
- package/hooks/hypo-first-prompt.mjs +59 -0
- package/hooks/hypo-hot-rebuild.mjs +95 -0
- package/hooks/hypo-lookup.mjs +178 -0
- package/hooks/hypo-personal-check.mjs +195 -0
- package/hooks/hypo-session-start.mjs +141 -0
- package/hooks/hypo-shared.mjs +213 -0
- package/package.json +37 -0
- package/scripts/.gitkeep +0 -0
- package/scripts/bump-version.mjs +53 -0
- package/scripts/crystallize.mjs +153 -0
- package/scripts/doctor.mjs +361 -0
- package/scripts/feedback.mjs +130 -0
- package/scripts/graph.mjs +183 -0
- package/scripts/ingest.mjs +130 -0
- package/scripts/init.mjs +515 -0
- package/scripts/lib/frontmatter.mjs +11 -0
- package/scripts/lib/hypo-ignore.mjs +54 -0
- package/scripts/lib/hypo-root.mjs +53 -0
- package/scripts/lint.mjs +210 -0
- package/scripts/query.mjs +124 -0
- package/scripts/resume.mjs +115 -0
- package/scripts/stats.mjs +132 -0
- package/scripts/uninstall.mjs +188 -0
- package/scripts/upgrade.mjs +538 -0
- package/scripts/verify.mjs +172 -0
- package/skills/.gitkeep +0 -0
- package/skills/crystallize/SKILL.md +85 -0
- package/skills/graph/SKILL.md +54 -0
- package/skills/ingest/SKILL.md +83 -0
- package/skills/lint/SKILL.md +55 -0
- package/skills/query/SKILL.md +58 -0
- package/skills/verify/SKILL.md +92 -0
- package/templates/.gitkeep +0 -0
- package/templates/.hypoignore +18 -0
- package/templates/Home.md +34 -0
- package/templates/Overview.md +50 -0
- package/templates/SCHEMA.md +106 -0
- package/templates/hot.md +22 -0
- package/templates/hypo-automation.md +69 -0
- package/templates/hypo-config.md +41 -0
- package/templates/hypo-guide.md +146 -0
- package/templates/hypo-help.md +53 -0
- package/templates/index.md +44 -0
- package/templates/log.md +25 -0
- package/templates/pages/_index.md +61 -0
- package/templates/projects/_template/hot.md +28 -0
- package/templates/projects/_template/index.md +39 -0
- package/templates/projects/_template/prd.md +29 -0
- package/templates/projects/_template/session-state.md +9 -0
- package/templates/session-state.md +12 -0
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* hypo-personal-check.mjs — PreCompact hook
|
|
4
|
+
*
|
|
5
|
+
* Hard gate before /compact. Blocks if:
|
|
6
|
+
* - last substantial wiki op is not a session close
|
|
7
|
+
* - wiki git repo has uncommitted/unpushed changes
|
|
8
|
+
* - hot.md has forbidden structure
|
|
9
|
+
* - lint blockers exist
|
|
10
|
+
*
|
|
11
|
+
* Bypass options (checked in order, short-circuits before heavy checks):
|
|
12
|
+
* 1. wiki-context-critical.json exists (context ≥ 90% — hard limit imminent)
|
|
13
|
+
* 2. HYPO_SKIP_GATE=1 env var
|
|
14
|
+
* 3. HYPO_SKIP_GATE=1 in a recent *user-role* transcript message
|
|
15
|
+
* (assistant/tool output is excluded to prevent self-triggering from block reason text)
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { spawnSync } from 'child_process';
|
|
19
|
+
import { join } from 'path';
|
|
20
|
+
import { readFileSync, existsSync, unlinkSync } from 'fs';
|
|
21
|
+
import { homedir } from 'os';
|
|
22
|
+
import {
|
|
23
|
+
HYPO_DIR,
|
|
24
|
+
PKG_ROOT,
|
|
25
|
+
lastSubstantialOpIsSession,
|
|
26
|
+
hypoIsClean,
|
|
27
|
+
hotMdIsClean,
|
|
28
|
+
readChecklist,
|
|
29
|
+
isGateSkipped,
|
|
30
|
+
} from './hypo-shared.mjs';
|
|
31
|
+
|
|
32
|
+
const CRITICAL_FILE = join(homedir(), '.claude', 'state', 'wiki-context-critical.json');
|
|
33
|
+
const WARNING_FILE = join(homedir(), '.claude', 'state', 'wiki-context-warning.json');
|
|
34
|
+
|
|
35
|
+
/** Parse JSONL transcript and return concatenated text of user-role messages only.
|
|
36
|
+
*
|
|
37
|
+
* Claude Code transcript format: each line is `{ type: "user", message: { role: "user", content: ... } }`.
|
|
38
|
+
* Older shape (`{ role, content }` at top level) is also accepted for forward compatibility.
|
|
39
|
+
*/
|
|
40
|
+
function extractUserMessages(transcriptPath) {
|
|
41
|
+
try {
|
|
42
|
+
const lines = readFileSync(transcriptPath, 'utf-8').split('\n');
|
|
43
|
+
const tail = lines.slice(-30); // last 30 lines is enough
|
|
44
|
+
return tail.map(line => {
|
|
45
|
+
try {
|
|
46
|
+
const obj = JSON.parse(line);
|
|
47
|
+
const msg = obj.message ?? obj;
|
|
48
|
+
const role = msg.role ?? obj.role ?? obj.type;
|
|
49
|
+
if (role !== 'user') return '';
|
|
50
|
+
const content = msg.content ?? obj.content;
|
|
51
|
+
return typeof content === 'string' ? content : JSON.stringify(content);
|
|
52
|
+
} catch { return ''; }
|
|
53
|
+
}).join('\n');
|
|
54
|
+
} catch { return ''; }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
let raw = '';
|
|
58
|
+
process.stdin.setEncoding('utf-8');
|
|
59
|
+
process.stdin.on('data', chunk => raw += chunk);
|
|
60
|
+
process.stdin.on('end', () => {
|
|
61
|
+
let transcriptPath = null;
|
|
62
|
+
try {
|
|
63
|
+
const input = JSON.parse(raw || '{}');
|
|
64
|
+
transcriptPath = input.transcript_path ?? null;
|
|
65
|
+
} catch { /* fail-open */ }
|
|
66
|
+
|
|
67
|
+
// ── Bypass 1: context critical (≥90%) — short-circuit BEFORE all checks ──
|
|
68
|
+
if (existsSync(CRITICAL_FILE)) {
|
|
69
|
+
try { unlinkSync(CRITICAL_FILE); } catch {}
|
|
70
|
+
console.log(JSON.stringify({
|
|
71
|
+
continue: true,
|
|
72
|
+
systemMessage: '[WIKI CHECK] gate auto-bypassed (context ≥90% critical). Session close pending next session.',
|
|
73
|
+
}));
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ── Block 1.5: context warning (≥70%) — request session-compact before compact ──
|
|
78
|
+
if (existsSync(WARNING_FILE)) {
|
|
79
|
+
try { unlinkSync(WARNING_FILE); } catch {}
|
|
80
|
+
console.log(JSON.stringify({
|
|
81
|
+
decision: 'block',
|
|
82
|
+
reason: [
|
|
83
|
+
`[WIKI CHECK — BLOCKING] Context ≥70%: run /session-compact before compacting.`,
|
|
84
|
+
`STOP. Do NOT compact yet.`,
|
|
85
|
+
`1. If Skill tool is available: call it with skill="session-compact" immediately.`,
|
|
86
|
+
`2. If Skill tool is unavailable: perform the full session-close checklist from hypo-guide.md.`,
|
|
87
|
+
`After session close completes, compact will proceed normally.`,
|
|
88
|
+
``,
|
|
89
|
+
`To skip: set HYPO_SKIP_GATE=1`,
|
|
90
|
+
].join('\n'),
|
|
91
|
+
}));
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ── Bypass 2: env var ──
|
|
96
|
+
if (!isGateSkipped() && transcriptPath && existsSync(transcriptPath)) {
|
|
97
|
+
// Only scan user-role messages to avoid matching the block reason text
|
|
98
|
+
// which itself contains "Bypass with HYPO_SKIP_GATE=1"
|
|
99
|
+
const userText = extractUserMessages(transcriptPath);
|
|
100
|
+
if (/HYPO_SKIP_GATE=1/.test(userText)) {
|
|
101
|
+
process.env.HYPO_SKIP_GATE = '1';
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ── Heavy checks ──
|
|
106
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
107
|
+
|
|
108
|
+
const hasSession = lastSubstantialOpIsSession();
|
|
109
|
+
const gitStatus = hypoIsClean();
|
|
110
|
+
const hotStatus = hotMdIsClean();
|
|
111
|
+
|
|
112
|
+
const lintPath = PKG_ROOT ? join(PKG_ROOT, 'scripts', 'lint.mjs') : null;
|
|
113
|
+
let lintBlockers = [];
|
|
114
|
+
let lintW8 = [];
|
|
115
|
+
let lintSkipped = false;
|
|
116
|
+
if (!lintPath || !existsSync(lintPath)) {
|
|
117
|
+
lintSkipped = true;
|
|
118
|
+
} else {
|
|
119
|
+
try {
|
|
120
|
+
const r = spawnSync('node', [lintPath, '--json'], {
|
|
121
|
+
encoding: 'utf-8',
|
|
122
|
+
cwd: HYPO_DIR,
|
|
123
|
+
timeout: 30000,
|
|
124
|
+
});
|
|
125
|
+
const parsed = JSON.parse(r.stdout || '{}');
|
|
126
|
+
lintBlockers = parsed.errors || [];
|
|
127
|
+
lintW8 = (parsed.warns || []).filter(w => w.id === 'W8');
|
|
128
|
+
} catch { /* fail-open */ }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const lintOk = lintBlockers.length === 0;
|
|
132
|
+
const designHistoryOk = lintW8.length === 0;
|
|
133
|
+
|
|
134
|
+
if (hasSession && gitStatus.clean && hotStatus.clean && lintOk && designHistoryOk) {
|
|
135
|
+
console.log(JSON.stringify({ continue: true, suppressOutput: true }));
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ── Bypass 3: HYPO_SKIP_GATE ──
|
|
140
|
+
if (isGateSkipped()) {
|
|
141
|
+
const skipped = [
|
|
142
|
+
!hasSession ? 'session log missing' : '',
|
|
143
|
+
!gitStatus.clean ? gitStatus.reason : '',
|
|
144
|
+
!hotStatus.clean ? hotStatus.reason : '',
|
|
145
|
+
!designHistoryOk ? `design-history stale (${lintW8.length})` : '',
|
|
146
|
+
lintSkipped ? 'lint skipped (hypo-pkg.json missing)' : '',
|
|
147
|
+
].filter(Boolean).join(', ');
|
|
148
|
+
console.log(JSON.stringify({
|
|
149
|
+
continue: true,
|
|
150
|
+
systemMessage: `[WIKI CHECK] gate bypassed via HYPO_SKIP_GATE=1 (incomplete: ${skipped}).`,
|
|
151
|
+
}));
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ── Block ──
|
|
156
|
+
const reasons = [
|
|
157
|
+
!hasSession ? 'session log entry missing' : '',
|
|
158
|
+
!gitStatus.clean ? gitStatus.reason : '',
|
|
159
|
+
!hotStatus.clean ? hotStatus.reason : '',
|
|
160
|
+
!lintOk ? `lint blockers: ${lintBlockers.map(b => b.id).join(', ')}` : '',
|
|
161
|
+
!designHistoryOk ? `design-history stale: ${lintW8.map(w => w.file.split('/')[1]).join(', ')}` : '',
|
|
162
|
+
lintSkipped ? 'lint skipped (run `hypomnema init` to enable lint gate)' : '',
|
|
163
|
+
].filter(Boolean);
|
|
164
|
+
|
|
165
|
+
const checklist = readChecklist(today);
|
|
166
|
+
const checklistText = checklist ?? [
|
|
167
|
+
` [ ] 0. Read SCHEMA.md + hypo-guide.md (required before wiki work)`,
|
|
168
|
+
` [ ] 1. PRD — create projects/<name>/prd.md if missing`,
|
|
169
|
+
` [ ] 2. ADR — decide yes/no on 5 types; if all N, note "no ADR — reason: <why>"`,
|
|
170
|
+
` [ ] 3. Ingest — if new external knowledge, save to sources/ and ingest`,
|
|
171
|
+
` [ ] 4. Pages — extract new concepts/patterns to pages/`,
|
|
172
|
+
` [ ] 5. Synthesis — if 3+ cross-page analysis results, save to pages/syntheses/`,
|
|
173
|
+
` [ ] 6. session-log — append to projects/<name>/session-log/YYYY-MM.md`,
|
|
174
|
+
` [ ] 7. index.md — update Projects section if needed`,
|
|
175
|
+
` [ ] 8. log.md — append ## [${today}] session | <project-name>`,
|
|
176
|
+
` [ ] 9. hot.md — update projects/<name>/hot.md (no exceptions)`,
|
|
177
|
+
` [ ] 10. root hot.md — update ~/hypomnema/hot.md active project table`,
|
|
178
|
+
` [ ] 11. updated: field — verify today's date on all touched .md files`,
|
|
179
|
+
` [ ] 12. git commit & push`,
|
|
180
|
+
].join('\n');
|
|
181
|
+
|
|
182
|
+
console.log(JSON.stringify({
|
|
183
|
+
decision: 'block',
|
|
184
|
+
reason: [
|
|
185
|
+
`[WIKI CHECK — BLOCKING] Session close incomplete. (${reasons.join(', ')})`,
|
|
186
|
+
`Run the checklist below in order, then retry /compact:`,
|
|
187
|
+
``,
|
|
188
|
+
checklistText,
|
|
189
|
+
``,
|
|
190
|
+
`Trivial session? Bypass with HYPO_SKIP_GATE=1`,
|
|
191
|
+
].join('\n'),
|
|
192
|
+
continue: false,
|
|
193
|
+
stopReason: `Session close incomplete: ${reasons.join(', ')}`,
|
|
194
|
+
}));
|
|
195
|
+
});
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* hypo-session-start.mjs — SessionStart hook
|
|
4
|
+
*
|
|
5
|
+
* On session start:
|
|
6
|
+
* HIT → cwd matches a project's working_dir → inject hot.md (2000 chars) + session-state.md (2000 chars)
|
|
7
|
+
* MISS → inject global hot.md pointer only (no fan-out to all projects)
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { readFileSync, writeFileSync, existsSync, readdirSync, statSync } from 'fs';
|
|
11
|
+
import { homedir, tmpdir } from 'os';
|
|
12
|
+
import { join } from 'path';
|
|
13
|
+
import { spawnSync } from 'child_process';
|
|
14
|
+
import { HYPO_DIR, buildOutput, SESSION_STATE_NEXT_HEADINGS } from './hypo-shared.mjs';
|
|
15
|
+
|
|
16
|
+
const PROJECTS_DIR = join(HYPO_DIR, 'projects');
|
|
17
|
+
|
|
18
|
+
function gitPull(dir) {
|
|
19
|
+
if (!existsSync(join(dir, '.git'))) return;
|
|
20
|
+
spawnSync('git', ['-C', dir, 'pull', '--ff-only', '--quiet'], { stdio: 'pipe', timeout: 10000 });
|
|
21
|
+
}
|
|
22
|
+
const GLOBAL_HOT = join(HYPO_DIR, 'hot.md');
|
|
23
|
+
const HOT_CHARS = 2000;
|
|
24
|
+
const STATE_CHARS = 2000;
|
|
25
|
+
|
|
26
|
+
function parseFrontmatterField(content, key) {
|
|
27
|
+
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
28
|
+
if (!match) return null;
|
|
29
|
+
const line = match[1].split('\n').find(l => l.startsWith(`${key}:`));
|
|
30
|
+
if (!line) return null;
|
|
31
|
+
return line.slice(key.length + 1).trim().replace(/^['"]|['"]$/g, '');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function findProjectFiles(cwd) {
|
|
35
|
+
if (!existsSync(PROJECTS_DIR)) return null;
|
|
36
|
+
for (const proj of readdirSync(PROJECTS_DIR)) {
|
|
37
|
+
const projDir = join(PROJECTS_DIR, proj);
|
|
38
|
+
if (!statSync(projDir).isDirectory()) continue;
|
|
39
|
+
const indexPath = join(projDir, 'index.md');
|
|
40
|
+
if (!existsSync(indexPath)) continue;
|
|
41
|
+
const content = readFileSync(indexPath, 'utf-8');
|
|
42
|
+
const workingDir = parseFrontmatterField(content, 'working_dir');
|
|
43
|
+
if (!workingDir) continue;
|
|
44
|
+
const resolved = workingDir.startsWith('~/')
|
|
45
|
+
? join(homedir(), workingDir.slice(2))
|
|
46
|
+
: workingDir;
|
|
47
|
+
if (cwd === resolved || cwd.startsWith(resolved + '/')) {
|
|
48
|
+
const hotPath = join(projDir, 'hot.md');
|
|
49
|
+
const statePath = join(projDir, 'session-state.md');
|
|
50
|
+
return {
|
|
51
|
+
proj,
|
|
52
|
+
hotPath: existsSync(hotPath) ? hotPath : null,
|
|
53
|
+
statePath: existsSync(statePath) ? statePath : null,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function extractSection(content, heading) {
|
|
61
|
+
const headings = Array.isArray(heading) ? heading : [heading];
|
|
62
|
+
for (const h of headings) {
|
|
63
|
+
const re = new RegExp(`## ${h}\\r?\\n([\\s\\S]*?)(?=\\r?\\n## |$)`);
|
|
64
|
+
const m = content.match(re);
|
|
65
|
+
if (m) return m[1].trim();
|
|
66
|
+
}
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function printTerminalSummary(proj, hotContent, stateContent) {
|
|
71
|
+
const nextFromState = stateContent
|
|
72
|
+
? extractSection(stateContent, SESSION_STATE_NEXT_HEADINGS)
|
|
73
|
+
: null;
|
|
74
|
+
const next = nextFromState
|
|
75
|
+
?? extractSection(hotContent ?? '', SESSION_STATE_NEXT_HEADINGS);
|
|
76
|
+
const prev = hotContent
|
|
77
|
+
? (extractSection(hotContent, '직전 세션 \\([^)]+\\)')
|
|
78
|
+
?? extractSection(hotContent, '직전 세션.*')
|
|
79
|
+
?? extractSection(hotContent, 'Last Session.*'))
|
|
80
|
+
: null;
|
|
81
|
+
const lines = ['', `\x1b[36m[Hypomnema]\x1b[0m project: \x1b[1m${proj}\x1b[0m`];
|
|
82
|
+
if (prev) lines.push(` prev: ${prev.split('\n')[0].replace(/^\*\*|\*\*$/g, '')}`);
|
|
83
|
+
if (next) {
|
|
84
|
+
lines.push(' next:');
|
|
85
|
+
next.split('\n').slice(0, 20).forEach(l => lines.push(` ${l}`));
|
|
86
|
+
}
|
|
87
|
+
lines.push('');
|
|
88
|
+
process.stderr.write(lines.join('\n'));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
let raw = '';
|
|
92
|
+
process.stdin.setEncoding('utf-8');
|
|
93
|
+
process.stdin.on('data', chunk => raw += chunk);
|
|
94
|
+
process.stdin.on('end', () => {
|
|
95
|
+
try {
|
|
96
|
+
let data = {};
|
|
97
|
+
try { data = JSON.parse(raw); } catch {}
|
|
98
|
+
|
|
99
|
+
gitPull(HYPO_DIR);
|
|
100
|
+
const cwd = data.cwd || data.directory || process.cwd();
|
|
101
|
+
const sessionId = data.session_id || 'default';
|
|
102
|
+
const MARKER_FILE = join(tmpdir(), `hypo-session-marker-${sessionId}.json`);
|
|
103
|
+
const hit = findProjectFiles(cwd);
|
|
104
|
+
|
|
105
|
+
if (hit) {
|
|
106
|
+
const hotContent = hit.hotPath ? readFileSync(hit.hotPath, 'utf-8').slice(0, HOT_CHARS) : null;
|
|
107
|
+
const stateContent = hit.statePath ? readFileSync(hit.statePath, 'utf-8').slice(0, STATE_CHARS) : null;
|
|
108
|
+
|
|
109
|
+
if (hotContent || stateContent) {
|
|
110
|
+
printTerminalSummary(hit.proj, hotContent, stateContent);
|
|
111
|
+
writeFileSync(MARKER_FILE, JSON.stringify({ proj: hit.proj, hotPath: hit.hotPath, statePath: hit.statePath, hasSnapshot: true, ts: Date.now() }));
|
|
112
|
+
const parts = [];
|
|
113
|
+
if (hotContent) parts.push(`[HOT]\n${hotContent}`);
|
|
114
|
+
if (stateContent) parts.push(`[SESSION STATE — 다음 작업]\n${stateContent}`);
|
|
115
|
+
console.log(JSON.stringify(
|
|
116
|
+
buildOutput(`[WIKI HOT CACHE: project=${hit.proj}]\n\n${parts.join('\n\n')}`, { continue: true, suppressOutput: true })
|
|
117
|
+
));
|
|
118
|
+
} else {
|
|
119
|
+
process.stderr.write(`\n\x1b[36m[Hypomnema]\x1b[0m project: \x1b[1m${hit.proj}\x1b[0m (no snapshot yet)\n\n`);
|
|
120
|
+
writeFileSync(MARKER_FILE, JSON.stringify({ proj: hit.proj, hotPath: null, ts: Date.now() }));
|
|
121
|
+
console.log(JSON.stringify(
|
|
122
|
+
buildOutput(`[WIKI HOT CACHE: project=${hit.proj}, no snapshot yet]`, { continue: true, suppressOutput: true })
|
|
123
|
+
));
|
|
124
|
+
}
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (!existsSync(GLOBAL_HOT)) {
|
|
129
|
+
console.log(JSON.stringify({ continue: true, suppressOutput: true }));
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const globalContent = readFileSync(GLOBAL_HOT, 'utf-8').slice(0, HOT_CHARS);
|
|
134
|
+
console.log(JSON.stringify(
|
|
135
|
+
buildOutput(`[WIKI HOT CACHE: global — no project matched cwd=${cwd}]\n\n${globalContent}`, { continue: true, suppressOutput: true })
|
|
136
|
+
));
|
|
137
|
+
|
|
138
|
+
} catch {
|
|
139
|
+
console.log(JSON.stringify({ continue: true, suppressOutput: true }));
|
|
140
|
+
}
|
|
141
|
+
});
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* hypo-shared.mjs — shared utilities for Hypomnema hooks
|
|
4
|
+
*
|
|
5
|
+
* Imported by personal-wiki-check.mjs, wiki-compact-guard.mjs, and others.
|
|
6
|
+
* Hooks are deployed to ~/.claude/hooks/ — no external imports allowed.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { readFileSync, existsSync } from 'fs';
|
|
10
|
+
import { join, relative, basename } from 'path';
|
|
11
|
+
import { homedir } from 'os';
|
|
12
|
+
import { spawnSync } from 'child_process';
|
|
13
|
+
|
|
14
|
+
const HOME = homedir();
|
|
15
|
+
|
|
16
|
+
// ── wiki root resolution ────────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
function expandHome(p) {
|
|
19
|
+
if (p === '~') return HOME;
|
|
20
|
+
if (p.startsWith('~/') || p.startsWith('~\\')) return join(HOME, p.slice(2));
|
|
21
|
+
return p;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Resolve Hypomnema root: HYPO_DIR env → hypo-config.md scan → ~/hypomnema default.
|
|
26
|
+
* @returns {string}
|
|
27
|
+
*/
|
|
28
|
+
function resolveHypoRoot() {
|
|
29
|
+
if (process.env.HYPO_DIR) return expandHome(process.env.HYPO_DIR);
|
|
30
|
+
|
|
31
|
+
const candidates = [
|
|
32
|
+
join(HOME, 'hypomnema'),
|
|
33
|
+
join(HOME, 'wiki'),
|
|
34
|
+
join(HOME, 'notes'),
|
|
35
|
+
join(HOME, 'knowledge'),
|
|
36
|
+
join(HOME, 'Documents', 'hypomnema'),
|
|
37
|
+
join(HOME, 'Documents', 'wiki'),
|
|
38
|
+
];
|
|
39
|
+
for (const c of candidates) {
|
|
40
|
+
if (existsSync(join(c, 'hypo-config.md'))) return c;
|
|
41
|
+
}
|
|
42
|
+
return join(HOME, 'hypomnema');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export const HYPO_DIR = resolveHypoRoot();
|
|
46
|
+
export const LOG_PATH = join(HYPO_DIR, 'log.md');
|
|
47
|
+
export const HOT_PATH = join(HYPO_DIR, 'hot.md');
|
|
48
|
+
export const GUIDE_PATH = join(HYPO_DIR, 'hypo-guide.md');
|
|
49
|
+
|
|
50
|
+
// Package root: written by init/upgrade to ~/.claude/hypo-pkg.json
|
|
51
|
+
function resolvePkgRoot() {
|
|
52
|
+
const p = join(HOME, '.claude', 'hypo-pkg.json');
|
|
53
|
+
if (!existsSync(p)) return null;
|
|
54
|
+
try { const v = JSON.parse(readFileSync(p, 'utf-8')).pkgRoot; return typeof v === 'string' && v ? v : null; } catch { return null; }
|
|
55
|
+
}
|
|
56
|
+
export const PKG_ROOT = resolvePkgRoot();
|
|
57
|
+
|
|
58
|
+
// Optional H2 allowlist for hot.md validation.
|
|
59
|
+
// Set HYPO_ALLOWED_HOT_H2=comma,separated,headings to enable.
|
|
60
|
+
const _allowedH2Env = process.env.HYPO_ALLOWED_HOT_H2;
|
|
61
|
+
export const ALLOWED_HOT_H2 = _allowedH2Env
|
|
62
|
+
? new Set(_allowedH2Env.split(',').map(s => s.trim()))
|
|
63
|
+
: null;
|
|
64
|
+
|
|
65
|
+
// ── skip-gate helper ───────────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
/** Returns true if the wiki gate should be bypassed. */
|
|
68
|
+
export function isGateSkipped() {
|
|
69
|
+
return process.env.HYPO_SKIP_GATE === '1';
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ── state checkers ─────────────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
export function lastSubstantialOpIsSession() {
|
|
75
|
+
if (!existsSync(LOG_PATH)) return true;
|
|
76
|
+
const log = readFileSync(LOG_PATH, 'utf-8');
|
|
77
|
+
const substantial = log.split('\n')
|
|
78
|
+
.filter(l => /^## \[\d{4}-\d{2}-\d{2}\] (session|ingest)/.test(l));
|
|
79
|
+
if (substantial.length === 0) return true;
|
|
80
|
+
return /^## \[\d{4}-\d{2}-\d{2}\] session/.test(substantial[substantial.length - 1]);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function hypoIsClean() {
|
|
84
|
+
try {
|
|
85
|
+
const porcelain = spawnSync('git', ['-C', HYPO_DIR, 'status', '--porcelain'], { encoding: 'utf-8' });
|
|
86
|
+
if (porcelain.status !== 0) return { clean: false, reason: `git check failed in ${HYPO_DIR}` };
|
|
87
|
+
if (porcelain.stdout.trim() !== '') return { clean: false, reason: `uncommitted changes in ${HYPO_DIR}` };
|
|
88
|
+
const ahead = spawnSync('git', ['-C', HYPO_DIR, 'status', '--branch', '--porcelain'], { encoding: 'utf-8' });
|
|
89
|
+
if (/\[ahead \d+\]/.test(ahead.stdout || '')) return { clean: false, reason: `unpushed commits in ${HYPO_DIR}` };
|
|
90
|
+
return { clean: true };
|
|
91
|
+
} catch {
|
|
92
|
+
return { clean: false, reason: `git check failed in ${HYPO_DIR}` };
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function hotMdIsClean() {
|
|
97
|
+
if (!existsSync(HOT_PATH)) return { clean: true };
|
|
98
|
+
const content = readFileSync(HOT_PATH, 'utf-8');
|
|
99
|
+
const reasons = [];
|
|
100
|
+
|
|
101
|
+
// Optional: check H2 allowlist if HYPO_ALLOWED_HOT_H2 is set
|
|
102
|
+
if (ALLOWED_HOT_H2) {
|
|
103
|
+
const h2s = [...content.matchAll(/^## (.+)$/gm)].map(m => m[1].trim());
|
|
104
|
+
const extra = h2s.filter(h => !ALLOWED_HOT_H2.has(h));
|
|
105
|
+
if (extra.length > 0) reasons.push(`hot.md has unexpected H2 sections: ${extra.join(', ')}`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Always check for forbidden frontmatter fields
|
|
109
|
+
const fmMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
110
|
+
if (fmMatch && /^last_session:/m.test(fmMatch[1])) {
|
|
111
|
+
reasons.push('hot.md frontmatter has forbidden field: last_session');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return reasons.length === 0 ? { clean: true } : { clean: false, reason: reasons.join(' / ') };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ── session-close checklist ────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Read the session-close checklist from hypo-guide.md.
|
|
121
|
+
* Falls back to null if the guide is unavailable or the section can't be parsed.
|
|
122
|
+
*/
|
|
123
|
+
export function readChecklist(today) {
|
|
124
|
+
if (!existsSync(GUIDE_PATH)) return null;
|
|
125
|
+
try {
|
|
126
|
+
const lines = readFileSync(GUIDE_PATH, 'utf-8').split('\n');
|
|
127
|
+
let collecting = false;
|
|
128
|
+
const result = [];
|
|
129
|
+
for (const line of lines) {
|
|
130
|
+
if (!collecting && /^\[ \] 0\./.test(line.trim())) collecting = true;
|
|
131
|
+
if (collecting) {
|
|
132
|
+
if (/^─+$/.test(line.trim()) || line.trim() === '```') break;
|
|
133
|
+
result.push(line);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
if (result.length === 0) return null;
|
|
137
|
+
return result.join('\n').replace(/YYYY-MM-DD/g, today);
|
|
138
|
+
} catch {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ── session-state schema ───────────────────────────────────────────────────
|
|
144
|
+
|
|
145
|
+
/** Accepted heading aliases for the "next task" section in session-state.md. */
|
|
146
|
+
export const SESSION_STATE_NEXT_HEADINGS = ['다음 이어받기', '다음 작업', 'Next Up', 'Next'];
|
|
147
|
+
|
|
148
|
+
// ── misc helpers ───────────────────────────────────────────────────────────
|
|
149
|
+
|
|
150
|
+
/** Returns true if the prompt is a /compact command invocation. */
|
|
151
|
+
export function isCompactCommand(prompt) {
|
|
152
|
+
return prompt === '/compact' || /^\/compact(\s|$)/.test(prompt);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Build hook output for Claude Code (additionalContext channel).
|
|
157
|
+
* Codex hooks write systemMessage directly in their own files.
|
|
158
|
+
*/
|
|
159
|
+
export function buildOutput(context, extra = {}) {
|
|
160
|
+
return { ...extra, additionalContext: context };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ── .hypoignore support ────────────────────────────────────────────────────
|
|
164
|
+
// Inlined here so deployed hooks (~/.claude/hooks/) don't need scripts/lib/.
|
|
165
|
+
|
|
166
|
+
function _globToRegex(glob) {
|
|
167
|
+
return new RegExp('^' +
|
|
168
|
+
glob
|
|
169
|
+
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
170
|
+
.replace(/\*\*/g, '\x00')
|
|
171
|
+
.replace(/\*/g, '[^/]*')
|
|
172
|
+
.replace(/\?/g, '[^/]')
|
|
173
|
+
.replace(/\x00/g, '.*')
|
|
174
|
+
+ '$');
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function loadHypoIgnore(hypoDir) {
|
|
178
|
+
const ignorePath = join(hypoDir, '.hypoignore');
|
|
179
|
+
if (!existsSync(ignorePath)) return [];
|
|
180
|
+
return readFileSync(ignorePath, 'utf-8')
|
|
181
|
+
.split('\n')
|
|
182
|
+
.map(l => l.trim())
|
|
183
|
+
.filter(l => l && !l.startsWith('#'));
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function isIgnored(filePath, hypoDir, patterns) {
|
|
187
|
+
const rel = relative(hypoDir, filePath).replace(/\\/g, '/');
|
|
188
|
+
const base = basename(filePath);
|
|
189
|
+
for (const pattern of patterns) {
|
|
190
|
+
const isDir = pattern.endsWith('/');
|
|
191
|
+
if (isDir) {
|
|
192
|
+
const dir = pattern.slice(0, -1);
|
|
193
|
+
const isAnchored = dir.includes('/');
|
|
194
|
+
if (isAnchored) {
|
|
195
|
+
const re = _globToRegex(dir);
|
|
196
|
+
const parts = rel.split('/');
|
|
197
|
+
for (let i = dir.split('/').length; i <= parts.length; i++) {
|
|
198
|
+
if (re.test(parts.slice(0, i).join('/'))) return true;
|
|
199
|
+
}
|
|
200
|
+
} else {
|
|
201
|
+
const re = _globToRegex(dir);
|
|
202
|
+
for (const part of rel.split('/')) {
|
|
203
|
+
if (re.test(part)) return true;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
const hasSlash = pattern.includes('/');
|
|
209
|
+
const target = hasSlash ? rel : base;
|
|
210
|
+
if (_globToRegex(pattern).test(target)) return true;
|
|
211
|
+
}
|
|
212
|
+
return false;
|
|
213
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "hypomnema",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "LLM-native personal wiki system for Claude Code",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"bin": {
|
|
8
|
+
"hypomnema": "scripts/init.mjs"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"scripts/",
|
|
12
|
+
"commands/",
|
|
13
|
+
"hooks/",
|
|
14
|
+
"skills/",
|
|
15
|
+
"templates/",
|
|
16
|
+
"docs/",
|
|
17
|
+
".claude-plugin/",
|
|
18
|
+
"README.md"
|
|
19
|
+
],
|
|
20
|
+
"keywords": ["wiki", "llm", "claude", "claude-code", "personal-wiki", "knowledge-management"],
|
|
21
|
+
"repository": {
|
|
22
|
+
"type": "git",
|
|
23
|
+
"url": "https://github.com/sk-lim19f/Hypomnema.git"
|
|
24
|
+
},
|
|
25
|
+
"bugs": {
|
|
26
|
+
"url": "https://github.com/sk-lim19f/Hypomnema/issues"
|
|
27
|
+
},
|
|
28
|
+
"homepage": "https://github.com/sk-lim19f/Hypomnema#readme",
|
|
29
|
+
"engines": {
|
|
30
|
+
"node": ">=18"
|
|
31
|
+
},
|
|
32
|
+
"scripts": {
|
|
33
|
+
"test": "node tests/runner.mjs",
|
|
34
|
+
"lint": "node scripts/lint.mjs",
|
|
35
|
+
"graph": "node scripts/graph.mjs"
|
|
36
|
+
}
|
|
37
|
+
}
|
package/scripts/.gitkeep
ADDED
|
File without changes
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Sync version across package.json, .claude-plugin/plugin.json, and templates/hypo-config.md.
|
|
3
|
+
// Usage: node scripts/bump-version.mjs <new-version>
|
|
4
|
+
|
|
5
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
6
|
+
import { resolve, dirname } from 'node:path';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
|
|
9
|
+
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
10
|
+
const next = process.argv[2];
|
|
11
|
+
|
|
12
|
+
if (!next || !/^\d+\.\d+\.\d+(-[\w.]+)?$/.test(next)) {
|
|
13
|
+
console.error('Usage: node scripts/bump-version.mjs <semver>');
|
|
14
|
+
console.error('Example: node scripts/bump-version.mjs 1.0.0');
|
|
15
|
+
process.exit(1);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const targets = [
|
|
19
|
+
{
|
|
20
|
+
path: 'package.json',
|
|
21
|
+
pattern: /("version"\s*:\s*")([^"]+)(")/,
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
path: '.claude-plugin/plugin.json',
|
|
25
|
+
pattern: /("version"\s*:\s*")([^"]+)(")/,
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
path: 'templates/hypo-config.md',
|
|
29
|
+
pattern: /(^version:\s*")([^"]+)(")/m,
|
|
30
|
+
},
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
for (const { path, pattern } of targets) {
|
|
34
|
+
const abs = resolve(root, path);
|
|
35
|
+
const before = readFileSync(abs, 'utf-8');
|
|
36
|
+
const match = before.match(pattern);
|
|
37
|
+
if (!match) {
|
|
38
|
+
console.error(`✗ ${path}: version pattern not found`);
|
|
39
|
+
process.exit(1);
|
|
40
|
+
}
|
|
41
|
+
const current = match[2];
|
|
42
|
+
if (current === next) {
|
|
43
|
+
console.log(`= ${path} already ${next}`);
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
writeFileSync(abs, before.replace(pattern, `$1${next}$3`));
|
|
47
|
+
console.log(`✓ ${path}: ${current} → ${next}`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
console.log(`\nNext steps:`);
|
|
51
|
+
console.log(` git add -A && git commit -m "chore(release): v${next}"`);
|
|
52
|
+
console.log(` git tag v${next}`);
|
|
53
|
+
console.log(` git push origin <branch> --tags`);
|