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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.ko.md +12 -5
- package/README.md +12 -5
- package/commands/audit.md +46 -0
- package/commands/crystallize.md +113 -23
- package/commands/feedback.md +40 -26
- package/commands/ingest.md +31 -9
- package/commands/upgrade.md +2 -2
- package/docs/ARCHITECTURE.md +83 -9
- package/docs/CONTRIBUTING.md +2 -2
- package/hooks/hooks.json +39 -1
- package/hooks/hypo-auto-commit.mjs +23 -4
- package/hooks/hypo-auto-minimal-crystallize.mjs +145 -0
- package/hooks/hypo-auto-stage.mjs +9 -5
- package/hooks/hypo-compact-guard.mjs +33 -24
- package/hooks/hypo-cwd-change.mjs +107 -24
- package/hooks/hypo-file-watch.mjs +23 -10
- package/hooks/hypo-first-prompt.mjs +37 -23
- package/hooks/hypo-hot-rebuild.mjs +31 -8
- package/hooks/hypo-lookup.mjs +171 -65
- package/hooks/hypo-personal-check.mjs +207 -112
- package/hooks/hypo-pre-commit.mjs +46 -0
- package/hooks/hypo-session-end.mjs +58 -0
- package/hooks/hypo-session-record.mjs +60 -0
- package/hooks/hypo-session-start.mjs +312 -44
- package/hooks/hypo-shared.mjs +880 -28
- package/hooks/hypo-web-fetch-ingest.mjs +121 -0
- package/hooks/version-check-fetch.mjs +74 -0
- package/hooks/version-check.mjs +184 -0
- package/package.json +17 -3
- package/scripts/crystallize.mjs +623 -18
- package/scripts/doctor.mjs +739 -46
- package/scripts/feedback-sync.mjs +974 -0
- package/scripts/feedback.mjs +253 -44
- package/scripts/graph.mjs +35 -22
- package/scripts/ingest.mjs +89 -16
- package/scripts/init.mjs +442 -114
- package/scripts/lib/design-history-stale.mjs +83 -0
- package/scripts/lib/extensions.mjs +749 -0
- package/scripts/lib/frontmatter.mjs +5 -1
- package/scripts/lib/hypo-ignore.mjs +12 -10
- package/scripts/lib/pkg-json.mjs +23 -5
- package/scripts/lib/project-create.mjs +225 -0
- package/scripts/lib/schema-vocab.mjs +96 -0
- package/scripts/lint.mjs +238 -31
- package/scripts/query.mjs +26 -10
- package/scripts/resume.mjs +11 -5
- package/scripts/session-audit.mjs +277 -0
- package/scripts/smoke-pack.mjs +224 -0
- package/scripts/stats.mjs +24 -10
- package/scripts/uninstall.mjs +369 -48
- package/scripts/upgrade.mjs +766 -195
- package/scripts/verify.mjs +24 -14
- package/scripts/weekly-report.mjs +211 -0
- package/skills/crystallize/SKILL.md +24 -7
- package/skills/graph/SKILL.md +4 -0
- package/skills/ingest/SKILL.md +29 -5
- package/skills/lint/SKILL.md +4 -0
- package/skills/query/SKILL.md +4 -0
- package/skills/verify/SKILL.md +4 -0
- package/templates/.hypoignore +19 -2
- package/templates/Home.md +2 -0
- package/templates/SCHEMA.md +61 -6
- package/templates/extensions/agents/.gitkeep +0 -0
- package/templates/extensions/commands/.gitkeep +0 -0
- package/templates/extensions/hooks/.gitkeep +0 -0
- package/templates/extensions/skills/.gitkeep +0 -0
- package/templates/gitignore +5 -0
- package/templates/hot.md +2 -0
- package/templates/hypo-config.md +1 -1
- package/templates/hypo-guide.md +63 -1
- package/templates/hypo-help.md +1 -1
- package/templates/pages/observability/_index.md +77 -0
- package/templates/projects/_template/index.md +2 -2
- package/templates/projects/_template/prd.md +1 -1
|
@@ -8,48 +8,233 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { readFileSync, writeFileSync, existsSync, readdirSync, statSync } from 'fs';
|
|
11
|
-
import { homedir
|
|
12
|
-
import { join } from 'path';
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
11
|
+
import { homedir } from 'os';
|
|
12
|
+
import { join, dirname } from 'path';
|
|
13
|
+
import { fileURLToPath } from 'url';
|
|
14
|
+
import { spawnSync, spawn } from 'child_process';
|
|
15
|
+
import {
|
|
16
|
+
HYPO_DIR,
|
|
17
|
+
buildOutput,
|
|
18
|
+
SESSION_STATE_NEXT_HEADINGS,
|
|
19
|
+
formatGrowthMetrics,
|
|
20
|
+
readSyncState,
|
|
21
|
+
clearSyncState,
|
|
22
|
+
readClearMarker,
|
|
23
|
+
clearClearMarker,
|
|
24
|
+
loadHypoIgnore,
|
|
25
|
+
isIgnored,
|
|
26
|
+
sessionMarkerPath,
|
|
27
|
+
shouldSuggestProjectCreation,
|
|
28
|
+
buildProjectSuggestionLine,
|
|
29
|
+
recordSuggestionCooldown,
|
|
30
|
+
} from './hypo-shared.mjs';
|
|
31
|
+
import {
|
|
32
|
+
defaultCachePath,
|
|
33
|
+
detectChannel,
|
|
34
|
+
readCache,
|
|
35
|
+
cacheIsFresh,
|
|
36
|
+
computeNotice,
|
|
37
|
+
markNotified,
|
|
38
|
+
isOptedOut,
|
|
39
|
+
} from './version-check.mjs';
|
|
40
|
+
|
|
41
|
+
// Privacy guard: refuse to read+inject .hypoignore-matched
|
|
42
|
+
// wiki files into additionalContext. Without this, a user who lists
|
|
43
|
+
// `projects/private/hot.md` in .hypoignore would still see SECRET emit because
|
|
44
|
+
// session-start reads hot/state paths directly.
|
|
45
|
+
function readIfNotIgnored(path, maxChars, patterns) {
|
|
46
|
+
if (!path) return null;
|
|
47
|
+
if (patterns.length > 0 && isIgnored(path, HYPO_DIR, patterns)) return null;
|
|
48
|
+
return readFileSync(path, 'utf-8').slice(0, maxChars);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Directory of the running hook, and the install root one level up
|
|
52
|
+
// (<root>/hooks/...). The root is derived from the RUNNING hook path rather
|
|
53
|
+
// than ~/.claude/hypo-pkg.json so a dual install (npm + plugin) or a stale
|
|
54
|
+
// metadata file can't mislabel the channel (teams review (b), 2026-05-21).
|
|
55
|
+
const HOOK_DIR = dirname(fileURLToPath(import.meta.url));
|
|
56
|
+
const ACTIVE_ROOT = dirname(HOOK_DIR);
|
|
57
|
+
|
|
58
|
+
function readInstalledVersion(root) {
|
|
59
|
+
try {
|
|
60
|
+
return JSON.parse(readFileSync(join(root, 'package.json'), 'utf-8')).version || null;
|
|
61
|
+
} catch {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Update-notifier (teams-reviewed 2026-05-21). Reads ONLY the cache — never a
|
|
68
|
+
* synchronous network call. When the cache is stale, fires a detached worker to
|
|
69
|
+
* refresh it (shown next session). Fully best-effort: any failure returns ''.
|
|
70
|
+
*/
|
|
71
|
+
function buildUpdateNotice() {
|
|
72
|
+
try {
|
|
73
|
+
if (isOptedOut()) return '';
|
|
74
|
+
const cachePath = defaultCachePath();
|
|
75
|
+
|
|
76
|
+
let root = ACTIVE_ROOT;
|
|
77
|
+
let version = readInstalledVersion(root);
|
|
78
|
+
if (!version) {
|
|
79
|
+
try {
|
|
80
|
+
const meta = JSON.parse(readFileSync(join(homedir(), '.claude', 'hypo-pkg.json'), 'utf-8'));
|
|
81
|
+
root = meta.pkgRoot || root;
|
|
82
|
+
version = meta.pkgVersion || readInstalledVersion(root);
|
|
83
|
+
} catch {
|
|
84
|
+
/* fallback unavailable */
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (!version) return '';
|
|
88
|
+
|
|
89
|
+
const channel = detectChannel(root);
|
|
90
|
+
const cache = readCache(cachePath);
|
|
91
|
+
|
|
92
|
+
if (!cacheIsFresh(cache)) {
|
|
93
|
+
try {
|
|
94
|
+
const worker = join(HOOK_DIR, 'version-check-fetch.mjs');
|
|
95
|
+
if (existsSync(worker)) {
|
|
96
|
+
const child = spawn(process.execPath, [worker, cachePath], {
|
|
97
|
+
detached: true,
|
|
98
|
+
stdio: 'ignore',
|
|
99
|
+
});
|
|
100
|
+
// spawn() failures (EAGAIN/EMFILE/ENOENT) surface ASYNChronously on
|
|
101
|
+
// the child's 'error' event — the try/catch above only catches the
|
|
102
|
+
// synchronous throw. Without this listener an unhandled 'error' would
|
|
103
|
+
// crash SessionStart, violating the best-effort contract.
|
|
104
|
+
child.on('error', () => {});
|
|
105
|
+
child.unref();
|
|
106
|
+
}
|
|
107
|
+
} catch {
|
|
108
|
+
/* spawn is best-effort */
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const notice = computeNotice(cache, channel, version);
|
|
113
|
+
if (!notice) return '';
|
|
114
|
+
markNotified(cachePath, channel, notice.latest);
|
|
115
|
+
return notice.line;
|
|
116
|
+
} catch {
|
|
117
|
+
return '';
|
|
118
|
+
}
|
|
119
|
+
}
|
|
15
120
|
|
|
16
121
|
const PROJECTS_DIR = join(HYPO_DIR, 'projects');
|
|
122
|
+
const GROWTH_CACHE = join(HYPO_DIR, '.cache', 'last-session-growth.json');
|
|
123
|
+
|
|
124
|
+
function readLastGrowthLine() {
|
|
125
|
+
if (!existsSync(GROWTH_CACHE)) return '';
|
|
126
|
+
try {
|
|
127
|
+
const stats = JSON.parse(readFileSync(GROWTH_CACHE, 'utf-8'));
|
|
128
|
+
return formatGrowthMetrics('start', stats);
|
|
129
|
+
} catch {
|
|
130
|
+
return '';
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* fix #25 PR-A2 (ADR 0022 amendment 2026-05-14): if the prior session ended
|
|
136
|
+
* via `/clear`, hypo-session-end stashed its identity in `.cache/clear-marker.json`.
|
|
137
|
+
* Read it (with 7-day stale guard), unlink it (one-shot), and return a
|
|
138
|
+
* `[WIKI_AUTOCLOSE]` recovery line for additionalContext + stderr.
|
|
139
|
+
*
|
|
140
|
+
* @param {string|undefined} source SessionStart payload `source` field
|
|
141
|
+
* @returns {string} recovery line, or '' when no recovery is needed
|
|
142
|
+
*/
|
|
143
|
+
function buildClearRecoveryLine(source) {
|
|
144
|
+
if (source !== 'clear') return '';
|
|
145
|
+
const marker = readClearMarker(HYPO_DIR);
|
|
146
|
+
if (!marker) return '';
|
|
147
|
+
clearClearMarker(HYPO_DIR);
|
|
148
|
+
const prevId = marker.prev_session_id || 'unknown';
|
|
149
|
+
const prevTr = marker.prev_transcript_path || null;
|
|
150
|
+
const prevCwd = marker.prev_cwd || null;
|
|
151
|
+
const trLine = prevTr ? `\n prev_transcript: ${prevTr}` : '';
|
|
152
|
+
const cwdLine = prevCwd ? `\n prev_cwd: ${prevCwd}` : '';
|
|
153
|
+
return (
|
|
154
|
+
`[WIKI_AUTOCLOSE] 이전 세션(${prevId})이 /clear로 강제 종료됨.${trLine}${cwdLine}\n` +
|
|
155
|
+
` session-close가 미완료라면 지금 즉시 실행할 것 ` +
|
|
156
|
+
`(hot.md + session-state.md + log.md 최소 갱신).`
|
|
157
|
+
);
|
|
158
|
+
}
|
|
17
159
|
|
|
160
|
+
/** Pull the wiki repo. Returns true only when the pull actually succeeded. */
|
|
18
161
|
function gitPull(dir) {
|
|
19
|
-
if (!existsSync(join(dir, '.git'))) return;
|
|
20
|
-
spawnSync('git', ['-C', dir, 'pull', '--ff-only', '--quiet'], {
|
|
162
|
+
if (!existsSync(join(dir, '.git'))) return false;
|
|
163
|
+
const r = spawnSync('git', ['-C', dir, 'pull', '--ff-only', '--quiet'], {
|
|
164
|
+
stdio: 'pipe',
|
|
165
|
+
timeout: 10000,
|
|
166
|
+
});
|
|
167
|
+
return r.status === 0;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* fix #10: surface unresolved sync failures recorded by a prior session's
|
|
172
|
+
* Stop hook (#9). The entry is cleared only once this session's pull has
|
|
173
|
+
* succeeded AND there is no unpushed commit left behind by a failed push
|
|
174
|
+
* (`[ahead N]`).
|
|
175
|
+
*
|
|
176
|
+
* Resolution deliberately checks only the ahead-of-remote state, not the full
|
|
177
|
+
* working tree: uncommitted/untracked files are not a sync failure, and a
|
|
178
|
+
* fresh `hypo init` wiki does not git-ignore `.cache/`, so a broader cleanliness
|
|
179
|
+
* check would see the sync-state file itself and never clear.
|
|
180
|
+
*
|
|
181
|
+
* @returns {string} a `[WIKI: last sync failed: ...]` line, or '' when clear.
|
|
182
|
+
*/
|
|
183
|
+
function syncStateNotice(pullOk) {
|
|
184
|
+
const { entries, parseError } = readSyncState(HYPO_DIR);
|
|
185
|
+
// A corrupt JSONL file is still an "open" failure — surface it (doctor warns
|
|
186
|
+
// too) but never clear it, so the unreadable record survives for inspection.
|
|
187
|
+
if (parseError) return '[WIKI: last sync failed: sync-state.json unreadable — inspect manually]';
|
|
188
|
+
if (entries.length === 0) return '';
|
|
189
|
+
let resolved = false;
|
|
190
|
+
if (pullOk) {
|
|
191
|
+
const r = spawnSync('git', ['-C', HYPO_DIR, 'status', '--branch', '--porcelain'], {
|
|
192
|
+
encoding: 'utf-8',
|
|
193
|
+
timeout: 10000,
|
|
194
|
+
});
|
|
195
|
+
resolved = r.status === 0 && !/\[ahead \d+\]/.test(r.stdout || '');
|
|
196
|
+
}
|
|
197
|
+
if (resolved) {
|
|
198
|
+
clearSyncState(HYPO_DIR);
|
|
199
|
+
return '';
|
|
200
|
+
}
|
|
201
|
+
const last = entries[entries.length - 1];
|
|
202
|
+
return `[WIKI: last sync failed: ${last.op || '?'} — ${last.error || 'unknown'}]`;
|
|
21
203
|
}
|
|
22
|
-
const GLOBAL_HOT
|
|
23
|
-
const HOT_CHARS
|
|
24
|
-
const STATE_CHARS
|
|
204
|
+
const GLOBAL_HOT = join(HYPO_DIR, 'hot.md');
|
|
205
|
+
const HOT_CHARS = 2000;
|
|
206
|
+
const STATE_CHARS = 2000;
|
|
25
207
|
|
|
26
208
|
function parseFrontmatterField(content, key) {
|
|
27
209
|
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
28
210
|
if (!match) return null;
|
|
29
|
-
const line = match[1].split('\n').find(l => l.startsWith(`${key}:`));
|
|
211
|
+
const line = match[1].split('\n').find((l) => l.startsWith(`${key}:`));
|
|
30
212
|
if (!line) return null;
|
|
31
|
-
return line
|
|
213
|
+
return line
|
|
214
|
+
.slice(key.length + 1)
|
|
215
|
+
.trim()
|
|
216
|
+
.replace(/^['"]|['"]$/g, '');
|
|
32
217
|
}
|
|
33
218
|
|
|
34
219
|
function findProjectFiles(cwd) {
|
|
35
220
|
if (!existsSync(PROJECTS_DIR)) return null;
|
|
36
221
|
for (const proj of readdirSync(PROJECTS_DIR)) {
|
|
37
|
-
const projDir
|
|
222
|
+
const projDir = join(PROJECTS_DIR, proj);
|
|
38
223
|
if (!statSync(projDir).isDirectory()) continue;
|
|
39
224
|
const indexPath = join(projDir, 'index.md');
|
|
40
225
|
if (!existsSync(indexPath)) continue;
|
|
41
|
-
const content
|
|
226
|
+
const content = readFileSync(indexPath, 'utf-8');
|
|
42
227
|
const workingDir = parseFrontmatterField(content, 'working_dir');
|
|
43
228
|
if (!workingDir) continue;
|
|
44
229
|
const resolved = workingDir.startsWith('~/')
|
|
45
230
|
? join(homedir(), workingDir.slice(2))
|
|
46
231
|
: workingDir;
|
|
47
232
|
if (cwd === resolved || cwd.startsWith(resolved + '/')) {
|
|
48
|
-
const hotPath
|
|
233
|
+
const hotPath = join(projDir, 'hot.md');
|
|
49
234
|
const statePath = join(projDir, 'session-state.md');
|
|
50
235
|
return {
|
|
51
236
|
proj,
|
|
52
|
-
hotPath:
|
|
237
|
+
hotPath: existsSync(hotPath) ? hotPath : null,
|
|
53
238
|
statePath: existsSync(statePath) ? statePath : null,
|
|
54
239
|
};
|
|
55
240
|
}
|
|
@@ -71,18 +256,20 @@ function printTerminalSummary(proj, hotContent, stateContent) {
|
|
|
71
256
|
const nextFromState = stateContent
|
|
72
257
|
? extractSection(stateContent, SESSION_STATE_NEXT_HEADINGS)
|
|
73
258
|
: null;
|
|
74
|
-
const next = nextFromState
|
|
75
|
-
?? extractSection(hotContent ?? '', SESSION_STATE_NEXT_HEADINGS);
|
|
259
|
+
const next = nextFromState ?? extractSection(hotContent ?? '', SESSION_STATE_NEXT_HEADINGS);
|
|
76
260
|
const prev = hotContent
|
|
77
|
-
? (extractSection(hotContent, '직전 세션 \\([^)]+\\)')
|
|
78
|
-
|
|
79
|
-
|
|
261
|
+
? (extractSection(hotContent, '직전 세션 \\([^)]+\\)') ??
|
|
262
|
+
extractSection(hotContent, '직전 세션.*') ??
|
|
263
|
+
extractSection(hotContent, 'Last Session.*'))
|
|
80
264
|
: null;
|
|
81
265
|
const lines = ['', `\x1b[36m[Hypomnema]\x1b[0m project: \x1b[1m${proj}\x1b[0m`];
|
|
82
266
|
if (prev) lines.push(` prev: ${prev.split('\n')[0].replace(/^\*\*|\*\*$/g, '')}`);
|
|
83
267
|
if (next) {
|
|
84
268
|
lines.push(' next:');
|
|
85
|
-
next
|
|
269
|
+
next
|
|
270
|
+
.split('\n')
|
|
271
|
+
.slice(0, 20)
|
|
272
|
+
.forEach((l) => lines.push(` ${l}`));
|
|
86
273
|
}
|
|
87
274
|
lines.push('');
|
|
88
275
|
process.stderr.write(lines.join('\n'));
|
|
@@ -90,52 +277,133 @@ function printTerminalSummary(proj, hotContent, stateContent) {
|
|
|
90
277
|
|
|
91
278
|
let raw = '';
|
|
92
279
|
process.stdin.setEncoding('utf-8');
|
|
93
|
-
process.stdin.on('data', chunk => raw += chunk);
|
|
280
|
+
process.stdin.on('data', (chunk) => (raw += chunk));
|
|
94
281
|
process.stdin.on('end', () => {
|
|
95
282
|
try {
|
|
96
283
|
let data = {};
|
|
97
|
-
try {
|
|
284
|
+
try {
|
|
285
|
+
data = JSON.parse(raw);
|
|
286
|
+
} catch {}
|
|
98
287
|
|
|
99
|
-
gitPull(HYPO_DIR);
|
|
288
|
+
const pullOk = gitPull(HYPO_DIR);
|
|
289
|
+
const syncLine = syncStateNotice(pullOk);
|
|
290
|
+
const growthLine = readLastGrowthLine();
|
|
291
|
+
// fix #25 PR-A2 (ADR 0022 amendment): on source='clear', surface the dying
|
|
292
|
+
// session's identity that hypo-session-end stashed so Claude can recover
|
|
293
|
+
// session-close work that /clear skipped. One-shot: marker is unlinked
|
|
294
|
+
// immediately after read.
|
|
295
|
+
const clearRecoveryLine = buildClearRecoveryLine(data.source);
|
|
296
|
+
const updateLine = buildUpdateNotice();
|
|
297
|
+
// Intentional dual emit: stderr (yellow/cyan) is the human-visible nudge in
|
|
298
|
+
// the terminal; noticePrefix injects the same plain-text lines into the
|
|
299
|
+
// LLM's additionalContext so model and user start the session looking at
|
|
300
|
+
// the same state. ANSI escapes are kept out of additionalContext on purpose.
|
|
301
|
+
const notices = [syncLine, growthLine, clearRecoveryLine, updateLine].filter(Boolean);
|
|
302
|
+
let noticePrefix = notices.length ? `${notices.join('\n\n')}\n\n` : '';
|
|
303
|
+
if (syncLine) process.stderr.write(`\n\x1b[33m${syncLine}\x1b[0m\n`);
|
|
304
|
+
if (growthLine) process.stderr.write(`\n\x1b[36m${growthLine}\x1b[0m\n`);
|
|
305
|
+
if (clearRecoveryLine)
|
|
306
|
+
process.stderr.write(`\n\x1b[33m${clearRecoveryLine.split('\n')[0]}\x1b[0m\n`);
|
|
307
|
+
if (updateLine) process.stderr.write(`\n\x1b[33m${updateLine}\x1b[0m\n`);
|
|
100
308
|
const cwd = data.cwd || data.directory || process.cwd();
|
|
101
309
|
const sessionId = data.session_id || 'default';
|
|
102
|
-
const MARKER_FILE =
|
|
310
|
+
const MARKER_FILE = sessionMarkerPath(sessionId);
|
|
103
311
|
const hit = findProjectFiles(cwd);
|
|
104
312
|
|
|
313
|
+
const ignorePatterns = loadHypoIgnore(HYPO_DIR);
|
|
314
|
+
|
|
105
315
|
if (hit) {
|
|
106
|
-
const hotContent
|
|
107
|
-
const stateContent =
|
|
316
|
+
const hotContent = readIfNotIgnored(hit.hotPath, HOT_CHARS, ignorePatterns);
|
|
317
|
+
const stateContent = readIfNotIgnored(hit.statePath, STATE_CHARS, ignorePatterns);
|
|
108
318
|
|
|
109
319
|
if (hotContent || stateContent) {
|
|
110
320
|
printTerminalSummary(hit.proj, hotContent, stateContent);
|
|
111
|
-
writeFileSync(
|
|
321
|
+
writeFileSync(
|
|
322
|
+
MARKER_FILE,
|
|
323
|
+
JSON.stringify({
|
|
324
|
+
proj: hit.proj,
|
|
325
|
+
hotPath: hit.hotPath,
|
|
326
|
+
statePath: hit.statePath,
|
|
327
|
+
hasSnapshot: true,
|
|
328
|
+
ts: Date.now(),
|
|
329
|
+
}),
|
|
330
|
+
);
|
|
112
331
|
const parts = [];
|
|
113
|
-
if (hotContent)
|
|
332
|
+
if (hotContent) parts.push(`[HOT]\n${hotContent}`);
|
|
114
333
|
if (stateContent) parts.push(`[SESSION STATE — 다음 작업]\n${stateContent}`);
|
|
115
|
-
console.log(
|
|
116
|
-
|
|
117
|
-
|
|
334
|
+
console.log(
|
|
335
|
+
JSON.stringify(
|
|
336
|
+
buildOutput(
|
|
337
|
+
`${noticePrefix}[WIKI HOT CACHE: project=${hit.proj}]\n\n${parts.join('\n\n')}`,
|
|
338
|
+
{ continue: true, suppressOutput: true },
|
|
339
|
+
),
|
|
340
|
+
),
|
|
341
|
+
);
|
|
118
342
|
} else {
|
|
119
|
-
process.stderr.write(
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
343
|
+
process.stderr.write(
|
|
344
|
+
`\n\x1b[36m[Hypomnema]\x1b[0m project: \x1b[1m${hit.proj}\x1b[0m (no snapshot yet)\n\n`,
|
|
345
|
+
);
|
|
346
|
+
writeFileSync(
|
|
347
|
+
MARKER_FILE,
|
|
348
|
+
JSON.stringify({ proj: hit.proj, hotPath: null, ts: Date.now() }),
|
|
349
|
+
);
|
|
350
|
+
console.log(
|
|
351
|
+
JSON.stringify(
|
|
352
|
+
buildOutput(`${noticePrefix}[WIKI HOT CACHE: project=${hit.proj}, no snapshot yet]`, {
|
|
353
|
+
continue: true,
|
|
354
|
+
suppressOutput: true,
|
|
355
|
+
}),
|
|
356
|
+
),
|
|
357
|
+
);
|
|
124
358
|
}
|
|
125
359
|
return;
|
|
126
360
|
}
|
|
127
361
|
|
|
362
|
+
// MISS: cwd matches no project. fix #23 / ADR 0023 — offer to create one
|
|
363
|
+
// when the ADR trigger conditions hold (git repo + project marker + no
|
|
364
|
+
// cooldown + not previously declined). The actual scaffold is the LLM's
|
|
365
|
+
// job on a "Y" reply (scripts/lib/project-create.mjs); the hook only nudges.
|
|
366
|
+
if (shouldSuggestProjectCreation(cwd, HYPO_DIR)) {
|
|
367
|
+
const suggestLine = buildProjectSuggestionLine(cwd);
|
|
368
|
+
notices.push(suggestLine);
|
|
369
|
+
noticePrefix = `${notices.join('\n\n')}\n\n`;
|
|
370
|
+
recordSuggestionCooldown(HYPO_DIR, cwd);
|
|
371
|
+
process.stderr.write(`\n\x1b[33m${suggestLine}\x1b[0m\n`);
|
|
372
|
+
}
|
|
373
|
+
|
|
128
374
|
if (!existsSync(GLOBAL_HOT)) {
|
|
129
|
-
|
|
375
|
+
const notice = notices.join('\n\n');
|
|
376
|
+
if (notice) {
|
|
377
|
+
console.log(JSON.stringify(buildOutput(notice, { continue: true, suppressOutput: true })));
|
|
378
|
+
} else {
|
|
379
|
+
console.log(JSON.stringify({ continue: true, suppressOutput: true }));
|
|
380
|
+
}
|
|
130
381
|
return;
|
|
131
382
|
}
|
|
132
383
|
|
|
133
|
-
const globalContent =
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
384
|
+
const globalContent = readIfNotIgnored(GLOBAL_HOT, HOT_CHARS, ignorePatterns);
|
|
385
|
+
if (!globalContent) {
|
|
386
|
+
// GLOBAL_HOT exists but is empty or .hypoignore'd — still surface any
|
|
387
|
+
// pending notices (sync state, growth, AND the auto-project offer), which
|
|
388
|
+
// would otherwise be silently dropped here (codex review 2026-05-22).
|
|
389
|
+
const notice = notices.join('\n\n');
|
|
390
|
+
if (notice) {
|
|
391
|
+
console.log(JSON.stringify(buildOutput(notice, { continue: true, suppressOutput: true })));
|
|
392
|
+
} else {
|
|
393
|
+
console.log(JSON.stringify({ continue: true, suppressOutput: true }));
|
|
394
|
+
}
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
console.log(
|
|
398
|
+
JSON.stringify(
|
|
399
|
+
buildOutput(
|
|
400
|
+
`${noticePrefix}[WIKI HOT CACHE: global — no project matched cwd=${cwd}]\n\n${globalContent}`,
|
|
401
|
+
{ continue: true, suppressOutput: true },
|
|
402
|
+
),
|
|
403
|
+
),
|
|
404
|
+
);
|
|
405
|
+
} catch (err) {
|
|
406
|
+
process.stderr.write(`[hypo-session-start] error: ${err?.message ?? String(err)}\n`);
|
|
139
407
|
console.log(JSON.stringify({ continue: true, suppressOutput: true }));
|
|
140
408
|
}
|
|
141
409
|
});
|