Uses Jev to spot durable knowledge in a turn and auto-capture it into ~/vault/raw/ for /compile.

Claude Code plugin that watches every main-loop turn and, when it looks like it holds durable knowledge, auto-captures it into ~/vault/raw/ for the /compile skill to pick up later.
Jev only returns numeric answers (a probability, or a pick from a fixed list) — it cannot draft prose. So it does two things, not three:
noul question — "does this turn hold durable knowledge worth saving?" — scored 0–1 against threshold (default 0.6).INDEX.md exists): a choice question picking which existing vault page this content most belongs under, or new_topic.What it does not do: write the vault page itself. The raw capture is the user/assistant text verbatim (bounded to ~4000 chars each), not a summary — drafting the actual page still happens when you run /compile, which uses a full model, not Jev.
turn.complete (main loop only, not subagents): if the answer is long enough (minAnswerChars, default 200) and the gate passes, writes ~/vault/raw/<date>-<slug>.md with the turn's user/assistant text and a capture comment (score, topic guess)./compile./vault-check runs the same gate + topic-route logic by hand, against a chosen exchange, without waiting for a real turn — useful for testing or for content the automatic hook already passed by. It's a separate plain-JS script (hooks/vault-check-cli.mjs), since a slash command runs outside the plugin engine and can only shell out; keep it in sync with vault-gate.ts if the gate logic changes.
/audit-sessions runs the same gate logic across every archived Claude Code session transcript (~/.claude/projects/*/*.jsonl), not just live turns. Per session, isolated per-request, Jev scores two things: vault_worthy (same as the live gate) and safe_to_delete (nothing unique or still-needed lives only in this transcript). Vault-worthy sessions are auto-captured into vault/raw/ the same way. Nothing is ever deleted automatically — delete candidates are only reported; deleting them is a separate, explicit, user-confirmed step the command walks through.
export TYPESAFE_API_KEY=...
claude plugin marketplace add .
claude plugin install jev-vault-gate
Configure vaultPath if it's not ~/vault, via /plugin configure jev-vault-gate.
Every turn long enough to check gets sent to the external Jev API (user text
and jev-harness-audit — don't install this if you don't want turn content leaving the machine for that classification.
hooks/vault-gate.ts 244 lines1import type { On, PluginOptions, Register, SessionMessage, TurnCompleteInput } from 'claude-code';
2
3const DEFAULTS = {
4 vaultPath: '~/vault',
5 threshold: 0.6,
6 minAnswerChars: 200,
7 model: 'jev-latest',
8};
9
10const SYSTEM_ONE_URL = 'https://api.typesafe.ai/v1/systemone';
11const MAX_TEXT_CHARS = 4000;
12const MAX_TOPICS = 20;
13
14type NoulAnswer = { noul: number };
15type ChoiceAnswer = { choice: string; confidence: number };
16type JevAnswer = NoulAnswer | ChoiceAnswer;
17type JevQuestions = Record<string, { type: 'noul' | 'choice'; instructions: string; criteria?: Record<string, string | null> }>;
18
19interface Config {
20 apiKey?: string;
21 vaultPath: string;
22 threshold: number;
23 minAnswerChars: number;
24 model: string;
25}
26
27function optionNumber(options: PluginOptions, key: string, fallback: number): number {
28 const value = options[key];
29 return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
30}
31
32function optionString(options: PluginOptions, key: string, fallback: string): string {
33 const value = options[key];
34 return typeof value === 'string' && value.length > 0 ? value : fallback;
35}
36
37function resolveConfig(options: PluginOptions): Config {
38 const apiKey = optionString(options, 'apiKey', '');
39 return {
40 apiKey: apiKey || undefined,
41 vaultPath: optionString(options, 'vaultPath', DEFAULTS.vaultPath),
42 threshold: optionNumber(options, 'threshold', DEFAULTS.threshold),
43 minAnswerChars: optionNumber(options, 'minAnswerChars', DEFAULTS.minAnswerChars),
44 model: optionString(options, 'model', DEFAULTS.model),
45 };
46}
47
48function expandHome(path: string, home: string): string {
49 return path === '~' || path.startsWith('~/') ? home + path.slice(1) : path;
50}
51
52function truncate(text: string, max: number): string {
53 return text.length <= max ? text : `${text.slice(0, max)}\n[...truncated...]`;
54}
55
56/**
57 * Deterministic, local redaction applied ONLY to what leaves the machine for
58 * Jev — the gate/route questions never need the real value, just that a
59 * secret-shaped thing is there. The unredacted text still goes into
60 * vault/raw/ untouched; that's the whole point of the vault.
61 */
62function scrubSecrets(text: string): string {
63 return text
64 // key=value / key: value credential assignments (api key, token, password, secret, community string, bearer)
65 .replace(
66 /\b((?:api[_-]?key|access[_-]?key|secret[_-]?key|client[_-]?secret|auth[_-]?token|bearer|password|passwd|pwd|community(?:[_-]?string)?)\s*[:=]\s*)(\S+)/gi,
67 '$1[REDACTED]',
68 )
69 // PEM-style private key / certificate blocks
70 .replace(/-----BEGIN [^-]+-----[\s\S]*?-----END [^-]+-----/g, '[REDACTED PEM BLOCK]')
71 // AWS-style access key ids
72 .replace(/\bAKIA[0-9A-Z]{16}\b/g, '[REDACTED_AWS_KEY]')
73 // long bearer/JWT-looking tokens standing alone
74 .replace(/\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g, '[REDACTED_JWT]')
75 // MAC addresses
76 .replace(/\b([0-9a-fA-F]{2}[:-]){5}[0-9a-fA-F]{2}\b/g, '[MAC]')
77 // IPv4 addresses (network inventory is the main thing this vault handles)
78 .replace(/\b(?:(?:25[0-5]|2[0-4]\d|1?\d?\d)\.){3}(?:25[0-5]|2[0-4]\d|1?\d?\d)\b/g, '[IP]');
79}
80
81/** Pulls `[[page-slug]]` wikilinks out of INDEX.md as candidate topic buckets. */
82function extractTopics(indexMd: string): string[] {
83 const found = new Set<string>();
84 for (const match of indexMd.matchAll(/\[\[([a-z0-9-]+)\]\]/g)) {
85 found.add(match[1]!);
86 if (found.size >= MAX_TOPICS) break;
87 }
88 return [...found];
89}
90
91function lastUserText(messages: readonly SessionMessage[]): string {
92 for (let i = messages.length - 1; i >= 0; i--) {
93 const message = messages[i]!;
94 if (message.role === 'user' && message.text.trim().length > 0) return message.text;
95 }
96 return '';
97}
98
99async function askJev(
100 fetchFn: (url: string, init: { method: string; headers: Record<string, string>; body: string }) => Promise<{ status: number; ok: boolean; text: string }>,
101 config: Config,
102 state: unknown,
103 questions: JevQuestions,
104): Promise<Record<string, JevAnswer>> {
105 const response = await fetchFn(SYSTEM_ONE_URL, {
106 method: 'POST',
107 headers: { authorization: `Bearer ${config.apiKey}`, 'content-type': 'application/json' },
108 body: JSON.stringify({ model: config.model, state, questions }),
109 });
110 if (!response.ok) throw new Error(`Jev request failed (${response.status}): ${response.text.slice(0, 200)}`);
111 const parsed = JSON.parse(response.text) as { answers?: Record<string, JevAnswer> };
112 if (!parsed.answers) throw new Error('Jev response is missing answers');
113 return parsed.answers;
114}
115
116function slugifyTitle(text: string, maxWords: number): string {
117 return text
118 .split(/\s+/)
119 .slice(0, maxWords)
120 .join(' ')
121 .toLowerCase()
122 .replace(/[^a-z0-9]+/g, '-')
123 .replace(/^-+|-+$/g, '')
124 .slice(0, 60);
125}
126
127export const register: Register = (on: On, options: PluginOptions) => {
128 const configured = resolveConfig(options);
129
130 on('turn.complete', async ($, event: TurnCompleteInput, next) => {
131 if (event.agentId) return next(event); // subagent turns aren't the user's own work log
132 if (event.reason !== 'answer') return next(event);
133 if (event.answer.length < configured.minAnswerChars) return next(event);
134
135 try {
136 const apiKey = configured.apiKey ?? (await $.env.get('TYPESAFE_API_KEY'));
137 if (!apiKey) {
138 $.ui.log('jev-vault-gate: TYPESAFE_API_KEY not configured, skipping capture check');
139 return next(event);
140 }
141 const config = { ...configured, apiKey };
142 const home = (await $.env.get('HOME')) ?? '';
143 const vaultPath = expandHome(config.vaultPath, home);
144
145 let topics: string[] = [];
146 try {
147 const indexMd = await $.fs.read(`${vaultPath}/INDEX.md`);
148 topics = extractTopics(indexMd);
149 } catch {
150 // no INDEX.md yet, or vault path not set up; capture still works without topic routing
151 }
152
153 const messages = await $.session.messages();
154 const userText = truncate(lastUserText(messages), MAX_TEXT_CHARS);
155 const assistantText = truncate(event.answer, MAX_TEXT_CHARS);
156
157 const state = {
158 context:
159 'One turn of a technical work session: a user message and the assistant\'s reply, with ' +
160 'credential- and address-shaped values already redacted before reaching you — judge the ' +
161 'structure and topic, not the exact values. Judge whether it holds durable knowledge ' +
162 'worth keeping in a personal engineering vault (facts: inventory, configs, architecture; ' +
163 'lessons: methods, root causes, gotchas, fixes) that would still be useful weeks or ' +
164 'months later. Most turns are ordinary back-and-forth, task chatter, or acknowledgements ' +
165 'and do not qualify.',
166 user: scrubSecrets(userText),
167 assistant: scrubSecrets(assistantText),
168 };
169
170 const questions: JevQuestions = {
171 worthy: {
172 type: 'noul',
173 instructions:
174 'This turn holds durable knowledge worth saving to the vault, per the context above. ' +
175 'Most turns do not; only answer high when the content itself shows a fact or lesson, ' +
176 'not because the turn is long or technical-sounding.',
177 },
178 };
179 if (topics.length > 0) {
180 const criteria: Record<string, string | null> = { new_topic: 'None of the existing pages fit this content.' };
181 for (const topic of topics) criteria[topic] = null;
182 questions['topic'] = {
183 type: 'choice',
184 instructions: 'Which existing vault page this turn\'s content most belongs under, or new_topic.',
185 criteria,
186 };
187 }
188
189 const answers = await askJev(
190 async (url, init) => {
191 const response = await $.http.fetch(url, init);
192 return { status: response.status, ok: response.ok, text: response.text };
193 },
194 config,
195 state,
196 questions,
197 );
198
199 const worthy = answers['worthy'] as NoulAnswer | undefined;
200 const score = worthy?.noul ?? 0;
201 if (!Number.isFinite(score) || score < config.threshold) return next(event);
202
203 const topicAnswer = answers['topic'] as ChoiceAnswer | undefined;
204 const date = new Date().toISOString().slice(0, 10);
205 const slug = slugifyTitle(userText || assistantText, 8) || event.turnId.slice(0, 8);
206 const path = `${vaultPath}/raw/${date}-${slug}.md`;
207 if (await $.fs.exists(path)) return next(event); // already captured (hot reload re-run, etc.)
208
209 const header = [
210 `<!-- captured by jev-vault-gate: turn ${event.turnId}, score ${score.toFixed(2)}` +
211 (topicAnswer ? `, topic ${topicAnswer.choice} (${topicAnswer.confidence.toFixed(2)})` : '') +
212 ' -->',
213 `# ${date} ${slug.replace(/-/g, ' ')}`,
214 '',
215 '## User',
216 '',
217 userText,
218 '',
219 '## Assistant',
220 '',
221 assistantText,
222 '',
223 ].join('\n');
224 await $.fs.write(path, header);
225
226 const pending = Number((await $.store.get('pendingCount')) ?? 0) + 1;
227 await $.store.set('pendingCount', pending);
228 $.ui.log(`jev-vault-gate: captured to ${path} (score ${score.toFixed(2)})`);
229 } catch (error) {
230 $.ui.log(`jev-vault-gate: capture check skipped (${error instanceof Error ? error.message : String(error)})`);
231 }
232 return next(event);
233 });
234
235 on('classic.UserPromptSubmit', async ($, e, next) => {
236 const result = await next(e);
237 const pending = Number((await $.store.get('pendingCount')) ?? 0);
238 if (pending === 0) return result;
239 await $.store.set('pendingCount', 0);
240 const note = `jev-vault-gate: ${pending} new vault raw file(s) captured since last prompt — run /compile when convenient.`;
241 return { ...result, additionalContext: [...(result.additionalContext ?? []), note] };
242 });
243};
244