hypomnema 1.1.0 → 1.2.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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.ko.md +74 -26
- package/README.md +57 -9
- package/commands/audit.md +2 -2
- 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 +1 -1
- package/docs/CONTRIBUTING.md +1 -1
- package/hooks/hooks.json +30 -1
- package/hooks/hypo-auto-commit.mjs +10 -4
- package/hooks/hypo-auto-minimal-crystallize.mjs +145 -0
- package/hooks/hypo-auto-stage.mjs +4 -3
- package/hooks/hypo-compact-guard.mjs +33 -24
- package/hooks/hypo-cwd-change.mjs +111 -24
- package/hooks/hypo-file-watch.mjs +23 -10
- package/hooks/hypo-first-prompt.mjs +69 -23
- package/hooks/hypo-hot-rebuild.mjs +22 -10
- package/hooks/hypo-lookup.mjs +171 -65
- package/hooks/hypo-personal-check.mjs +209 -112
- package/hooks/hypo-pre-commit.mjs +46 -0
- package/hooks/hypo-session-end.mjs +58 -0
- package/hooks/hypo-session-record.mjs +11 -5
- package/hooks/hypo-session-start.mjs +302 -52
- package/hooks/hypo-shared.mjs +817 -37
- 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 +730 -47
- 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 +398 -113
- 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 +16 -6
- package/scripts/session-audit.mjs +37 -27
- package/scripts/smoke-pack.mjs +224 -0
- package/scripts/stats.mjs +24 -10
- package/scripts/uninstall.mjs +363 -49
- package/scripts/upgrade.mjs +706 -202
- package/scripts/verify.mjs +24 -14
- package/scripts/weekly-report.mjs +59 -25
- package/skills/crystallize/SKILL.md +20 -7
- package/skills/ingest/SKILL.md +25 -5
- package/templates/.hypoignore +16 -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 +42 -2
- 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
package/hooks/hypo-shared.mjs
CHANGED
|
@@ -6,13 +6,46 @@
|
|
|
6
6
|
* Hooks are deployed to ~/.claude/hooks/ — no external imports allowed.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { readFileSync, existsSync } from 'fs';
|
|
9
|
+
import { readFileSync, writeFileSync, existsSync, appendFileSync, mkdirSync, rmSync } from 'fs';
|
|
10
10
|
import { join, relative, basename } from 'path';
|
|
11
|
-
import { homedir } from 'os';
|
|
11
|
+
import { homedir, hostname, tmpdir } from 'os';
|
|
12
12
|
import { spawnSync } from 'child_process';
|
|
13
13
|
|
|
14
14
|
const HOME = homedir();
|
|
15
15
|
|
|
16
|
+
// ── session marker path ─────────────────────────────────────────────────────
|
|
17
|
+
// hypo-session-start / hypo-cwd-change WRITE this marker; hypo-first-prompt
|
|
18
|
+
// READS + unlinks it. The session_id comes from the Claude Code runtime (a
|
|
19
|
+
// UUID), but we sanitize defensively so a malformed id with path separators or
|
|
20
|
+
// `..` can never escape tmpdir or collide on an empty value (codex review
|
|
21
|
+
// 2026-05-21, fix #3/#13). Non-alphanumeric chars collapse to `_`.
|
|
22
|
+
export function sessionMarkerPath(sessionId) {
|
|
23
|
+
const safe = String(sessionId || 'default').replace(/[^A-Za-z0-9._-]/g, '_') || 'default';
|
|
24
|
+
return join(tmpdir(), `hypo-session-marker-${safe}.json`);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// ── project name sanitizer for prompt-facing interpolation ─────────────────
|
|
28
|
+
// marker.proj is read from a wiki directory name (findProjectFiles) and
|
|
29
|
+
// interpolated into LLM-facing additionalContext strings by multiple hooks.
|
|
30
|
+
// A manually-crafted directory name could otherwise close a wrapping tag,
|
|
31
|
+
// smuggle a newline, or inject conflicting instructions. Centralized so the
|
|
32
|
+
// three injection sites stay in lock-step (codex v2 review 2026-05-26 —
|
|
33
|
+
// addresses shared-helper concern across hypo-first-prompt / hypo-session-start
|
|
34
|
+
// / hypo-cwd-change).
|
|
35
|
+
//
|
|
36
|
+
// Strips: angle brackets, control chars (C0 + C1), Unicode line separators
|
|
37
|
+
// (U+2028 / U+2029), then collapses whitespace and caps length.
|
|
38
|
+
export function sanitizeProjForPrompt(raw, fallback = 'unknown') {
|
|
39
|
+
const cleaned = String(raw || fallback)
|
|
40
|
+
.replace(/[<>\[\]]/g, '_')
|
|
41
|
+
// eslint-disable-next-line no-control-regex
|
|
42
|
+
.replace(/[\u0000-\u001F\u007F-\u009F\u2028\u2029]/g, ' ')
|
|
43
|
+
.replace(/\s+/g, ' ')
|
|
44
|
+
.trim()
|
|
45
|
+
.slice(0, 80);
|
|
46
|
+
return cleaned || fallback;
|
|
47
|
+
}
|
|
48
|
+
|
|
16
49
|
// ── wiki root resolution ────────────────────────────────────────────────────
|
|
17
50
|
|
|
18
51
|
function expandHome(p) {
|
|
@@ -42,16 +75,21 @@ function resolveHypoRoot() {
|
|
|
42
75
|
return join(HOME, 'hypomnema');
|
|
43
76
|
}
|
|
44
77
|
|
|
45
|
-
export const HYPO_DIR
|
|
46
|
-
export const LOG_PATH
|
|
47
|
-
export const HOT_PATH
|
|
78
|
+
export const HYPO_DIR = resolveHypoRoot();
|
|
79
|
+
export const LOG_PATH = join(HYPO_DIR, 'log.md');
|
|
80
|
+
export const HOT_PATH = join(HYPO_DIR, 'hot.md');
|
|
48
81
|
export const GUIDE_PATH = join(HYPO_DIR, 'hypo-guide.md');
|
|
49
82
|
|
|
50
83
|
// Package root: written by init/upgrade to ~/.claude/hypo-pkg.json
|
|
51
84
|
function resolvePkgRoot() {
|
|
52
85
|
const p = join(HOME, '.claude', 'hypo-pkg.json');
|
|
53
86
|
if (!existsSync(p)) return null;
|
|
54
|
-
try {
|
|
87
|
+
try {
|
|
88
|
+
const v = JSON.parse(readFileSync(p, 'utf-8')).pkgRoot;
|
|
89
|
+
return typeof v === 'string' && v ? v : null;
|
|
90
|
+
} catch {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
55
93
|
}
|
|
56
94
|
export const PKG_ROOT = resolvePkgRoot();
|
|
57
95
|
|
|
@@ -59,7 +97,7 @@ export const PKG_ROOT = resolvePkgRoot();
|
|
|
59
97
|
// Set HYPO_ALLOWED_HOT_H2=comma,separated,headings to enable.
|
|
60
98
|
const _allowedH2Env = process.env.HYPO_ALLOWED_HOT_H2;
|
|
61
99
|
export const ALLOWED_HOT_H2 = _allowedH2Env
|
|
62
|
-
? new Set(_allowedH2Env.split(',').map(s => s.trim()))
|
|
100
|
+
? new Set(_allowedH2Env.split(',').map((s) => s.trim()))
|
|
63
101
|
: null;
|
|
64
102
|
|
|
65
103
|
// ── skip-gate helper ───────────────────────────────────────────────────────
|
|
@@ -74,22 +112,29 @@ export function isGateSkipped() {
|
|
|
74
112
|
export function lastSubstantialOpIsSession() {
|
|
75
113
|
if (!existsSync(LOG_PATH)) return true;
|
|
76
114
|
const log = readFileSync(LOG_PATH, 'utf-8');
|
|
77
|
-
const substantial = log
|
|
78
|
-
.
|
|
115
|
+
const substantial = log
|
|
116
|
+
.split('\n')
|
|
117
|
+
.filter((l) => /^## \[\d{4}-\d{2}-\d{2}\] (session|ingest)/.test(l));
|
|
79
118
|
if (substantial.length === 0) return true;
|
|
80
119
|
return /^## \[\d{4}-\d{2}-\d{2}\] session/.test(substantial[substantial.length - 1]);
|
|
81
120
|
}
|
|
82
121
|
|
|
83
|
-
export function hypoIsClean() {
|
|
122
|
+
export function hypoIsClean(dir = HYPO_DIR) {
|
|
84
123
|
try {
|
|
85
|
-
const porcelain = spawnSync('git', ['-C',
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
if (
|
|
124
|
+
const porcelain = spawnSync('git', ['-C', dir, 'status', '--porcelain'], {
|
|
125
|
+
encoding: 'utf-8',
|
|
126
|
+
});
|
|
127
|
+
if (porcelain.status !== 0) return { clean: false, reason: `git check failed in ${dir}` };
|
|
128
|
+
if (porcelain.stdout.trim() !== '')
|
|
129
|
+
return { clean: false, reason: `uncommitted changes in ${dir}` };
|
|
130
|
+
const ahead = spawnSync('git', ['-C', dir, 'status', '--branch', '--porcelain'], {
|
|
131
|
+
encoding: 'utf-8',
|
|
132
|
+
});
|
|
133
|
+
if (/\[ahead \d+\]/.test(ahead.stdout || ''))
|
|
134
|
+
return { clean: false, reason: `unpushed commits in ${dir}` };
|
|
90
135
|
return { clean: true };
|
|
91
136
|
} catch {
|
|
92
|
-
return { clean: false, reason: `git check failed in ${
|
|
137
|
+
return { clean: false, reason: `git check failed in ${dir}` };
|
|
93
138
|
}
|
|
94
139
|
}
|
|
95
140
|
|
|
@@ -100,8 +145,8 @@ export function hotMdIsClean() {
|
|
|
100
145
|
|
|
101
146
|
// Optional: check H2 allowlist if HYPO_ALLOWED_HOT_H2 is set
|
|
102
147
|
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));
|
|
148
|
+
const h2s = [...content.matchAll(/^## (.+)$/gm)].map((m) => m[1].trim());
|
|
149
|
+
const extra = h2s.filter((h) => !ALLOWED_HOT_H2.has(h));
|
|
105
150
|
if (extra.length > 0) reasons.push(`hot.md has unexpected H2 sections: ${extra.join(', ')}`);
|
|
106
151
|
}
|
|
107
152
|
|
|
@@ -114,6 +159,650 @@ export function hotMdIsClean() {
|
|
|
114
159
|
return reasons.length === 0 ? { clean: true } : { clean: false, reason: reasons.join(' / ') };
|
|
115
160
|
}
|
|
116
161
|
|
|
162
|
+
// ── strict session-close verification ────────────────────────────
|
|
163
|
+
// spec §5.2.7 / §8.3 (updated 2026-05-15): session-close = steps 1~6 of the
|
|
164
|
+
// 11-step crystallize checklist (synthesis is steps 7~11). The hard gate
|
|
165
|
+
// (sessionCloseFileStatus) confirms the 5 mandatory files — session-state.md,
|
|
166
|
+
// project hot.md, root hot.md, session-log/YYYY-MM.md, and log.md.
|
|
167
|
+
// pages/open-questions.md (step 5) is conditional ("변경 시") — it is a
|
|
168
|
+
// cross-project queue, so a session that raises no questions should not be
|
|
169
|
+
// forced to touch it. Gating it would produce false-blocks; spec §5.2.7
|
|
170
|
+
// records this as the intended policy.
|
|
171
|
+
//
|
|
172
|
+
// Known limitation: freshness is date-based per spec §8.3 ("timestamp가 같음"),
|
|
173
|
+
// so a second session on the same day that skips updating a file still passes
|
|
174
|
+
// if an earlier close that day already stamped it. freshDates() accepting both
|
|
175
|
+
// local and UTC dates widens that window by up to one UTC offset. A per-session
|
|
176
|
+
// boundary is out of scope for fix #17.
|
|
177
|
+
|
|
178
|
+
/** Parse the frontmatter `updated:` field. Returns the trimmed value or null. */
|
|
179
|
+
function frontmatterUpdated(content) {
|
|
180
|
+
const m = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
181
|
+
if (!m) return null;
|
|
182
|
+
const u = m[1].match(/^updated:\s*(.+)$/m);
|
|
183
|
+
return u ? u[1].trim().replace(/^["']|["']$/g, '') : null;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Escape a string for safe literal use inside a RegExp. */
|
|
187
|
+
function escapeRegExp(s) {
|
|
188
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* True if `content` carries an ATX heading dated `date` (YYYY-MM-DD), e.g.
|
|
193
|
+
* `## [2026-05-15] something`. Matches H1-H6.
|
|
194
|
+
* Shared by sessionCloseFileStatus() and the crystallize apply helper so both
|
|
195
|
+
* use the same definition of "session-log already updated for today".
|
|
196
|
+
*/
|
|
197
|
+
export function hasSessionLogHeading(content, date) {
|
|
198
|
+
return new RegExp('^#{1,6} \\[' + escapeRegExp(date) + '\\]', 'm').test(content || '');
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* True if `content` carries a today-dated `## [date] session | <project>` entry
|
|
203
|
+
* in log.md.
|
|
204
|
+
*
|
|
205
|
+
* Bounded with an explicit `(?=\s|$)` lookahead, NOT `\b`: a regex word boundary
|
|
206
|
+
* matches between word and non-word chars, so `\b` after "foo" still matches in
|
|
207
|
+
* "foo-bar" (hyphen is non-word). The canonical log format always separates the
|
|
208
|
+
* project slug from anything that follows by whitespace or end-of-line, so the
|
|
209
|
+
* lookahead correctly rejects "session | foo-bar" when looking for "foo".
|
|
210
|
+
* (Reported by codex review of fix #38 — was a pre-existing bug in
|
|
211
|
+
* sessionCloseFileStatus that the helper extraction inherited.)
|
|
212
|
+
*/
|
|
213
|
+
export function hasLogEntry(content, date, project) {
|
|
214
|
+
return new RegExp(
|
|
215
|
+
'^## \\[' + escapeRegExp(date) + '\\] session \\| ' + escapeRegExp(project) + '(?=\\s|$)',
|
|
216
|
+
'm',
|
|
217
|
+
).test(content || '');
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Date strings that count as "today" for freshness checks. Both the local and
|
|
222
|
+
* UTC dates are accepted: Claude writes file dates in the user's local zone,
|
|
223
|
+
* while hypo-hot-rebuild stamps root hot.md with the UTC date. Accepting both
|
|
224
|
+
* removes the ~timezone-offset window where a correctly closed session would
|
|
225
|
+
* otherwise false-block.
|
|
226
|
+
* @returns {string[]} 1-2 ISO dates (YYYY-MM-DD), most-relevant first.
|
|
227
|
+
*/
|
|
228
|
+
export function freshDates() {
|
|
229
|
+
const d = new Date();
|
|
230
|
+
const local = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
|
231
|
+
const utc = d.toISOString().slice(0, 10);
|
|
232
|
+
return local === utc ? [local] : [local, utc];
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Resolve the most-recently-active project slug from root hot.md.
|
|
237
|
+
* Mirrors scripts/resume.mjs resolveActiveProject — kept in sync by hand.
|
|
238
|
+
* @param {string} hypoDir
|
|
239
|
+
* @returns {string|null}
|
|
240
|
+
*/
|
|
241
|
+
export function resolveActiveProject(hypoDir) {
|
|
242
|
+
const hotPath = join(hypoDir, 'hot.md');
|
|
243
|
+
if (!existsSync(hotPath)) return null;
|
|
244
|
+
let content;
|
|
245
|
+
try {
|
|
246
|
+
// Strip HTML comments before parsing so the canonical-format example row
|
|
247
|
+
// in templates/hot.md (`<!-- Row format: ... -->`) is not picked up as data.
|
|
248
|
+
content = readFileSync(hotPath, 'utf-8').replace(/<!--[\s\S]*?-->/g, '');
|
|
249
|
+
} catch {
|
|
250
|
+
return null;
|
|
251
|
+
}
|
|
252
|
+
// Canonical hot.md uses wikilinks: | name | date | [[projects/slug/hot]] |
|
|
253
|
+
const wikiRows = [
|
|
254
|
+
...content.matchAll(
|
|
255
|
+
/\|\s*([^|]+?)\s*\|\s*(\d{4}-\d{2}-\d{2})?\s*\|\s*\[\[projects\/([^\]/]+)\/[^\]]+\]\]/g,
|
|
256
|
+
),
|
|
257
|
+
].map((m) => ({ name: m[1].trim(), date: m[2] || '', slug: m[3] }));
|
|
258
|
+
if (wikiRows.length > 0) {
|
|
259
|
+
wikiRows.sort((a, b) => b.date.localeCompare(a.date));
|
|
260
|
+
return wikiRows[0].slug;
|
|
261
|
+
}
|
|
262
|
+
// Legacy markdown-link rows: | [name](projects/name/...) | ...
|
|
263
|
+
const mdRow = content.match(/\|\s*\[([^\]]+)\]\(projects\/([^/)]+)/);
|
|
264
|
+
if (mdRow) return mdRow[2];
|
|
265
|
+
return null;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Strict session-close verification (fix #17, spec §5.2.7 / §8.3).
|
|
270
|
+
* Confirms the memory files a session close must touch were updated today:
|
|
271
|
+
* - projects/<project>/session-state.md — frontmatter `updated:` is today
|
|
272
|
+
* - projects/<project>/hot.md — frontmatter `updated:` is today
|
|
273
|
+
* - hot.md (root) — frontmatter `updated:` is today
|
|
274
|
+
* - projects/<project>/session-log/YYYY-MM.md — has a `## [today]` heading
|
|
275
|
+
* - log.md — has a `## [today] session | <project>` entry
|
|
276
|
+
* The log.md check is project-scoped so a session close left incomplete for
|
|
277
|
+
* project A can't be masked by a fresh close of project B (and vice versa).
|
|
278
|
+
* open-questions.md (file #5) is conditional and not gated.
|
|
279
|
+
*
|
|
280
|
+
* @param {string} hypoDir
|
|
281
|
+
* @returns {{ok: boolean, project: string|null, dates: string[], stale: string[], missing: string[]}}
|
|
282
|
+
*/
|
|
283
|
+
export function sessionCloseFileStatus(hypoDir) {
|
|
284
|
+
const dates = freshDates();
|
|
285
|
+
const project = resolveActiveProject(hypoDir);
|
|
286
|
+
if (!project) {
|
|
287
|
+
return {
|
|
288
|
+
ok: false,
|
|
289
|
+
project: null,
|
|
290
|
+
dates,
|
|
291
|
+
stale: [],
|
|
292
|
+
missing: ['hot.md (no active project in pointer table)'],
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const stale = []; // exists but not updated this session
|
|
297
|
+
const missing = []; // file does not exist
|
|
298
|
+
|
|
299
|
+
const checkUpdated = (relPath) => {
|
|
300
|
+
const full = join(hypoDir, relPath);
|
|
301
|
+
if (!existsSync(full)) {
|
|
302
|
+
missing.push(relPath);
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
let content;
|
|
306
|
+
try {
|
|
307
|
+
content = readFileSync(full, 'utf-8');
|
|
308
|
+
} catch {
|
|
309
|
+
missing.push(relPath);
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
if (!dates.includes(frontmatterUpdated(content))) stale.push(relPath);
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
checkUpdated(join('projects', project, 'session-state.md'));
|
|
316
|
+
checkUpdated(join('projects', project, 'hot.md'));
|
|
317
|
+
checkUpdated('hot.md');
|
|
318
|
+
|
|
319
|
+
// session-log: monthly append-only file — must carry a today-dated heading.
|
|
320
|
+
// Reported under the local date's month (dates[0]) when no match is found.
|
|
321
|
+
let sessionLogOk = false;
|
|
322
|
+
for (const date of dates) {
|
|
323
|
+
const full = join(hypoDir, 'projects', project, 'session-log', `${date.slice(0, 7)}.md`);
|
|
324
|
+
if (!existsSync(full)) continue;
|
|
325
|
+
let content = '';
|
|
326
|
+
try {
|
|
327
|
+
content = readFileSync(full, 'utf-8');
|
|
328
|
+
} catch {
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
if (hasSessionLogHeading(content, date)) {
|
|
332
|
+
sessionLogOk = true;
|
|
333
|
+
break;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
if (!sessionLogOk) {
|
|
337
|
+
const logRel = join('projects', project, 'session-log', `${dates[0].slice(0, 7)}.md`);
|
|
338
|
+
(existsSync(join(hypoDir, logRel)) ? stale : missing).push(logRel);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// log.md: must carry a today-dated `session` entry for the resolved project.
|
|
342
|
+
const logFull = join(hypoDir, 'log.md');
|
|
343
|
+
if (!existsSync(logFull)) {
|
|
344
|
+
missing.push('log.md');
|
|
345
|
+
} else {
|
|
346
|
+
let content = '';
|
|
347
|
+
try {
|
|
348
|
+
content = readFileSync(logFull, 'utf-8');
|
|
349
|
+
} catch {
|
|
350
|
+
missing.push('log.md');
|
|
351
|
+
}
|
|
352
|
+
const logFresh = content && dates.some((d) => hasLogEntry(content, d, project));
|
|
353
|
+
if (content && !logFresh) stale.push('log.md');
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
return { ok: stale.length === 0 && missing.length === 0, project, dates, stale, missing };
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// ── sync-state ────────────────────────────────────────────
|
|
360
|
+
// `.cache/sync-state.json` is JSONL: one {timestamp, op, error, host} entry per
|
|
361
|
+
// line. hypo-auto-commit (#9) appends on pull/push failure; hypo-session-start
|
|
362
|
+
// (#10) surfaces open entries and clears them once sync is healthy again;
|
|
363
|
+
// doctor (#11) warns while entries remain. Keep the schema defined here only.
|
|
364
|
+
|
|
365
|
+
/** @returns {string} path to the sync-state JSONL file for a wiki root. */
|
|
366
|
+
function syncStatePath(hypoDir) {
|
|
367
|
+
return join(hypoDir, '.cache', 'sync-state.json');
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Append a sync failure entry. Best-effort — never throws, since a failed
|
|
372
|
+
* failure-log must not break the Stop hook that calls it.
|
|
373
|
+
*
|
|
374
|
+
* @param {string} hypoDir
|
|
375
|
+
* @param {'pull'|'push'} op
|
|
376
|
+
* @param {string} error raw stderr/stdout; first non-empty line is kept
|
|
377
|
+
*/
|
|
378
|
+
export function appendSyncFailure(hypoDir, op, error) {
|
|
379
|
+
try {
|
|
380
|
+
const cacheDir = join(hypoDir, '.cache');
|
|
381
|
+
if (!existsSync(cacheDir)) mkdirSync(cacheDir, { recursive: true });
|
|
382
|
+
const firstLine =
|
|
383
|
+
String(error || '')
|
|
384
|
+
.split('\n')
|
|
385
|
+
.map((l) => l.trim())
|
|
386
|
+
.find(Boolean) || 'unknown error';
|
|
387
|
+
const entry = {
|
|
388
|
+
timestamp: new Date().toISOString(),
|
|
389
|
+
op,
|
|
390
|
+
error: firstLine.slice(0, 200),
|
|
391
|
+
host: hostname(),
|
|
392
|
+
};
|
|
393
|
+
appendFileSync(syncStatePath(hypoDir), JSON.stringify(entry) + '\n');
|
|
394
|
+
} catch {
|
|
395
|
+
// best-effort
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Read sync-state entries.
|
|
401
|
+
* @param {string} hypoDir
|
|
402
|
+
* @returns {{entries: object[], parseError: boolean}}
|
|
403
|
+
*/
|
|
404
|
+
export function readSyncState(hypoDir) {
|
|
405
|
+
const path = syncStatePath(hypoDir);
|
|
406
|
+
if (!existsSync(path)) return { entries: [], parseError: false };
|
|
407
|
+
try {
|
|
408
|
+
const entries = readFileSync(path, 'utf-8')
|
|
409
|
+
.split('\n')
|
|
410
|
+
.filter((l) => l.trim())
|
|
411
|
+
.map((l) => JSON.parse(l));
|
|
412
|
+
return { entries, parseError: false };
|
|
413
|
+
} catch {
|
|
414
|
+
return { entries: [], parseError: true };
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/** Remove the sync-state file. Called once sync is healthy again. Best-effort. */
|
|
419
|
+
export function clearSyncState(hypoDir) {
|
|
420
|
+
try {
|
|
421
|
+
rmSync(syncStatePath(hypoDir), { force: true });
|
|
422
|
+
} catch {
|
|
423
|
+
// best-effort
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// ── auto-project suggestion (ADR 0023) ────────────────────────────
|
|
428
|
+
// `.cache/project-suggestions.json` is a single JSON object:
|
|
429
|
+
// { "skips": [{cwd, declined_at, reason}], "cooldowns": {"<cwd>": "<iso>"} }
|
|
430
|
+
// `skips` is written by the LLM (Layer-1 behavioral rule) when the user answers
|
|
431
|
+
// "N" to an auto-project offer — permanent per-cwd suppression (no TTL).
|
|
432
|
+
// `cooldowns` is written by the hook each time it emits an offer — a 5-minute
|
|
433
|
+
// same-cwd noise guard. The two live in one file so doctor validates a single
|
|
434
|
+
// schema. Both reads/writes are best-effort; a failure only loses the offer,
|
|
435
|
+
// never breaks SessionStart/CwdChanged.
|
|
436
|
+
|
|
437
|
+
const PROJECT_MARKERS = [
|
|
438
|
+
'package.json',
|
|
439
|
+
'Cargo.toml',
|
|
440
|
+
'go.mod',
|
|
441
|
+
'pyproject.toml',
|
|
442
|
+
'pom.xml',
|
|
443
|
+
'build.gradle',
|
|
444
|
+
'composer.json',
|
|
445
|
+
'Gemfile',
|
|
446
|
+
];
|
|
447
|
+
const SUGGESTION_COOLDOWN_MS = 5 * 60 * 1000;
|
|
448
|
+
|
|
449
|
+
/** @returns {string} path to the project-suggestions file for a wiki root. */
|
|
450
|
+
export function projectSuggestionsPath(hypoDir) {
|
|
451
|
+
return join(hypoDir, '.cache', 'project-suggestions.json');
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* Read the project-suggestions store.
|
|
456
|
+
* @param {string} hypoDir
|
|
457
|
+
* @returns {{skips: object[], cooldowns: Record<string,string>, parseError: boolean}}
|
|
458
|
+
*/
|
|
459
|
+
export function readProjectSuggestions(hypoDir) {
|
|
460
|
+
const path = projectSuggestionsPath(hypoDir);
|
|
461
|
+
if (!existsSync(path)) return { skips: [], cooldowns: {}, parseError: false };
|
|
462
|
+
try {
|
|
463
|
+
const data = JSON.parse(readFileSync(path, 'utf-8'));
|
|
464
|
+
return {
|
|
465
|
+
skips: Array.isArray(data.skips) ? data.skips : [],
|
|
466
|
+
cooldowns: data.cooldowns && typeof data.cooldowns === 'object' ? data.cooldowns : {},
|
|
467
|
+
parseError: false,
|
|
468
|
+
};
|
|
469
|
+
} catch {
|
|
470
|
+
return { skips: [], cooldowns: {}, parseError: true };
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* Record that an offer was just emitted for `cwd`, starting the cooldown.
|
|
476
|
+
* Preserves the existing skips array. Best-effort.
|
|
477
|
+
*/
|
|
478
|
+
export function recordSuggestionCooldown(hypoDir, cwd, now = new Date()) {
|
|
479
|
+
try {
|
|
480
|
+
const cacheDir = join(hypoDir, '.cache');
|
|
481
|
+
if (!existsSync(cacheDir)) mkdirSync(cacheDir, { recursive: true });
|
|
482
|
+
const { skips, cooldowns } = readProjectSuggestions(hypoDir);
|
|
483
|
+
cooldowns[cwd] = now.toISOString();
|
|
484
|
+
writeFileSync(
|
|
485
|
+
projectSuggestionsPath(hypoDir),
|
|
486
|
+
JSON.stringify({ skips, cooldowns }, null, 2) + '\n',
|
|
487
|
+
);
|
|
488
|
+
} catch {
|
|
489
|
+
// best-effort
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/** True when `cwd` carries one of the recognized project-root markers. */
|
|
494
|
+
export function cwdHasProjectMarker(cwd) {
|
|
495
|
+
return PROJECT_MARKERS.some((m) => existsSync(join(cwd, m)));
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* Decide whether SessionStart/CwdChanged should offer to create a project for
|
|
500
|
+
* `cwd`. The caller MUST have already confirmed `cwd` matches no project's
|
|
501
|
+
* `working_dir` (the hook's MISS branch); this evaluates the remaining ADR 0023
|
|
502
|
+
* trigger conditions: (a) cwd is a git repo, (b) carries a project marker
|
|
503
|
+
* (`.git` alone is a weak signal — §8.11), (c) not in cooldown, (d) not a cwd
|
|
504
|
+
* the user previously declined. A corrupt store stays silent (doctor surfaces).
|
|
505
|
+
*
|
|
506
|
+
* @param {string} cwd
|
|
507
|
+
* @param {string} [hypoDir]
|
|
508
|
+
* @param {number} [now] epoch ms, injectable for tests
|
|
509
|
+
* @returns {boolean}
|
|
510
|
+
*/
|
|
511
|
+
export function shouldSuggestProjectCreation(cwd, hypoDir = HYPO_DIR, now = Date.now()) {
|
|
512
|
+
if (!cwd) return false;
|
|
513
|
+
if (!existsSync(join(cwd, '.git'))) return false;
|
|
514
|
+
if (!cwdHasProjectMarker(cwd)) return false;
|
|
515
|
+
const { skips, cooldowns, parseError } = readProjectSuggestions(hypoDir);
|
|
516
|
+
if (parseError) return false;
|
|
517
|
+
if (skips.some((s) => s && s.cwd === cwd)) return false;
|
|
518
|
+
const ts = cooldowns[cwd];
|
|
519
|
+
if (ts) {
|
|
520
|
+
const t = Date.parse(ts);
|
|
521
|
+
if (Number.isFinite(t) && now - t < SUGGESTION_COOLDOWN_MS) return false;
|
|
522
|
+
}
|
|
523
|
+
return true;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* Build the §8.11 auto-project offer line for a cwd. The display name is the
|
|
528
|
+
* cwd basename, which is attacker-influenced (a directory name can contain
|
|
529
|
+
* newlines/control chars on Unix). Strip control characters and length-cap it
|
|
530
|
+
* so a crafted dir name cannot spoof extra instructions in additionalContext
|
|
531
|
+
* (codex review 2026-05-22).
|
|
532
|
+
*/
|
|
533
|
+
export function buildProjectSuggestionLine(cwd) {
|
|
534
|
+
// Replace any control char (code < 0x20 or === 0x7F) with a space so a
|
|
535
|
+
// crafted dir name cannot inject newlines/instructions into additionalContext
|
|
536
|
+
// (codex review 2026-05-22). Done by codepoint to keep control bytes out of
|
|
537
|
+
// this source file.
|
|
538
|
+
const sanitized = Array.from(basename(cwd))
|
|
539
|
+
.map((ch) => {
|
|
540
|
+
const code = ch.codePointAt(0);
|
|
541
|
+
return code < 0x20 || code === 0x7f ? ' ' : ch;
|
|
542
|
+
})
|
|
543
|
+
.join('');
|
|
544
|
+
const safe = sanitized.slice(0, 80).trim() || 'project';
|
|
545
|
+
return `[WIKI: cwd '${safe}'에 매칭되는 프로젝트가 없습니다. 자동 생성할까요? (Y/n)]`;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// ── clear-marker (ADR 0022 amendment 2026-05-14) ────────────
|
|
549
|
+
// `/clear` cannot be blocked (no UserPromptSubmit fire). The only intervention
|
|
550
|
+
// point is the SessionEnd(reason='clear') → SessionStart(source='clear') pair:
|
|
551
|
+
// SessionEnd writes `.cache/clear-marker.json` with the dying session's id +
|
|
552
|
+
// transcript path; SessionStart on `source=clear` reads, injects a recovery
|
|
553
|
+
// nudge into additionalContext, and unlinks (one-shot). A 7-day stale guard
|
|
554
|
+
// prevents an orphaned marker (SessionEnd fired but new session never began)
|
|
555
|
+
// from polluting an unrelated later session.
|
|
556
|
+
|
|
557
|
+
const CLEAR_MARKER_STALE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
558
|
+
|
|
559
|
+
/** @returns {string} path to the clear-marker file for a wiki root. */
|
|
560
|
+
function clearMarkerPath(hypoDir) {
|
|
561
|
+
return join(hypoDir, '.cache', 'clear-marker.json');
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/**
|
|
565
|
+
* Persist the dying session's identity so the next SessionStart(source=clear)
|
|
566
|
+
* can issue a recovery nudge. Single-file by design (see ADR 0022 amendment):
|
|
567
|
+
* /clear is a single-client UX action, multi-marker disambiguation buys no
|
|
568
|
+
* safety and breaks the 1-of-1 read-and-unlink contract.
|
|
569
|
+
*
|
|
570
|
+
* Best-effort: a failure here only loses the recovery nudge, never the user's
|
|
571
|
+
* `/clear` itself.
|
|
572
|
+
*
|
|
573
|
+
* @param {string} hypoDir
|
|
574
|
+
* @param {{prev_session_id: string, prev_transcript_path: string, prev_cwd?: string}} info
|
|
575
|
+
*/
|
|
576
|
+
export function writeClearMarker(hypoDir, info) {
|
|
577
|
+
try {
|
|
578
|
+
const cacheDir = join(hypoDir, '.cache');
|
|
579
|
+
if (!existsSync(cacheDir)) mkdirSync(cacheDir, { recursive: true });
|
|
580
|
+
const payload = {
|
|
581
|
+
prev_session_id: info.prev_session_id || null,
|
|
582
|
+
prev_transcript_path: info.prev_transcript_path || null,
|
|
583
|
+
prev_cwd: info.prev_cwd || null,
|
|
584
|
+
ts: new Date().toISOString(),
|
|
585
|
+
};
|
|
586
|
+
writeFileSync(clearMarkerPath(hypoDir), JSON.stringify(payload) + '\n');
|
|
587
|
+
} catch (err) {
|
|
588
|
+
process.stderr.write(`[hypo] clear-marker write failed: ${err?.message || err}\n`);
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* Read the clear-marker if present and not stale (>7 days). Returns null when
|
|
594
|
+
* absent, unreadable, or expired. Stale markers are unlinked here so a single
|
|
595
|
+
* SessionStart cleans them up — no separate cron needed.
|
|
596
|
+
*
|
|
597
|
+
* @param {string} hypoDir
|
|
598
|
+
* @returns {object|null}
|
|
599
|
+
*/
|
|
600
|
+
export function readClearMarker(hypoDir) {
|
|
601
|
+
const path = clearMarkerPath(hypoDir);
|
|
602
|
+
if (!existsSync(path)) return null;
|
|
603
|
+
try {
|
|
604
|
+
const data = JSON.parse(readFileSync(path, 'utf-8'));
|
|
605
|
+
const ts = Date.parse(data?.ts || '');
|
|
606
|
+
if (!Number.isFinite(ts) || Date.now() - ts > CLEAR_MARKER_STALE_MS) {
|
|
607
|
+
rmSync(path, { force: true });
|
|
608
|
+
return null;
|
|
609
|
+
}
|
|
610
|
+
return data;
|
|
611
|
+
} catch (err) {
|
|
612
|
+
// Corrupt marker: emit a debug line AND unlink so the next SessionStart
|
|
613
|
+
// does not log the same parse error forever (self-cleanup invariant).
|
|
614
|
+
process.stderr.write(`[hypo] clear-marker read failed: ${err?.message || err}\n`);
|
|
615
|
+
try {
|
|
616
|
+
rmSync(path, { force: true });
|
|
617
|
+
} catch {
|
|
618
|
+
// best-effort
|
|
619
|
+
}
|
|
620
|
+
return null;
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/** Delete the clear-marker. One-shot contract — caller is SessionStart. */
|
|
625
|
+
export function clearClearMarker(hypoDir) {
|
|
626
|
+
try {
|
|
627
|
+
rmSync(clearMarkerPath(hypoDir), { force: true });
|
|
628
|
+
} catch {
|
|
629
|
+
// best-effort
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// ── session-closed marker (ADR 0022 amendment 2026-05-19) ────
|
|
634
|
+
// Per-session marker proving session-close completed. Stop hook
|
|
635
|
+
// (`hypo-auto-minimal-crystallize`) reads it; `scripts/crystallize.mjs` writes
|
|
636
|
+
// it after a verified close. Per-session (not per-day) precision resolves the
|
|
637
|
+
// codex BLOCKER from 2026-05-14: log.md date-level check false-passes when a
|
|
638
|
+
// later session reuses an earlier session's entry on the same day.
|
|
639
|
+
//
|
|
640
|
+
// Writer authority lives in crystallize, NOT this hook: the hook only checks
|
|
641
|
+
// presence. See ADR 0022 amendment 2026-05-19 Q2 for the split rationale.
|
|
642
|
+
|
|
643
|
+
const SESSION_CLOSED_MARKER_STALE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
644
|
+
|
|
645
|
+
/** Sanitize session_id for filesystem use — Claude session_ids are UUIDs but
|
|
646
|
+
* defend against accidental path traversal regardless. */
|
|
647
|
+
function sanitizeSessionId(sessionId) {
|
|
648
|
+
return String(sessionId)
|
|
649
|
+
.replace(/[^A-Za-z0-9._-]/g, '_')
|
|
650
|
+
.slice(0, 128);
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/** @returns {string} path to the session-closed marker for a given session_id. */
|
|
654
|
+
export function sessionClosedMarkerPath(hypoDir, sessionId) {
|
|
655
|
+
return join(hypoDir, '.cache', `session-closed-${sanitizeSessionId(sessionId)}.marker`);
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/**
|
|
659
|
+
* Persist a per-session close proof. Caller MUST verify
|
|
660
|
+
* `sessionCloseFileStatus(hypoDir).ok` before invoking — this helper does NOT
|
|
661
|
+
* re-check; that's the writer's contract (crystallize.mjs).
|
|
662
|
+
*
|
|
663
|
+
* Best-effort: stderr debug line on failure, no exception propagation.
|
|
664
|
+
*
|
|
665
|
+
* @param {string} hypoDir
|
|
666
|
+
* @param {string} sessionId
|
|
667
|
+
* @param {{project?: string, transcript_path?: string}} info
|
|
668
|
+
*/
|
|
669
|
+
export function writeSessionClosedMarker(hypoDir, sessionId, info = {}) {
|
|
670
|
+
if (!sessionId) return;
|
|
671
|
+
try {
|
|
672
|
+
const cacheDir = join(hypoDir, '.cache');
|
|
673
|
+
if (!existsSync(cacheDir)) mkdirSync(cacheDir, { recursive: true });
|
|
674
|
+
const payload = {
|
|
675
|
+
session_id: sessionId,
|
|
676
|
+
project: info.project || null,
|
|
677
|
+
transcript_path: info.transcript_path || null,
|
|
678
|
+
closed_at: new Date().toISOString(),
|
|
679
|
+
verification: 'session-close-file-status:ok',
|
|
680
|
+
};
|
|
681
|
+
writeFileSync(sessionClosedMarkerPath(hypoDir, sessionId), JSON.stringify(payload) + '\n');
|
|
682
|
+
} catch (err) {
|
|
683
|
+
process.stderr.write(`[hypo] session-closed marker write failed: ${err?.message || err}\n`);
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
/**
|
|
688
|
+
* Read the session-closed marker for `sessionId` if present and not stale
|
|
689
|
+
* (>7 days). Returns null when absent, unreadable, or expired. Stale/corrupt
|
|
690
|
+
* markers are unlinked here so a single Stop hook call cleans them up — no
|
|
691
|
+
* separate sweeper needed (mirrors clear-marker self-cleanup invariant).
|
|
692
|
+
*
|
|
693
|
+
* @param {string} hypoDir
|
|
694
|
+
* @param {string} sessionId
|
|
695
|
+
* @returns {object|null}
|
|
696
|
+
*/
|
|
697
|
+
export function readSessionClosedMarker(hypoDir, sessionId) {
|
|
698
|
+
if (!sessionId) return null;
|
|
699
|
+
const path = sessionClosedMarkerPath(hypoDir, sessionId);
|
|
700
|
+
if (!existsSync(path)) return null;
|
|
701
|
+
try {
|
|
702
|
+
const data = JSON.parse(readFileSync(path, 'utf-8'));
|
|
703
|
+
const ts = Date.parse(data?.closed_at || '');
|
|
704
|
+
if (!Number.isFinite(ts) || Date.now() - ts > SESSION_CLOSED_MARKER_STALE_MS) {
|
|
705
|
+
rmSync(path, { force: true });
|
|
706
|
+
return null;
|
|
707
|
+
}
|
|
708
|
+
return data;
|
|
709
|
+
} catch (err) {
|
|
710
|
+
process.stderr.write(`[hypo] session-closed marker read failed: ${err?.message || err}\n`);
|
|
711
|
+
try {
|
|
712
|
+
rmSync(path, { force: true });
|
|
713
|
+
} catch {
|
|
714
|
+
// best-effort
|
|
715
|
+
}
|
|
716
|
+
return null;
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/** Delete a session-closed marker. Test/maintenance helper. */
|
|
721
|
+
export function clearSessionClosedMarker(hypoDir, sessionId) {
|
|
722
|
+
if (!sessionId) return;
|
|
723
|
+
try {
|
|
724
|
+
rmSync(sessionClosedMarkerPath(hypoDir, sessionId), { force: true });
|
|
725
|
+
} catch {
|
|
726
|
+
// best-effort
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
// ── transcript activity heuristic (ADR 0022 amendment 2026-05-19) ──
|
|
731
|
+
// Substantial-session gate for the Stop hook: a session that performed at least
|
|
732
|
+
// one mutation tool call (Edit / Write / MultiEdit / NotebookEdit) is "worth"
|
|
733
|
+
// blocking on for session-close. Pure Q&A / read-only sessions skip the block.
|
|
734
|
+
//
|
|
735
|
+
// Bash is intentionally excluded — running tests would otherwise trigger
|
|
736
|
+
// block. Future fix may broaden to read-heavy sessions (Grep ≥ N).
|
|
737
|
+
|
|
738
|
+
const MUTATING_TOOL_NAMES = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit']);
|
|
739
|
+
|
|
740
|
+
/** Mirror of `scripts/session-audit.mjs` extractToolNames: handles both top-level
|
|
741
|
+
* `tool_use` entries (legacy fixtures) and nested `message.content[].tool_use`
|
|
742
|
+
* blocks (real Claude Code transcripts). */
|
|
743
|
+
function extractTranscriptToolNames(entry) {
|
|
744
|
+
const names = [];
|
|
745
|
+
if (!entry || typeof entry !== 'object') return names;
|
|
746
|
+
if (entry.type === 'tool_use') {
|
|
747
|
+
const n = entry.name || entry.tool_name;
|
|
748
|
+
if (n) names.push(n);
|
|
749
|
+
} else if (entry.tool_name || entry.name) {
|
|
750
|
+
if (entry.type === undefined || entry.type === 'tool_use') {
|
|
751
|
+
const n = entry.tool_name || entry.name;
|
|
752
|
+
if (n) names.push(n);
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
const content = entry.message?.content ?? (Array.isArray(entry.content) ? entry.content : null);
|
|
756
|
+
if (Array.isArray(content)) {
|
|
757
|
+
for (const block of content) {
|
|
758
|
+
if (block && typeof block === 'object' && block.type === 'tool_use') {
|
|
759
|
+
const n = block.name || block.tool_name;
|
|
760
|
+
if (n) names.push(n);
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
return names;
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
/**
|
|
768
|
+
* True if the JSONL transcript at `transcriptPath` contains ≥1 mutation
|
|
769
|
+
* tool_use (Edit/Write/MultiEdit/NotebookEdit).
|
|
770
|
+
*
|
|
771
|
+
* Granularity:
|
|
772
|
+
* • Whole-file unreadable / missing path → returns false (fail-open).
|
|
773
|
+
* • Per-line malformed JSON → that line is skipped, scan continues. Real
|
|
774
|
+
* transcripts occasionally carry truncated lines; one bad line must not
|
|
775
|
+
* hide a clearly-mutating session that follows. (Codex Worker-2 CONCERN
|
|
776
|
+
* resolved 2026-05-19: line-level skip is the intended contract.)
|
|
777
|
+
*
|
|
778
|
+
* @param {string|null|undefined} transcriptPath
|
|
779
|
+
* @returns {boolean}
|
|
780
|
+
*/
|
|
781
|
+
export function hasMutatingTranscriptActivity(transcriptPath) {
|
|
782
|
+
if (!transcriptPath || typeof transcriptPath !== 'string') return false;
|
|
783
|
+
if (!existsSync(transcriptPath)) return false;
|
|
784
|
+
let raw;
|
|
785
|
+
try {
|
|
786
|
+
raw = readFileSync(transcriptPath, 'utf-8');
|
|
787
|
+
} catch {
|
|
788
|
+
return false;
|
|
789
|
+
}
|
|
790
|
+
for (const line of raw.split('\n')) {
|
|
791
|
+
const trimmed = line.trim();
|
|
792
|
+
if (!trimmed) continue;
|
|
793
|
+
let entry;
|
|
794
|
+
try {
|
|
795
|
+
entry = JSON.parse(trimmed);
|
|
796
|
+
} catch {
|
|
797
|
+
continue;
|
|
798
|
+
}
|
|
799
|
+
for (const name of extractTranscriptToolNames(entry)) {
|
|
800
|
+
if (MUTATING_TOOL_NAMES.has(name)) return true;
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
return false;
|
|
804
|
+
}
|
|
805
|
+
|
|
117
806
|
// ── session-close checklist ────────────────────────────────────────────────
|
|
118
807
|
|
|
119
808
|
/**
|
|
@@ -152,6 +841,86 @@ export function isCompactCommand(prompt) {
|
|
|
152
841
|
return prompt === '/compact' || /^\/compact(\s|$)/.test(prompt);
|
|
153
842
|
}
|
|
154
843
|
|
|
844
|
+
/** Returns true if the prompt is a /clear command invocation. */
|
|
845
|
+
export function isClearCommand(prompt) {
|
|
846
|
+
return prompt === '/clear' || /^\/clear(\s|$)/.test(prompt);
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
/** Returns true if the prompt is either /compact or /clear (ADR 0022 Layer 2, fix #25). */
|
|
850
|
+
export function isCompactOrClearCommand(prompt) {
|
|
851
|
+
return isCompactCommand(prompt) || isClearCommand(prompt);
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
/**
|
|
855
|
+
* Extract recent user-role message text from a JSONL transcript (last `tailN`
|
|
856
|
+
* lines). Promoted from hypo-personal-check.mjs (fix #27 PR-C) so both the
|
|
857
|
+
* PreCompact gate and the Stop-chain Layer 3 hook share one close-intent
|
|
858
|
+
* signal source. Claude Code transcript format: each line is
|
|
859
|
+
* `{ type:"user", message:{ role:"user", content: ... } }`; the older
|
|
860
|
+
* top-level `{ role, content }` shape is also accepted.
|
|
861
|
+
*
|
|
862
|
+
* @param {string} transcriptPath
|
|
863
|
+
* @param {number} tailN how many trailing lines to scan (default 30)
|
|
864
|
+
* @returns {string} newline-joined user text, or '' on any failure (fail-open)
|
|
865
|
+
*/
|
|
866
|
+
export function extractUserMessages(transcriptPath, tailN = 30) {
|
|
867
|
+
try {
|
|
868
|
+
const lines = readFileSync(transcriptPath, 'utf-8').split('\n');
|
|
869
|
+
const tail = lines.slice(-tailN);
|
|
870
|
+
return tail
|
|
871
|
+
.map((line) => {
|
|
872
|
+
try {
|
|
873
|
+
const obj = JSON.parse(line);
|
|
874
|
+
const msg = obj.message ?? obj;
|
|
875
|
+
const role = msg.role ?? obj.role ?? obj.type;
|
|
876
|
+
if (role !== 'user') return '';
|
|
877
|
+
const content = msg.content ?? obj.content;
|
|
878
|
+
return typeof content === 'string' ? content : JSON.stringify(content);
|
|
879
|
+
} catch {
|
|
880
|
+
return '';
|
|
881
|
+
}
|
|
882
|
+
})
|
|
883
|
+
.join('\n');
|
|
884
|
+
} catch {
|
|
885
|
+
return '';
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
/**
|
|
890
|
+
* Returns true if the text contains a natural-language session-close signal.
|
|
891
|
+
* Scans Korean and English close patterns. Designed for transcript user-message
|
|
892
|
+
* text — favours low false-positive rate over recall.
|
|
893
|
+
*
|
|
894
|
+
* Examples that match: "세션 마무리하자", "오늘 여기까지", "wrap up", "signing off".
|
|
895
|
+
* Examples that do NOT match: "이 함수 마무리하자", "wrap up this PR".
|
|
896
|
+
*/
|
|
897
|
+
export function isClosePattern(text) {
|
|
898
|
+
if (!text || typeof text !== 'string') return false;
|
|
899
|
+
const krPatterns = [
|
|
900
|
+
/세션\s*(끝|종료|마무리)/,
|
|
901
|
+
/오늘\s*은?\s*(여기|작업|세션).*(끝|마치|마무리|종료)/,
|
|
902
|
+
// 여기까지: requires no continuation action word (e.g. 여기까지 구현해줘 is not a close signal)
|
|
903
|
+
/여기(서)?까지(?!\s*(?:구현|작성|완성|수정|변경|추가|삭제|테스트|확인|검토|해줘|해야|하고|하면))/,
|
|
904
|
+
/이만\s*(마치|끝|종료|마무리)/,
|
|
905
|
+
// 작업 종료/마무리: requires verb ending, not noun modifier (e.g. 작업 종료 조건을 is not a close signal)
|
|
906
|
+
/작업\s*(?:마무리|종료)\s*(?:하자|할게|하겠|했어|임)/,
|
|
907
|
+
/오늘은?\s*여기/,
|
|
908
|
+
/그만\s*(하자|할게|하겠|합시다)/,
|
|
909
|
+
/슬슬\s*(마무리|종료)/,
|
|
910
|
+
/오늘은?\s*이만/,
|
|
911
|
+
];
|
|
912
|
+
const enPatterns = [
|
|
913
|
+
// wrap up: requires session-level context or sentence-end, not code-level objects
|
|
914
|
+
/wrap(?:ping)?\s+up(?!\s+(?:this|the)\s+(?:pr|issue|bug|task|function|component|module|feature|code|test)\b)/i,
|
|
915
|
+
/done\s+for\s+(?:today|now|the\s+day)/i,
|
|
916
|
+
/that'?s?\s+(?:all|it)\s+for\s+(?:today|now|the\s+day)/i,
|
|
917
|
+
/signing\s+off/i,
|
|
918
|
+
/end(?:ing)?\s+(?:the|this)\s+(?:session|work|day)/i,
|
|
919
|
+
/close\s+(?:the|this)\s+session/i,
|
|
920
|
+
];
|
|
921
|
+
return [...krPatterns, ...enPatterns].some((re) => re.test(text));
|
|
922
|
+
}
|
|
923
|
+
|
|
155
924
|
/**
|
|
156
925
|
* Build hook output for Claude Code (additionalContext channel).
|
|
157
926
|
* Codex hooks write systemMessage directly in their own files.
|
|
@@ -174,12 +943,12 @@ export function buildOutput(context, extra = {}) {
|
|
|
174
943
|
* @returns {string}
|
|
175
944
|
*/
|
|
176
945
|
export function formatGrowthMetrics(mode, stats) {
|
|
177
|
-
const a = Number(stats?.addedPages)
|
|
946
|
+
const a = Number(stats?.addedPages) || 0;
|
|
178
947
|
const u = Number(stats?.updatedPages) || 0;
|
|
179
948
|
const w = Number(stats?.newWikilinks) || 0;
|
|
180
949
|
if (a === 0 && u === 0 && w === 0) return '';
|
|
181
950
|
const body = `+${a} pages, ~${u} updated, ${w} wikilinks`;
|
|
182
|
-
if (mode === 'stop')
|
|
951
|
+
if (mode === 'stop') return `[hypo] ${body}`;
|
|
183
952
|
if (mode === 'start') return `[hypo] 직전 세션: ${body}. 이어서 볼까요?`;
|
|
184
953
|
return '';
|
|
185
954
|
}
|
|
@@ -201,9 +970,13 @@ export function computeSessionGrowth(hypoDir) {
|
|
|
201
970
|
// more expensive `git diff HEAD --unified=0` — Stop hook P95 win.
|
|
202
971
|
// `-uall` expands untracked directories so a brand-new `pages/new.md`
|
|
203
972
|
// isn't hidden behind a single `?? pages/` line.
|
|
204
|
-
const porcelain = spawnSync('git', ['-C', hypoDir, 'status', '--porcelain', '-uall'], {
|
|
973
|
+
const porcelain = spawnSync('git', ['-C', hypoDir, 'status', '--porcelain', '-uall'], {
|
|
974
|
+
encoding: 'utf-8',
|
|
975
|
+
timeout: 5000,
|
|
976
|
+
});
|
|
205
977
|
if (porcelain.status !== 0) return empty;
|
|
206
|
-
let addedPages = 0,
|
|
978
|
+
let addedPages = 0,
|
|
979
|
+
updatedPages = 0;
|
|
207
980
|
let hasTrackedMdChange = false;
|
|
208
981
|
const untrackedMd = [];
|
|
209
982
|
// Growth metrics describe wiki page activity, so restrict to the two
|
|
@@ -213,17 +986,22 @@ export function computeSessionGrowth(hypoDir) {
|
|
|
213
986
|
file.endsWith('.md') && (file.startsWith('pages/') || file.startsWith('projects/'));
|
|
214
987
|
for (const line of (porcelain.stdout || '').split('\n')) {
|
|
215
988
|
if (!line) continue;
|
|
216
|
-
const xy
|
|
989
|
+
const xy = line.slice(0, 2);
|
|
217
990
|
const file = line.slice(3).replace(/^"|"$/g, '').split(' -> ').pop().trim();
|
|
218
991
|
if (!inPagesScope(file)) continue;
|
|
219
|
-
if (xy === '??') {
|
|
992
|
+
if (xy === '??') {
|
|
993
|
+
untrackedMd.push(file);
|
|
994
|
+
addedPages++;
|
|
995
|
+
continue;
|
|
996
|
+
}
|
|
220
997
|
hasTrackedMdChange = true;
|
|
221
998
|
if (xy.includes('A')) addedPages++;
|
|
222
999
|
else if (xy.includes('M') || xy.includes('R')) updatedPages++;
|
|
223
1000
|
}
|
|
224
1001
|
if (!hasTrackedMdChange && untrackedMd.length === 0) return empty;
|
|
225
1002
|
|
|
226
|
-
let plus = 0,
|
|
1003
|
+
let plus = 0,
|
|
1004
|
+
minus = 0;
|
|
227
1005
|
if (hasTrackedMdChange) {
|
|
228
1006
|
// pathspec keeps non-Markdown / out-of-scope diffs from polluting the
|
|
229
1007
|
// wikilink count. Without it, a `[[…]]` string in a script.js diff was
|
|
@@ -231,14 +1009,14 @@ export function computeSessionGrowth(hypoDir) {
|
|
|
231
1009
|
const diff = spawnSync(
|
|
232
1010
|
'git',
|
|
233
1011
|
['-C', hypoDir, 'diff', 'HEAD', '--unified=0', '--', 'pages/', 'projects/'],
|
|
234
|
-
{ encoding: 'utf-8', timeout: 10000 }
|
|
1012
|
+
{ encoding: 'utf-8', timeout: 10000 },
|
|
235
1013
|
);
|
|
236
1014
|
if (diff.status === 0) {
|
|
237
1015
|
for (const line of (diff.stdout || '').split('\n')) {
|
|
238
1016
|
if (line.startsWith('+++') || line.startsWith('---')) continue;
|
|
239
1017
|
const matches = line.match(/\[\[[^\]\n]+\]\]/g);
|
|
240
1018
|
if (!matches) continue;
|
|
241
|
-
if (line.startsWith('+')) plus
|
|
1019
|
+
if (line.startsWith('+')) plus += matches.length;
|
|
242
1020
|
else if (line.startsWith('-')) minus += matches.length;
|
|
243
1021
|
}
|
|
244
1022
|
}
|
|
@@ -260,14 +1038,16 @@ export function computeSessionGrowth(hypoDir) {
|
|
|
260
1038
|
// Inlined here so deployed hooks (~/.claude/hooks/) don't need scripts/lib/.
|
|
261
1039
|
|
|
262
1040
|
function _globToRegex(glob) {
|
|
263
|
-
return new RegExp(
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
1041
|
+
return new RegExp(
|
|
1042
|
+
'^' +
|
|
1043
|
+
glob
|
|
1044
|
+
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
1045
|
+
.replace(/\*\*/g, '\x00')
|
|
1046
|
+
.replace(/\*/g, '[^/]*')
|
|
1047
|
+
.replace(/\?/g, '[^/]')
|
|
1048
|
+
.replace(/\x00/g, '.*') +
|
|
1049
|
+
'$',
|
|
1050
|
+
);
|
|
271
1051
|
}
|
|
272
1052
|
|
|
273
1053
|
export function loadHypoIgnore(hypoDir) {
|
|
@@ -275,8 +1055,8 @@ export function loadHypoIgnore(hypoDir) {
|
|
|
275
1055
|
if (!existsSync(ignorePath)) return [];
|
|
276
1056
|
return readFileSync(ignorePath, 'utf-8')
|
|
277
1057
|
.split('\n')
|
|
278
|
-
.map(l => l.trim())
|
|
279
|
-
.filter(l => l && !l.startsWith('#'));
|
|
1058
|
+
.map((l) => l.trim())
|
|
1059
|
+
.filter((l) => l && !l.startsWith('#'));
|
|
280
1060
|
}
|
|
281
1061
|
|
|
282
1062
|
export function isIgnored(filePath, hypoDir, patterns) {
|