wendkeep 0.1.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.
@@ -0,0 +1,201 @@
1
+ #!/usr/bin/env node
2
+ import { existsSync, readFileSync } from 'fs';
3
+ import { join } from 'path';
4
+ import { pathToFileURL } from 'url';
5
+ import {
6
+ controlPath,
7
+ getVaultBase,
8
+ listMarkdownFiles,
9
+ readControl,
10
+ readSessionRegistry,
11
+ wikilinkFromRel,
12
+ } from './obsidian-common.mjs';
13
+
14
+ const DEFAULT_PENDING_PATTERNS = [
15
+ /^- \[ \] Revisar resumo da sessão$/i,
16
+ /^- \[ \] Verificar se houve decisões a registrar$/i,
17
+ /^- \[ \] Verificar se houve bugs a registrar$/i,
18
+ /^- \[ \] Verificar se houve aprendizados a registrar$/i,
19
+ ];
20
+
21
+ function parseArgs(argv) {
22
+ const args = {};
23
+ for (let i = 0; i < argv.length; i += 1) {
24
+ const item = argv[i];
25
+ if (!item.startsWith('--')) continue;
26
+ const key = item.slice(2);
27
+ const next = argv[i + 1];
28
+ if (!next || next.startsWith('--')) {
29
+ args[key] = true;
30
+ } else {
31
+ args[key] = next;
32
+ i += 1;
33
+ }
34
+ }
35
+ return args;
36
+ }
37
+
38
+ function findDuplicateTurnMarkers(content) {
39
+ const seen = new Set();
40
+ const duplicated = new Set();
41
+ const regex = /<!-- codex-turn: ([^>]+) -->/g;
42
+ let match;
43
+ while ((match = regex.exec(content)) !== null) {
44
+ const turnId = match[1].trim();
45
+ if (seen.has(turnId)) duplicated.add(turnId);
46
+ seen.add(turnId);
47
+ }
48
+ return [...duplicated];
49
+ }
50
+
51
+ function hasHeadingAfterClosing(content) {
52
+ const closing = content.indexOf('\n## Encerramento');
53
+ if (closing === -1) return false;
54
+ return /\n#{2,3} /.test(content.slice(closing + '\n## Encerramento'.length));
55
+ }
56
+
57
+ function sectionBody(content, heading) {
58
+ const marker = `\n## ${heading}\n`;
59
+ const start = content.indexOf(marker);
60
+ if (start === -1) return '';
61
+ const bodyStart = start + marker.length;
62
+ const next = content.slice(bodyStart).search(/\n## /);
63
+ const bodyEnd = next === -1 ? content.length : bodyStart + next;
64
+ return content.slice(bodyStart, bodyEnd);
65
+ }
66
+
67
+ function hasDefaultPending(content) {
68
+ return sectionBody(content, 'Pendências')
69
+ .split('\n')
70
+ .some((line) => DEFAULT_PENDING_PATTERNS.some((pattern) => pattern.test(line.trim())));
71
+ }
72
+
73
+ function usageSectionIsPlaced(content) {
74
+ const usage = content.indexOf('\n## Uso de tokens e custos');
75
+ if (usage === -1) return true;
76
+ const changed = content.indexOf('\n## Arquivos criados ou alterados');
77
+ const pending = content.indexOf('\n## Pendências');
78
+ const closing = content.indexOf('\n## Encerramento');
79
+ if (pending === -1 || closing === -1) return false;
80
+ return usage < pending && usage < closing && (changed === -1 || usage > changed);
81
+ }
82
+
83
+ function linkedNotesFromSession(content) {
84
+ const notes = [];
85
+ const regex = /\[\[((?:04-Decisões|05-Bugs|06-Aprendizados)\/[^\]]+)\]\]/g;
86
+ let match;
87
+ while ((match = regex.exec(content)) !== null) {
88
+ if (!notes.includes(match[1])) notes.push(match[1]);
89
+ }
90
+ return notes;
91
+ }
92
+
93
+ function checkSession({ vaultBase, sessionRel, control, registry }) {
94
+ const failures = [];
95
+ const warnings = [];
96
+ const metrics = {};
97
+ const sessionPath = join(vaultBase, sessionRel);
98
+
99
+ if (!existsSync(sessionPath)) {
100
+ failures.push(`Sessão não encontrada: ${sessionRel}`);
101
+ return { failures, warnings, metrics };
102
+ }
103
+
104
+ const content = readFileSync(sessionPath, 'utf-8');
105
+ const duplicates = findDuplicateTurnMarkers(content);
106
+ metrics.turnMarkers = (content.match(/<!-- codex-turn:/g) || []).length;
107
+ metrics.duplicateTurnMarkers = duplicates.length;
108
+
109
+ if (duplicates.length) failures.push(`Marcadores codex-turn duplicados: ${duplicates.join(', ')}`);
110
+ if (hasHeadingAfterClosing(content)) failures.push('Há headings/iterações após ## Encerramento.');
111
+ if (!usageSectionIsPlaced(content)) failures.push('## Uso de tokens e custos está fora da posição esperada.');
112
+ if (hasDefaultPending(content)) warnings.push('Pendências ainda contém placeholders padrão.');
113
+
114
+ const registryEntry = registry.sessions?.[control.session_id];
115
+ if (control.session_id && !registryEntry) {
116
+ failures.push(`SESSION_REGISTRY não possui session_id ativo: ${control.session_id}`);
117
+ } else if (registryEntry) {
118
+ if (registryEntry.session_file !== sessionRel) {
119
+ failures.push('SESSION_REGISTRY diverge do CURRENT_SESSION.md para a sessão ativa.');
120
+ }
121
+ if (!registryEntry.transcript_path) {
122
+ warnings.push('SESSION_REGISTRY não possui transcript_path para a sessão ativa.');
123
+ } else if (!existsSync(registryEntry.transcript_path)) {
124
+ warnings.push(`Transcript da sessão ativa não encontrado: ${registryEntry.transcript_path}`);
125
+ }
126
+ }
127
+
128
+ const sessionLink = wikilinkFromRel(sessionRel);
129
+ for (const noteRel of linkedNotesFromSession(content)) {
130
+ const notePath = join(vaultBase, noteRel.endsWith('.md') ? noteRel : `${noteRel}.md`);
131
+ if (!existsSync(notePath)) {
132
+ failures.push(`Nota derivada linkada não existe: ${noteRel}`);
133
+ continue;
134
+ }
135
+ const noteContent = readFileSync(notePath, 'utf-8');
136
+ if (!noteContent.includes(sessionLink) && !noteContent.includes(sessionRel)) {
137
+ failures.push(`Nota derivada sem backlink para a sessão: ${noteRel}`);
138
+ }
139
+ }
140
+
141
+ return { failures, warnings, metrics };
142
+ }
143
+
144
+ export function runVaultHealth({ vaultBase, session = '' }) {
145
+ const control = readControl(vaultBase);
146
+ const registry = readSessionRegistry(vaultBase);
147
+ const sessionRel = session || control.session_file || control.last_session_file || '';
148
+ const failures = [];
149
+ const warnings = [];
150
+
151
+ if (!existsSync(controlPath(vaultBase))) {
152
+ failures.push('CURRENT_SESSION.md não encontrado.');
153
+ }
154
+ if (!sessionRel) failures.push('Nenhuma sessão ativa ou última sessão encontrada no controle.');
155
+
156
+ const sessionResult = sessionRel
157
+ ? checkSession({ vaultBase, sessionRel, control, registry })
158
+ : { failures: [], warnings: [], metrics: {} };
159
+ failures.push(...sessionResult.failures);
160
+ warnings.push(...sessionResult.warnings);
161
+
162
+ const staleDone = Object.values(registry.sessions || {})
163
+ .filter((item) => item.status === 'active' && item.ended_at)
164
+ .length;
165
+ if (staleDone) warnings.push(`${staleDone} entradas active com ended_at no SESSION_REGISTRY.`);
166
+
167
+ const derivedFolders = ['04-Decisões', '05-Bugs', '06-Aprendizados'];
168
+ const derivedCount = derivedFolders.reduce((total, folder) => {
169
+ const dir = join(vaultBase, folder);
170
+ return total + (existsSync(dir) ? listMarkdownFiles(dir).length : 0);
171
+ }, 0);
172
+
173
+ return {
174
+ ok: failures.length === 0,
175
+ session: sessionRel,
176
+ failures,
177
+ warnings,
178
+ metrics: {
179
+ ...sessionResult.metrics,
180
+ registrySessions: Object.keys(registry.sessions || {}).length,
181
+ derivedNotes: derivedCount,
182
+ },
183
+ };
184
+ }
185
+
186
+ function main() {
187
+ const args = parseArgs(process.argv.slice(2));
188
+ const vaultBase = getVaultBase({ obsidian_vault_path: args.vault });
189
+ const result = runVaultHealth({ vaultBase, session: args.session || '' });
190
+ console.log(JSON.stringify(result, null, 2));
191
+ if (!result.ok) process.exitCode = 1;
192
+ }
193
+
194
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
195
+ try {
196
+ main();
197
+ } catch (error) {
198
+ process.stderr.write(`[codex-obsidian] Vault health falhou: ${error.message}\n`);
199
+ process.exitCode = 1;
200
+ }
201
+ }
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "wendkeep",
3
+ "version": "0.1.0",
4
+ "description": "Automatically capture AI coding agent sessions (Claude Code, Codex) as local Markdown in your Obsidian vault — turn-by-turn history, cost/token tracking, auto-extracted decisions/bugs/learnings, rendered in the graph. Local-first.",
5
+ "type": "module",
6
+ "bin": {
7
+ "wendkeep": "bin/wendkeep.mjs",
8
+ "wk": "bin/wendkeep.mjs"
9
+ },
10
+ "files": [
11
+ "bin",
12
+ "src",
13
+ "hooks",
14
+ "README.md"
15
+ ],
16
+ "engines": {
17
+ "node": ">=18"
18
+ },
19
+ "scripts": {
20
+ "check": "node --check bin/wendkeep.mjs && node --check src/init.mjs && node --check src/doctor.mjs"
21
+ },
22
+ "keywords": [
23
+ "claude-code",
24
+ "codex",
25
+ "obsidian",
26
+ "session-memory",
27
+ "ai-agent",
28
+ "markdown",
29
+ "local-first",
30
+ "knowledge-graph",
31
+ "token-cost"
32
+ ],
33
+ "author": "Roger Alves",
34
+ "license": "MIT"
35
+ }
package/src/doctor.mjs ADDED
@@ -0,0 +1,36 @@
1
+ // `wendkeep doctor` — run the bundled vault-health check against a vault.
2
+ // Resolves the vault from --vault or the OBSIDIAN_VAULT_PATH env var, then runs
3
+ // hooks/vault-health.mjs (which reads getVaultBase()).
4
+ import { spawnSync } from 'node:child_process';
5
+ import { existsSync } from 'node:fs';
6
+ import { dirname, isAbsolute, join, resolve } from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+
9
+ export function runDoctor(argv) {
10
+ const here = dirname(fileURLToPath(import.meta.url));
11
+ const hookFile = join(here, '..', 'hooks', 'vault-health.mjs');
12
+ if (!existsSync(hookFile)) {
13
+ process.stderr.write(`wendkeep doctor: vault-health.mjs not found at ${hookFile}\n`);
14
+ process.exit(2);
15
+ }
16
+
17
+ let vault;
18
+ const passthrough = [];
19
+ for (let i = 0; i < argv.length; i += 1) {
20
+ const a = argv[i];
21
+ if (a === '--vault') vault = argv[++i];
22
+ else if (a.startsWith('--vault=')) vault = a.slice(8);
23
+ else passthrough.push(a);
24
+ }
25
+
26
+ const env = { ...process.env };
27
+ if (vault) env.OBSIDIAN_VAULT_PATH = isAbsolute(vault) ? vault : resolve(process.cwd(), vault);
28
+
29
+ if (!env.OBSIDIAN_VAULT_PATH) {
30
+ process.stderr.write('wendkeep doctor: no vault. Pass --vault <path> or set OBSIDIAN_VAULT_PATH.\n');
31
+ process.exit(2);
32
+ }
33
+
34
+ const r = spawnSync(process.execPath, [hookFile, ...passthrough], { stdio: 'inherit', env });
35
+ process.exit(r.status ?? 0);
36
+ }
package/src/init.mjs ADDED
@@ -0,0 +1,171 @@
1
+ // `wendkeep init` — cross-platform replacement for setup-vault.ps1.
2
+ // Creates the vault taxonomy, NON-DESTRUCTIVELY merges the session hooks +
3
+ // OBSIDIAN_VAULT_PATH into .claude/settings.json, and adds the mcpvault server to
4
+ // .mcp.json. Idempotent: re-running only adds what is missing.
5
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
6
+ import { isAbsolute, join, resolve } from 'node:path';
7
+ import { createInterface } from 'node:readline/promises';
8
+ import {
9
+ VAULT_FOLDERS,
10
+ SESSION_HOOKS,
11
+ MCP_SERVER_KEY,
12
+ mcpServerEntry,
13
+ hookCommand,
14
+ } from './taxonomy.mjs';
15
+
16
+ function parseArgs(argv) {
17
+ const args = { mcp: true, yes: false, force: false };
18
+ for (let i = 0; i < argv.length; i += 1) {
19
+ const a = argv[i];
20
+ if (a === '--vault') args.vault = argv[++i];
21
+ else if (a === '--project') args.project = argv[++i];
22
+ else if (a === '--no-mcp') args.mcp = false;
23
+ else if (a === '--yes' || a === '-y') args.yes = true;
24
+ else if (a === '--force') args.force = true;
25
+ else if (a.startsWith('--vault=')) args.vault = a.slice(8);
26
+ else if (a.startsWith('--project=')) args.project = a.slice(10);
27
+ }
28
+ return args;
29
+ }
30
+
31
+ function readJsonSafe(path) {
32
+ // Returns { ok, data }. ok=false means the file exists but is not parseable —
33
+ // caller must NOT clobber it (we write a *.new beside it instead).
34
+ if (!existsSync(path)) return { ok: true, data: null };
35
+ try {
36
+ return { ok: true, data: JSON.parse(readFileSync(path, 'utf8')) };
37
+ } catch {
38
+ return { ok: false, data: null };
39
+ }
40
+ }
41
+
42
+ function writeJson(path, obj) {
43
+ ensureParent(path);
44
+ writeFileSync(path, `${JSON.stringify(obj, null, 2)}\n`, 'utf8');
45
+ }
46
+
47
+ function ensureParent(path) {
48
+ const dir = resolve(path, '..');
49
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
50
+ }
51
+
52
+ function backup(path) {
53
+ const bak = `${path}.bak`;
54
+ if (!existsSync(bak)) copyFileSync(path, bak);
55
+ return bak;
56
+ }
57
+
58
+ // --- merges -----------------------------------------------------------------
59
+
60
+ function mergeSettings(existing, { vaultPath, withMcp, force }) {
61
+ const s = existing && typeof existing === 'object' ? { ...existing } : {};
62
+ s.hooks = { ...(s.hooks || {}) };
63
+ let added = 0;
64
+ for (const h of SESSION_HOOKS) {
65
+ const command = hookCommand(h.name);
66
+ const groups = Array.isArray(s.hooks[h.event]) ? [...s.hooks[h.event]] : [];
67
+ const present = groups.some((g) => (g.hooks || []).some((x) => x.command === command));
68
+ if (present && !force) {
69
+ s.hooks[h.event] = groups;
70
+ continue;
71
+ }
72
+ const entry = { type: 'command', command, timeout: h.timeout, statusMessage: h.statusMessage };
73
+ const group = h.matcher ? { matcher: h.matcher, hooks: [entry] } : { hooks: [entry] };
74
+ groups.push(group);
75
+ s.hooks[h.event] = groups;
76
+ added += 1;
77
+ }
78
+ s.env = { ...(s.env || {}), OBSIDIAN_VAULT_PATH: vaultPath };
79
+ if (withMcp) {
80
+ const set = new Set(Array.isArray(s.enabledMcpjsonServers) ? s.enabledMcpjsonServers : []);
81
+ set.add(MCP_SERVER_KEY);
82
+ s.enabledMcpjsonServers = [...set];
83
+ }
84
+ return { settings: s, added };
85
+ }
86
+
87
+ function mergeMcp(existing, vaultPath) {
88
+ const m = existing && typeof existing === 'object' ? { ...existing } : {};
89
+ m.mcpServers = { ...(m.mcpServers || {}) };
90
+ m.mcpServers[MCP_SERVER_KEY] = mcpServerEntry(vaultPath);
91
+ return m;
92
+ }
93
+
94
+ // --- main -------------------------------------------------------------------
95
+
96
+ export async function runInit(argv) {
97
+ const args = parseArgs(argv);
98
+ const projectPath = resolve(args.project || process.cwd());
99
+ const log = (s) => process.stdout.write(`${s}\n`);
100
+
101
+ let vaultPath = args.vault;
102
+ if (!vaultPath) {
103
+ const fallback = join(projectPath, '.wendkeep-vault');
104
+ if (process.stdin.isTTY && !args.yes) {
105
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
106
+ const ans = (await rl.question(`Obsidian vault path [${fallback}]: `)).trim();
107
+ rl.close();
108
+ vaultPath = ans || fallback;
109
+ } else {
110
+ vaultPath = fallback;
111
+ }
112
+ }
113
+ vaultPath = isAbsolute(vaultPath) ? vaultPath : resolve(projectPath, vaultPath);
114
+
115
+ log('\nwendkeep init');
116
+ log(` project : ${projectPath}`);
117
+ log(` vault : ${vaultPath}`);
118
+ log(` mcp : ${args.mcp ? 'mcpvault (wendkeep-vault)' : 'skipped'}\n`);
119
+
120
+ // 1. Vault taxonomy ---------------------------------------------------------
121
+ if (!existsSync(vaultPath)) mkdirSync(vaultPath, { recursive: true });
122
+ let created = 0;
123
+ for (const f of VAULT_FOLDERS) {
124
+ const p = join(vaultPath, f);
125
+ if (!existsSync(p)) {
126
+ mkdirSync(p, { recursive: true });
127
+ created += 1;
128
+ }
129
+ }
130
+ log(` [1/3] vault taxonomy: ${VAULT_FOLDERS.length} folders (${created} created)`);
131
+
132
+ // 2. .claude/settings.json --------------------------------------------------
133
+ const settingsPath = join(projectPath, '.claude', 'settings.json');
134
+ const settingsRead = readJsonSafe(settingsPath);
135
+ if (!settingsRead.ok) {
136
+ // Unparseable existing file — never clobber. Drop a .new for manual merge.
137
+ const fresh = mergeSettings(null, { vaultPath, withMcp: args.mcp, force: true }).settings;
138
+ writeJson(`${settingsPath}.new`, fresh);
139
+ log(` [2/3] settings.json exists but is not valid JSON -> wrote ${settingsPath}.new (merge by hand)`);
140
+ } else {
141
+ const hadFile = settingsRead.data !== null;
142
+ const { settings, added } = mergeSettings(settingsRead.data, { vaultPath, withMcp: args.mcp, force: args.force });
143
+ if (hadFile) backup(settingsPath);
144
+ writeJson(settingsPath, settings);
145
+ log(` [2/3] settings.json ${hadFile ? 'merged' : 'created'} (${added} hook(s) wired, OBSIDIAN_VAULT_PATH set${hadFile ? ', .bak saved' : ''})`);
146
+ }
147
+
148
+ // 3. .mcp.json --------------------------------------------------------------
149
+ if (args.mcp) {
150
+ const mcpPath = join(projectPath, '.mcp.json');
151
+ const mcpRead = readJsonSafe(mcpPath);
152
+ if (!mcpRead.ok) {
153
+ writeJson(`${mcpPath}.new`, mergeMcp(null, vaultPath));
154
+ log(` [3/3] .mcp.json exists but is not valid JSON -> wrote ${mcpPath}.new (merge by hand)`);
155
+ } else {
156
+ const hadFile = mcpRead.data !== null;
157
+ if (hadFile) backup(mcpPath);
158
+ writeJson(mcpPath, mergeMcp(mcpRead.data, vaultPath));
159
+ log(` [3/3] .mcp.json ${hadFile ? 'merged' : 'created'} (wendkeep-vault server${hadFile ? ', .bak saved' : ''})`);
160
+ }
161
+ } else {
162
+ log(' [3/3] .mcp.json skipped (--no-mcp)');
163
+ }
164
+
165
+ log('\nNext steps:');
166
+ log(` 1. Open the vault in Obsidian: "Open folder as vault" -> ${vaultPath}`);
167
+ log(' 2. Make sure wendkeep is installed where the agent runs (npm i -D wendkeep, or -g).');
168
+ log(' 3. Open Claude Code in this project and send a test prompt.');
169
+ log(' 4. Confirm a note appears under 02-Sessões/<year>/<month>/DIA <dd>/ in the vault.');
170
+ log(' Update later with: npm update wendkeep (no re-copying — hooks live in the package).\n');
171
+ }
@@ -0,0 +1,76 @@
1
+ // Shared, data-only constants for the wendkeep installer and CLI.
2
+ // Kept free of side effects so both bin/ and src/ can import it cheaply.
3
+
4
+ // Vault folder taxonomy the hooks read from / write to.
5
+ // NOTE: folder names are currently Portuguese (the convention the hooks hardcode
6
+ // in obsidian-common.mjs: sessionFolderRel -> '02-Sessões', derived notes ->
7
+ // '04-Decisões' / '05-Bugs' / '06-Aprendizados'). Internationalizing these is
8
+ // tracked as a known limitation in the README — do not rename here without also
9
+ // changing the hooks.
10
+ export const VAULT_FOLDERS = [
11
+ '00-Inbox',
12
+ '01-Projeto',
13
+ '02-Sessões',
14
+ '03-Linear',
15
+ '04-Decisões',
16
+ '05-Bugs',
17
+ '06-Aprendizados',
18
+ 'Templates',
19
+ '.brain',
20
+ ];
21
+
22
+ // Every file that must travel together for the hooks to run (shared lib +
23
+ // entrypoints + price table). Mirrors the list the old setup-vault.ps1 copied.
24
+ export const HOOK_FILES = [
25
+ 'obsidian-common.mjs',
26
+ 'session-start.mjs',
27
+ 'session-ensure.mjs',
28
+ 'session-stop.mjs',
29
+ 'linked-notes.mjs',
30
+ 'token-usage.mjs',
31
+ 'pricing.json',
32
+ 'brain-core.mjs',
33
+ 'brain-inject.mjs',
34
+ 'brain-recall.mjs',
35
+ 'brain-reindex.mjs',
36
+ 'session-backfill.mjs',
37
+ 'vault-health.mjs',
38
+ ];
39
+
40
+ // Hook scripts that are safe to invoke directly via `wendkeep hook <name>`.
41
+ // (Excludes pure libraries like obsidian-common / linked-notes / token-usage /
42
+ // brain-core, which are imported by the entrypoints rather than run standalone.)
43
+ export const RUNNABLE_HOOKS = [
44
+ 'session-start',
45
+ 'session-ensure',
46
+ 'session-stop',
47
+ 'session-backfill',
48
+ 'brain-inject',
49
+ 'brain-recall',
50
+ 'brain-reindex',
51
+ 'vault-health',
52
+ ];
53
+
54
+ // The MCP server entry wendkeep wires into .mcp.json so the agent can read/write the
55
+ // vault. Uses the published mcpvault server (no secrets).
56
+ export function mcpServerEntry(vaultPath) {
57
+ return {
58
+ type: 'stdio',
59
+ command: 'npx',
60
+ args: ['-y', '@bitbonsai/mcpvault@latest', vaultPath],
61
+ };
62
+ }
63
+ export const MCP_SERVER_KEY = 'wendkeep-vault';
64
+
65
+ // The three Claude Code session hooks, expressed as `wendkeep hook <name>` so the
66
+ // installed package is the single source of truth (update with `npm update wendkeep`,
67
+ // no re-copying). Returned as a spec the merge logic folds into settings.json.
68
+ export const SESSION_HOOKS = [
69
+ { event: 'SessionStart', matcher: 'startup', name: 'session-start', timeout: 30, statusMessage: 'wendkeep: opening Obsidian session' },
70
+ { event: 'Stop', matcher: null, name: 'session-stop', timeout: 60, statusMessage: 'wendkeep: writing session checkpoint' },
71
+ { event: 'UserPromptSubmit', matcher: null, name: 'session-ensure', timeout: 30, statusMessage: 'wendkeep: ensuring active session' },
72
+ ];
73
+
74
+ export function hookCommand(name) {
75
+ return `npx wendkeep hook ${name}`;
76
+ }