claudenv 1.2.5 → 1.3.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/src/doctor.js ADDED
@@ -0,0 +1,175 @@
1
+ /**
2
+ * CLI: `claudenv doctor` — health-check the claudenv setup.
3
+ *
4
+ * Prints a compact OK / WARN / FAIL line per check; exits 0 if no FAILs.
5
+ */
6
+
7
+ import { stat, readFile, readdir } from 'node:fs/promises';
8
+ import { join } from 'node:path';
9
+ import { homedir } from 'node:os';
10
+ import { execSync } from 'node:child_process';
11
+ import {
12
+ claudenvHome,
13
+ memoriesDir,
14
+ globalDecisionsDir,
15
+ indexMdPath,
16
+ } from './memory-paths.js';
17
+
18
+ const OK = '[OK] ';
19
+ const WARN = '[WARN]';
20
+ const FAIL = '[FAIL]';
21
+
22
+ async function pathExists(p) {
23
+ try {
24
+ await stat(p);
25
+ return true;
26
+ } catch {
27
+ return false;
28
+ }
29
+ }
30
+
31
+ async function nodeVersionCheck() {
32
+ const major = parseInt(process.version.slice(1).split('.')[0], 10);
33
+ if (major >= 20) return { status: OK, msg: `Node.js ${process.version}` };
34
+ return { status: FAIL, msg: `Node.js ${process.version} — require >= 20` };
35
+ }
36
+
37
+ async function claudeCliCheck() {
38
+ try {
39
+ const out = execSync('claude --version', {
40
+ encoding: 'utf-8',
41
+ stdio: ['pipe', 'pipe', 'pipe'],
42
+ }).trim();
43
+ return { status: OK, msg: `claude CLI: ${out}` };
44
+ } catch {
45
+ // WARN, not FAIL — claude CLI is only required for `claudenv loop`.
46
+ // memory / decisions / canon CLI work fine without it. FAIL'ing here
47
+ // would also break `claudenv doctor` in CI environments where claude
48
+ // isn't installed.
49
+ return {
50
+ status: WARN,
51
+ msg: 'claude CLI not found — required for `claudenv loop`. Install from https://docs.anthropic.com/en/docs/claude-code',
52
+ };
53
+ }
54
+ }
55
+
56
+ async function claudenvHomeCheck() {
57
+ if (await pathExists(claudenvHome())) {
58
+ return { status: OK, msg: `~/.claudenv/ at ${claudenvHome()}` };
59
+ }
60
+ return {
61
+ status: WARN,
62
+ msg: `~/.claudenv/ not initialised — run \`claudenv memory init\``,
63
+ };
64
+ }
65
+
66
+ async function memoriesLayoutCheck() {
67
+ const required = [
68
+ memoriesDir(),
69
+ globalDecisionsDir(),
70
+ join(memoriesDir(), 'canon'),
71
+ ];
72
+ const missing = [];
73
+ for (const p of required) {
74
+ if (!(await pathExists(p))) missing.push(p);
75
+ }
76
+ if (missing.length === 0) {
77
+ return { status: OK, msg: 'memories/{decisions,canon}/ present' };
78
+ }
79
+ return {
80
+ status: WARN,
81
+ msg: `Missing: ${missing.map((p) => p.replace(claudenvHome(), '~/.claudenv')).join(', ')}`,
82
+ };
83
+ }
84
+
85
+ async function indexSizeCheck() {
86
+ if (!(await pathExists(indexMdPath()))) {
87
+ return { status: WARN, msg: 'INDEX.md absent — run `claudenv memory index`' };
88
+ }
89
+ const text = await readFile(indexMdPath(), 'utf-8');
90
+ const lines = text.split('\n').length;
91
+ if (lines <= 100) return { status: OK, msg: `INDEX.md ${lines} lines (cap 100)` };
92
+ return { status: WARN, msg: `INDEX.md ${lines} lines — consider rotation` };
93
+ }
94
+
95
+ async function vibeDecisionsCheck() {
96
+ const skill = join(homedir(), '.claude', 'skills', 'vibe-decisions', 'SKILL.md');
97
+ if (await pathExists(skill)) {
98
+ return { status: OK, msg: 'vibe-decisions skill installed globally' };
99
+ }
100
+ return {
101
+ status: WARN,
102
+ msg: 'vibe-decisions skill not installed — run `claudenv install --force`',
103
+ };
104
+ }
105
+
106
+ async function projectHooksCheck() {
107
+ const settingsPath = join(process.cwd(), '.claude', 'settings.json');
108
+ if (!(await pathExists(settingsPath))) {
109
+ return {
110
+ status: WARN,
111
+ msg: 'no .claude/settings.json in current project (run `/claudenv` in Claude Code)',
112
+ };
113
+ }
114
+ let parsed;
115
+ try {
116
+ parsed = JSON.parse(await readFile(settingsPath, 'utf-8'));
117
+ } catch {
118
+ return { status: FAIL, msg: `.claude/settings.json is not valid JSON` };
119
+ }
120
+ const post = parsed?.hooks?.PostToolUse || [];
121
+ const hasLogger = post.some((b) =>
122
+ (b.hooks || []).some((h) => h.command && h.command.includes('decisions-logger'))
123
+ );
124
+ if (hasLogger) return { status: OK, msg: 'decisions-logger hook registered' };
125
+ return {
126
+ status: WARN,
127
+ msg: 'decisions-logger hook NOT registered in this project',
128
+ };
129
+ }
130
+
131
+ async function pythonModuleCheck() {
132
+ try {
133
+ execSync('python3 -c "import claudenv_memory"', { stdio: ['pipe', 'pipe', 'pipe'] });
134
+ return { status: OK, msg: 'claudenv-memory (Python) importable' };
135
+ } catch {
136
+ return {
137
+ status: WARN,
138
+ msg: 'claudenv-memory (Python) not installed — optional, `pip install claudenv-memory`',
139
+ };
140
+ }
141
+ }
142
+
143
+ async function countDecisionsCheck() {
144
+ let count = 0;
145
+ try {
146
+ const names = await readdir(globalDecisionsDir());
147
+ count = names.filter((n) => n.endsWith('.md')).length;
148
+ } catch {
149
+ /* no dir */
150
+ }
151
+ return { status: OK, msg: `${count} global decisions logged` };
152
+ }
153
+
154
+ export async function runDoctor() {
155
+ const checks = [
156
+ await nodeVersionCheck(),
157
+ await claudeCliCheck(),
158
+ await claudenvHomeCheck(),
159
+ await memoriesLayoutCheck(),
160
+ await indexSizeCheck(),
161
+ await vibeDecisionsCheck(),
162
+ await projectHooksCheck(),
163
+ await pythonModuleCheck(),
164
+ await countDecisionsCheck(),
165
+ ];
166
+
167
+ let hasFail = false;
168
+ const lines = [];
169
+ for (const c of checks) {
170
+ lines.push(`${c.status} ${c.msg}`);
171
+ if (c.status === FAIL) hasFail = true;
172
+ }
173
+
174
+ return { lines, hasFail };
175
+ }
@@ -0,0 +1,183 @@
1
+ /**
2
+ * decisions-logger — PostToolUse hook handler for Write events.
3
+ *
4
+ * Validates files written to a memories/decisions/ directory (global or project).
5
+ * Path check FIRST — short-circuit non-decision writes without parsing content.
6
+ *
7
+ * Skill writes the file to the correct scope directly (see SKILL.md). This hook
8
+ * does NOT rewrite paths post-hoc — that creates ping-pong with future Claude
9
+ * turns. Hook only validates frontmatter, logs mismatches for `claudenv decisions
10
+ * fix --rescope`, and marks INDEX.md dirty for regeneration.
11
+ */
12
+
13
+ import { readFile, writeFile, appendFile, mkdir } from 'node:fs/promises';
14
+ import { join, basename } from 'node:path';
15
+ import { homedir } from 'node:os';
16
+
17
+ const DECISIONS_PATH_RE = /(?:^|\/)(?:memories|memories\/project)\/decisions\/[^/]+\.md$/;
18
+ const VIBE_MARKER = '__VIBE_DECISION__';
19
+ const REQUIRED_FIELDS = ['date', 'topic', 'chose', 'reason'];
20
+
21
+ // CLAUDENV_HOME is resolved lazily so tests can override via process.env.CLAUDENV_HOME.
22
+ function claudenvHome() {
23
+ return process.env.CLAUDENV_HOME || join(homedir(), '.claudenv');
24
+ }
25
+
26
+ /**
27
+ * Extract the fields we care about from a Claude Code PostToolUse event.
28
+ * Tolerant to schema variants — Claude Code has shipped a few.
29
+ *
30
+ * @param {object|null} event - Parsed JSON event from stdin (or null)
31
+ * @returns {{ filePath: string|null, content: string|null }}
32
+ */
33
+ export function readDecisionsLoggerInput(event) {
34
+ if (!event || typeof event !== 'object') return { filePath: null, content: null };
35
+
36
+ const toolInput = event.tool_input || event.toolInput || event.input || {};
37
+ const filePath = toolInput.file_path ?? toolInput.filePath ?? toolInput.path ?? null;
38
+ const content = toolInput.content ?? toolInput.text ?? null;
39
+
40
+ return { filePath, content };
41
+ }
42
+
43
+ /**
44
+ * Decide whether this Write event concerns a decision file.
45
+ * Path match OR vibe marker in content. Path is the cheaper check, do it first.
46
+ */
47
+ function isDecisionWrite({ filePath, content }) {
48
+ if (filePath && DECISIONS_PATH_RE.test(filePath)) return true;
49
+ if (content && content.includes(VIBE_MARKER)) return true;
50
+ return false;
51
+ }
52
+
53
+ /**
54
+ * Extract YAML frontmatter as a flat object. Minimal parser — handles
55
+ * `key: value` and `key: [a, b, c]` on a single line. Heavy structures fall
56
+ * through as raw strings; we only need to validate REQUIRED_FIELDS exist.
57
+ */
58
+ function parseFrontmatter(content) {
59
+ const match = /^---\n([\s\S]*?)\n---/.exec(content);
60
+ if (!match) return null;
61
+
62
+ const result = {};
63
+ for (const line of match[1].split('\n')) {
64
+ const m = /^([a-zA-Z_][a-zA-Z0-9_-]*):\s*(.*)$/.exec(line);
65
+ if (!m) continue;
66
+ const key = m[1];
67
+ let value = m[2].trim();
68
+ if (value.startsWith('[') && value.endsWith(']')) {
69
+ value = value
70
+ .slice(1, -1)
71
+ .split(',')
72
+ .map((s) => s.trim().replace(/^['"]|['"]$/g, ''))
73
+ .filter(Boolean);
74
+ } else {
75
+ value = value.replace(/^['"]|['"]$/g, '');
76
+ }
77
+ result[key] = value;
78
+ }
79
+ return result;
80
+ }
81
+
82
+ /**
83
+ * Detect scope from path alone (project subdirectory marker).
84
+ * Recognises both the conventional `~/.claudenv/memories/decisions/` and any
85
+ * path that lives directly under the current CLAUDENV_HOME (for tests).
86
+ */
87
+ function pathScope(filePath) {
88
+ if (!filePath) return null;
89
+ if (/\.claude\/memories\/decisions\//.test(filePath)) return 'project';
90
+ if (/\.claudenv\/memories\/decisions\//.test(filePath)) return 'global';
91
+ if (filePath.startsWith(join(claudenvHome(), 'memories', 'decisions'))) return 'global';
92
+ return null;
93
+ }
94
+
95
+ /**
96
+ * Append a line to ~/.claudenv/.log/decisions-logger.log.
97
+ * Best-effort — never throws.
98
+ */
99
+ async function logWarning(message) {
100
+ try {
101
+ const home = claudenvHome();
102
+ await mkdir(join(home, '.log'), { recursive: true });
103
+ await appendFile(
104
+ join(home, '.log', 'decisions-logger.log'),
105
+ `${new Date().toISOString()} ${message}\n`,
106
+ 'utf-8'
107
+ );
108
+ } catch {
109
+ /* swallow — logging must not break hook chain */
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Touch the index-dirty flag so regen-index (SessionEnd) or post-iteration
115
+ * fallback knows to regenerate INDEX.md.
116
+ */
117
+ async function markIndexDirty() {
118
+ try {
119
+ const home = claudenvHome();
120
+ await mkdir(home, { recursive: true });
121
+ await writeFile(join(home, '.index-dirty'), new Date().toISOString(), 'utf-8');
122
+ } catch {
123
+ /* swallow */
124
+ }
125
+ }
126
+
127
+ /**
128
+ * Main handler.
129
+ * @param {{filePath: string|null, content: string|null}} input
130
+ * @returns {Promise<{exitCode: number, message?: string}>}
131
+ */
132
+ export async function handleDecisionsLogger(input) {
133
+ // 1. Hot-path short-circuit: not a decision write → done.
134
+ if (!isDecisionWrite(input)) {
135
+ return { exitCode: 0 };
136
+ }
137
+
138
+ // 2. We need the actual file content. Hooks fire AFTER Write succeeds, so
139
+ // reading the file from disk is authoritative even if `content` was truncated.
140
+ let fileText = input.content;
141
+ if (input.filePath) {
142
+ try {
143
+ fileText = await readFile(input.filePath, 'utf-8');
144
+ } catch {
145
+ // file unreadable — fall back to event content (may be null)
146
+ }
147
+ }
148
+
149
+ if (!fileText) {
150
+ await logWarning(`No content for ${input.filePath ?? '<unknown>'}`);
151
+ return { exitCode: 0 };
152
+ }
153
+
154
+ // 3. Validate frontmatter.
155
+ const fm = parseFrontmatter(fileText);
156
+ if (!fm) {
157
+ await logWarning(
158
+ `${input.filePath ?? '<unknown>'} matched decisions/ path but has no YAML frontmatter`
159
+ );
160
+ return { exitCode: 0 };
161
+ }
162
+
163
+ const missing = REQUIRED_FIELDS.filter((f) => !(f in fm));
164
+ if (missing.length) {
165
+ await logWarning(
166
+ `${basename(input.filePath ?? '?')} missing required fields: ${missing.join(', ')}`
167
+ );
168
+ }
169
+
170
+ // 4. Scope vs path consistency check (warn-only; do not rewrite).
171
+ const declared = fm.scope || 'global';
172
+ const fromPath = pathScope(input.filePath);
173
+ if (fromPath && fromPath !== declared) {
174
+ await logWarning(
175
+ `${basename(input.filePath ?? '?')} declares scope=${declared} but lives in ${fromPath} path — fix with: claudenv decisions fix --rescope`
176
+ );
177
+ }
178
+
179
+ // 5. Index is now stale.
180
+ await markIndexDirty();
181
+
182
+ return { exitCode: 0 };
183
+ }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Router for `claudenv hook <name>` — entry point for Claude Code hook scripts.
3
+ *
4
+ * Claude Code invokes registered hooks as shell commands with the hook event
5
+ * (JSON) piped on stdin. The dispatcher reads stdin, routes to a handler, and
6
+ * exits with 0 (allow) or 2 (block).
7
+ *
8
+ * Handlers MUST be fast (< 100ms for hot-path PostToolUse) and never throw
9
+ * uncaught — a thrown handler will surface to the user as a noisy hook failure.
10
+ */
11
+
12
+ import { readDecisionsLoggerInput, handleDecisionsLogger } from './decisions-logger.js';
13
+ import { handleRegenIndex } from './regen-index.js';
14
+
15
+ /**
16
+ * Read stdin with a short idle timeout. Avoids hanging when the hook is invoked
17
+ * without piped input (manual `claudenv hook regen-index` from a non-TTY shell —
18
+ * background tasks, GH Actions, etc.).
19
+ *
20
+ * - TTY → return "" immediately (interactive call, no event expected)
21
+ * - Otherwise wait up to `idleMs` for the first byte; once data starts arriving
22
+ * we drain to EOF normally.
23
+ */
24
+ async function readStdin(idleMs = 200) {
25
+ if (process.stdin.isTTY) return '';
26
+
27
+ return await new Promise((resolve, reject) => {
28
+ let data = '';
29
+ let gotAnyData = false;
30
+ const timer = setTimeout(() => {
31
+ // No data appeared within idleMs and no EOF either — treat as no event.
32
+ if (!gotAnyData) {
33
+ process.stdin.removeAllListeners();
34
+ resolve('');
35
+ }
36
+ }, idleMs);
37
+
38
+ process.stdin.setEncoding('utf-8');
39
+ process.stdin.on('data', (chunk) => {
40
+ gotAnyData = true;
41
+ data += chunk;
42
+ });
43
+ process.stdin.on('end', () => {
44
+ clearTimeout(timer);
45
+ resolve(data);
46
+ });
47
+ process.stdin.on('error', (err) => {
48
+ clearTimeout(timer);
49
+ reject(err);
50
+ });
51
+ });
52
+ }
53
+
54
+ /**
55
+ * Dispatch a hook by name. Reads stdin, calls handler, prints any result, exits.
56
+ *
57
+ * @param {string} name - The hook name passed as `claudenv hook <name>`
58
+ */
59
+ export async function dispatch(name) {
60
+ const stdin = await readStdin();
61
+ let event = null;
62
+ if (stdin.trim()) {
63
+ try {
64
+ event = JSON.parse(stdin);
65
+ } catch {
66
+ // Non-JSON input — pass raw string to handlers that want it.
67
+ event = { _raw: stdin };
68
+ }
69
+ }
70
+
71
+ switch (name) {
72
+ case 'decisions-logger': {
73
+ const input = readDecisionsLoggerInput(event);
74
+ const result = await handleDecisionsLogger(input);
75
+ if (result?.message) process.stdout.write(result.message + '\n');
76
+ process.exit(result?.exitCode ?? 0);
77
+ break;
78
+ }
79
+ case 'regen-index': {
80
+ const result = await handleRegenIndex({ event });
81
+ if (result?.message) process.stdout.write(result.message + '\n');
82
+ process.exit(result?.exitCode ?? 0);
83
+ break;
84
+ }
85
+ default: {
86
+ process.stderr.write(`Unknown hook: ${name}\n`);
87
+ process.stderr.write('Available: decisions-logger, regen-index\n');
88
+ process.exit(2);
89
+ }
90
+ }
91
+ }
@@ -0,0 +1,198 @@
1
+ /**
2
+ * regen-index — SessionEnd hook handler and CLI command for INDEX.md generation.
3
+ *
4
+ * Scans ~/.claudenv/memories/decisions/ + <cwd>/.claude/memories/decisions/,
5
+ * picks the top-N most recent by `date:` frontmatter, and produces a compact
6
+ * INDEX.md briefing (~30-50 lines) that the next session reads via memory tool
7
+ * or via `--append-system-prompt` (loop).
8
+ *
9
+ * Idempotent. Hard-capped output size so it never bloats the cache.
10
+ */
11
+
12
+ import { readFile, writeFile, readdir, mkdir, unlink, stat } from 'node:fs/promises';
13
+ import { join, basename } from 'node:path';
14
+ import { homedir } from 'node:os';
15
+
16
+ const DEFAULT_RECENT_LIMIT = 5;
17
+ const MAX_INDEX_LINES = 60;
18
+
19
+ // Lazy resolution so tests can override via process.env.CLAUDENV_HOME.
20
+ function claudenvHome() {
21
+ return process.env.CLAUDENV_HOME || join(homedir(), '.claudenv');
22
+ }
23
+ function memoriesDir() { return join(claudenvHome(), 'memories'); }
24
+ function globalDecisionsDir() { return join(memoriesDir(), 'decisions'); }
25
+ function userPrefsPath() { return join(memoriesDir(), 'user', 'preferences.md'); }
26
+ function indexPath() { return join(memoriesDir(), 'INDEX.md'); }
27
+ function dirtyFlagPath() { return join(claudenvHome(), '.index-dirty'); }
28
+
29
+ /**
30
+ * Read YAML frontmatter into a flat object. Same minimal parser as decisions-logger.
31
+ */
32
+ function parseFrontmatter(text) {
33
+ const match = /^---\n([\s\S]*?)\n---/.exec(text);
34
+ if (!match) return null;
35
+ const result = {};
36
+ for (const line of match[1].split('\n')) {
37
+ const m = /^([a-zA-Z_][a-zA-Z0-9_-]*):\s*(.*)$/.exec(line);
38
+ if (!m) continue;
39
+ let value = m[2].trim().replace(/^['"]|['"]$/g, '');
40
+ result[m[1]] = value;
41
+ }
42
+ return result;
43
+ }
44
+
45
+ /**
46
+ * Collect decision entries from one directory.
47
+ * @returns {Promise<Array<{file: string, date: string|null, topic: string, chose: string, scope: string}>>}
48
+ */
49
+ async function collectDecisions(dir, scopeHint) {
50
+ let names;
51
+ try {
52
+ names = await readdir(dir);
53
+ } catch {
54
+ return [];
55
+ }
56
+ const out = [];
57
+ for (const name of names) {
58
+ if (!name.endsWith('.md')) continue;
59
+ const full = join(dir, name);
60
+ let text;
61
+ try {
62
+ text = await readFile(full, 'utf-8');
63
+ } catch {
64
+ continue;
65
+ }
66
+ const fm = parseFrontmatter(text) || {};
67
+ out.push({
68
+ file: full,
69
+ name,
70
+ date: fm.date || null,
71
+ topic: fm.topic || name.replace(/\.md$/, ''),
72
+ chose: fm.chose || '',
73
+ scope: fm.scope || scopeHint,
74
+ });
75
+ }
76
+ return out;
77
+ }
78
+
79
+ /**
80
+ * Read user/preferences.md, return summary lines (up to N).
81
+ */
82
+ async function summarizePrefs(maxLines = 8) {
83
+ try {
84
+ const text = await readFile(userPrefsPath(), 'utf-8');
85
+ return text
86
+ .split('\n')
87
+ .map((l) => l.trim())
88
+ .filter((l) => l.startsWith('- '))
89
+ .slice(0, maxLines);
90
+ } catch {
91
+ return [];
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Generate INDEX.md content.
97
+ *
98
+ * @param {object} [options]
99
+ * @param {string} [options.cwd] - Project root to include project-scoped decisions from
100
+ * @param {number} [options.limit] - Max decisions to surface
101
+ */
102
+ export async function regenIndex(options = {}) {
103
+ const cwd = options.cwd || process.cwd();
104
+ const limit = options.limit ?? DEFAULT_RECENT_LIMIT;
105
+
106
+ const global = await collectDecisions(globalDecisionsDir(), 'global');
107
+ const project = await collectDecisions(
108
+ join(cwd, '.claude', 'memories', 'decisions'),
109
+ 'project'
110
+ );
111
+
112
+ const all = [...global, ...project];
113
+
114
+ // Sort newest first by date (ISO sorts lexicographically). Missing date = oldest.
115
+ all.sort((a, b) => {
116
+ if (!a.date && !b.date) return 0;
117
+ if (!a.date) return 1;
118
+ if (!b.date) return -1;
119
+ return b.date.localeCompare(a.date);
120
+ });
121
+
122
+ const recent = all.slice(0, limit);
123
+ const prefs = await summarizePrefs();
124
+
125
+ const lines = [];
126
+ lines.push('# claudenv memory — INDEX');
127
+ lines.push('');
128
+ lines.push(`> Auto-generated by \`claudenv hook regen-index\`. Last: ${new Date().toISOString()}`);
129
+ lines.push('');
130
+
131
+ lines.push('## Recent decisions');
132
+ lines.push('');
133
+ if (recent.length === 0) {
134
+ lines.push('_No decisions yet. Make a non-trivial technical choice; vibe-decisions will log it._');
135
+ } else {
136
+ for (const d of recent) {
137
+ const date = d.date ? d.date.slice(0, 10) : '?';
138
+ const scope = d.scope === 'project' ? '[project]' : '[global]';
139
+ const slug = basename(d.name, '.md');
140
+ lines.push(`- ${date} ${scope} **${d.topic}** → ${d.chose} (\`${slug}\`)`);
141
+ }
142
+ }
143
+ lines.push('');
144
+
145
+ if (prefs.length) {
146
+ lines.push('## Active preferences');
147
+ lines.push('');
148
+ for (const p of prefs) lines.push(p);
149
+ lines.push('');
150
+ }
151
+
152
+ lines.push('---');
153
+ lines.push('');
154
+ lines.push(`Files: ${global.length} global, ${project.length} project decisions. Run \`claudenv decisions list\` for the full set.`);
155
+
156
+ // Hard cap to keep cache stable.
157
+ const capped = lines.slice(0, MAX_INDEX_LINES);
158
+
159
+ await mkdir(memoriesDir(), { recursive: true });
160
+ await writeFile(indexPath(), capped.join('\n') + '\n', 'utf-8');
161
+
162
+ // Clear dirty flag if present.
163
+ try {
164
+ await unlink(dirtyFlagPath());
165
+ } catch {
166
+ /* not present — fine */
167
+ }
168
+
169
+ return {
170
+ indexPath: indexPath(),
171
+ decisionCount: all.length,
172
+ recentCount: recent.length,
173
+ };
174
+ }
175
+
176
+ /**
177
+ * Read the dirty flag (returns true if INDEX.md needs regen).
178
+ */
179
+ export async function isIndexDirty() {
180
+ try {
181
+ await stat(dirtyFlagPath());
182
+ return true;
183
+ } catch {
184
+ return false;
185
+ }
186
+ }
187
+
188
+ /**
189
+ * Hook entry point — called by dispatcher on SessionEnd.
190
+ */
191
+ export async function handleRegenIndex({ event } = {}) {
192
+ const cwd = event?.cwd || process.cwd();
193
+ const result = await regenIndex({ cwd });
194
+ return {
195
+ exitCode: 0,
196
+ message: `INDEX.md regenerated (${result.recentCount} recent of ${result.decisionCount} total decisions)`,
197
+ };
198
+ }