gramatr 0.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.
Files changed (50) hide show
  1. package/CLAUDE.md +18 -0
  2. package/README.md +78 -0
  3. package/bin/clean-legacy-install.ts +28 -0
  4. package/bin/get-token.py +3 -0
  5. package/bin/gmtr-login.ts +547 -0
  6. package/bin/gramatr.js +33 -0
  7. package/bin/gramatr.ts +248 -0
  8. package/bin/install.ts +756 -0
  9. package/bin/render-claude-hooks.ts +16 -0
  10. package/bin/statusline.ts +437 -0
  11. package/bin/uninstall.ts +289 -0
  12. package/bin/version-sync.ts +46 -0
  13. package/codex/README.md +28 -0
  14. package/codex/hooks/session-start.ts +73 -0
  15. package/codex/hooks/stop.ts +34 -0
  16. package/codex/hooks/user-prompt-submit.ts +76 -0
  17. package/codex/install.ts +99 -0
  18. package/codex/lib/codex-hook-utils.ts +48 -0
  19. package/codex/lib/codex-install-utils.ts +123 -0
  20. package/core/feedback.ts +55 -0
  21. package/core/formatting.ts +167 -0
  22. package/core/install.ts +114 -0
  23. package/core/installer-cli.ts +122 -0
  24. package/core/migration.ts +244 -0
  25. package/core/routing.ts +98 -0
  26. package/core/session.ts +202 -0
  27. package/core/targets.ts +292 -0
  28. package/core/types.ts +178 -0
  29. package/core/version.ts +2 -0
  30. package/gemini/README.md +95 -0
  31. package/gemini/hooks/session-start.ts +72 -0
  32. package/gemini/hooks/stop.ts +30 -0
  33. package/gemini/hooks/user-prompt-submit.ts +74 -0
  34. package/gemini/install.ts +272 -0
  35. package/gemini/lib/gemini-hook-utils.ts +63 -0
  36. package/gemini/lib/gemini-install-utils.ts +169 -0
  37. package/hooks/GMTRPromptEnricher.hook.ts +650 -0
  38. package/hooks/GMTRRatingCapture.hook.ts +198 -0
  39. package/hooks/GMTRSecurityValidator.hook.ts +399 -0
  40. package/hooks/GMTRToolTracker.hook.ts +181 -0
  41. package/hooks/StopOrchestrator.hook.ts +78 -0
  42. package/hooks/gmtr-tool-tracker-utils.ts +105 -0
  43. package/hooks/lib/gmtr-hook-utils.ts +771 -0
  44. package/hooks/lib/identity.ts +227 -0
  45. package/hooks/lib/notify.ts +46 -0
  46. package/hooks/lib/paths.ts +104 -0
  47. package/hooks/lib/transcript-parser.ts +452 -0
  48. package/hooks/session-end.hook.ts +168 -0
  49. package/hooks/session-start.hook.ts +490 -0
  50. package/package.json +54 -0
@@ -0,0 +1,650 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * GMTRPromptEnricher.hook.ts — grāmatr UserPromptSubmit Hook
4
+ *
5
+ * Fires before the agent processes a user prompt. Calls the grāmatr
6
+ * decision router to pre-classify the request, then injects
7
+ * the intelligence as additionalContext so the agent sees it.
8
+ *
9
+ * TRIGGER: UserPromptSubmit
10
+ * TIMEOUT: 15s default (configurable via GMTR_TIMEOUT)
11
+ * SAFETY: Never blocks. On any error, prompt passes through unmodified.
12
+ *
13
+ * What it injects:
14
+ * - Effort level (instant/fast/standard/extended/advanced/deep/comprehensive)
15
+ * - Intent type (search, retrieve, create, update, analyze, generate)
16
+ * - Matched skills from 25-capability registry
17
+ * - Reverse engineering (explicit/implicit wants and don't-wants)
18
+ * - ISC scaffold (preliminary Ideal State Criteria)
19
+ * - Suggested capabilities
20
+ * - Token savings metadata
21
+ */
22
+
23
+ import { getGitContext } from './lib/gmtr-hook-utils.ts';
24
+ import {
25
+ persistClassificationResult,
26
+ routePrompt,
27
+ shouldSkipPromptRouting,
28
+ } from '../core/routing.ts';
29
+ import type { RouteResponse } from '../core/types.ts';
30
+
31
+ // ── Types ──
32
+
33
+ interface HookInput {
34
+ session_id: string;
35
+ prompt: string;
36
+ cwd?: string;
37
+ permission_mode?: string;
38
+ hook_event_name: string;
39
+ transcript_path?: string;
40
+ }
41
+
42
+ // ── Configuration ──
43
+
44
+ const TIMEOUT_MS = parseInt(process.env.GMTR_TIMEOUT || '30000', 10);
45
+ const ENABLED = process.env.GMTR_ENRICH !== '0'; // disable with GMTR_ENRICH=0
46
+
47
+ // ── Project ID Resolution (Issue #76 — project-scoped memory) ──
48
+
49
+ function resolveProjectId(): string | null {
50
+ try {
51
+ // Read from the context file written by session-start.sh
52
+ const home = process.env.HOME || process.env.USERPROFILE || '';
53
+ const contextPath = `${home}/.claude/current-project-context.json`;
54
+ const context = JSON.parse(require('fs').readFileSync(contextPath, 'utf8'));
55
+ const remote = context.git_remote;
56
+ if (!remote || remote === 'no-remote') return null;
57
+
58
+ // Normalize git remote to org/repo format
59
+ // Handles: https://github.com/org/repo.git, git@github.com:org/repo.git, etc.
60
+ const match = remote.match(/[/:]([\w.-]+)\/([\w.-]+?)(?:\.git)?$/);
61
+ if (match) return `${match[1]}/${match[2]}`;
62
+
63
+ return null;
64
+ } catch {
65
+ return null;
66
+ }
67
+ }
68
+
69
+ const PROJECT_ID = resolveProjectId();
70
+
71
+ function persistLastClassification(
72
+ prompt: string,
73
+ sessionId: string,
74
+ route: RouteResponse | null,
75
+ downstreamModel: string,
76
+ ): void {
77
+ try {
78
+ const git = getGitContext();
79
+ if (!git) return;
80
+ persistClassificationResult({
81
+ rootDir: git.root,
82
+ prompt,
83
+ route,
84
+ downstreamModel,
85
+ clientType: 'claude_code',
86
+ agentName: 'Claude Code',
87
+ });
88
+ } catch {
89
+ // Non-critical
90
+ }
91
+ }
92
+
93
+ // ── Failure Tracking ──
94
+
95
+ type RouterFailure = {
96
+ reason: 'auth' | 'timeout' | 'server_down' | 'server_error' | 'parse_error' | 'unknown';
97
+ detail: string;
98
+ };
99
+
100
+ let lastFailure: RouterFailure | null = null;
101
+
102
+ // ── Format Failure Warning (LOUD — never silent) ──
103
+
104
+ function formatFailureWarning(failure: RouterFailure): string {
105
+ const lines: string[] = [];
106
+ lines.push('⚠️ [GMTR Intelligence — FAILED]');
107
+ lines.push('');
108
+
109
+ switch (failure.reason) {
110
+ case 'auth':
111
+ lines.push('🔒 AUTHENTICATION FAILURE — gramatr decision router cannot authenticate.');
112
+ lines.push(`Detail: ${failure.detail}`);
113
+ lines.push('');
114
+ lines.push('FIX: Set AIOS_MCP_TOKEN in your environment (same token as MCP server config)');
115
+ lines.push('Without this, NO pre-classification happens. The Algorithm runs without intelligence.');
116
+ break;
117
+ case 'timeout':
118
+ lines.push('⏱️ TIMEOUT — gramatr decision router did not respond in time.');
119
+ lines.push(`Detail: ${failure.detail}`);
120
+ lines.push('');
121
+ lines.push('The classifier may be overloaded or the server may be slow.');
122
+ break;
123
+ case 'server_down':
124
+ lines.push('🔴 SERVER UNREACHABLE — cannot connect to gramatr server.');
125
+ lines.push(`Detail: ${failure.detail}`);
126
+ lines.push('');
127
+ lines.push('Check: Is the server running? Is the URL correct? Is there a network issue?');
128
+ break;
129
+ case 'server_error':
130
+ lines.push('💥 SERVER ERROR — gramatr server returned an error.');
131
+ lines.push(`Detail: ${failure.detail}`);
132
+ break;
133
+ case 'parse_error':
134
+ lines.push('🔧 PARSE ERROR — could not understand the server response.');
135
+ lines.push(`Detail: ${failure.detail}`);
136
+ break;
137
+ default:
138
+ lines.push('❓ UNKNOWN ERROR — gramatr enrichment failed.');
139
+ lines.push(`Detail: ${failure.detail}`);
140
+ }
141
+
142
+ lines.push('');
143
+ lines.push('IMPORTANT: Tell the user about this error. Do not silently proceed without intelligence.');
144
+ lines.push('You should still follow the Algorithm from CLAUDE.md, but note that pre-classification is unavailable.');
145
+
146
+ return lines.join('\n');
147
+ }
148
+
149
+ // ── Format Intelligence Block ──
150
+
151
+ function formatIntelligence(data: RouteResponse): string {
152
+ const c = data.classification || {};
153
+ const ts = data.token_savings || {};
154
+ const es = data.execution_summary || {};
155
+
156
+ const lines: string[] = [];
157
+ const serverVersion = es.server_version || '';
158
+ const versionTag = serverVersion ? ` v${serverVersion}` : '';
159
+ lines.push(`[GMTR Intelligence — pre-classified by ${es.qwen_model || 'gramatr'}]`);
160
+
161
+ // ── PROJECT STATE (Issue #80 — render FIRST so agent can't miss it) ──
162
+ const ps = data.project_state;
163
+ if (ps && ps.project_id) {
164
+ lines.push('');
165
+ lines.push('ACTIVE PROJECT STATE:');
166
+ if (ps.active_prd_title) {
167
+ lines.push(` PRD: ${ps.active_prd_title}${ps.active_prd_id ? ` (${ps.active_prd_id})` : ''}`);
168
+ }
169
+ if (ps.current_phase) {
170
+ lines.push(` Phase: ${ps.current_phase}`);
171
+ }
172
+ if (ps.isc_summary && ps.isc_summary.total && ps.isc_summary.total > 0) {
173
+ const s = ps.isc_summary;
174
+ lines.push(` ISC: ${s.passing || 0}/${s.total} passing, ${s.failing || 0} failing, ${s.pending || 0} pending`);
175
+ }
176
+ if (ps.session_history_summary) {
177
+ lines.push(` Last session: "${ps.session_history_summary}"`);
178
+ }
179
+ if (ps.current_phase && ps.current_phase !== 'OBSERVE') {
180
+ lines.push(` ⚠️ RESUME from ${ps.current_phase} phase — do NOT restart OBSERVE`);
181
+ }
182
+ lines.push('');
183
+ }
184
+
185
+ // Core classification
186
+ const meta: string[] = [];
187
+ if (c.effort_level) meta.push(`Effort: ${c.effort_level}`);
188
+ if (c.intent_type) meta.push(`Intent: ${c.intent_type}`);
189
+ if (c.confidence) meta.push(`Confidence: ${Math.round(c.confidence * 100)}%`);
190
+ if (c.memory_tier) meta.push(`Memory: ${c.memory_tier}`);
191
+ if (meta.length) lines.push(meta.join(' | '));
192
+
193
+ // Matched skills
194
+ if (c.matched_skills?.length) {
195
+ lines.push(`Matched skills: ${c.matched_skills.join(', ')}`);
196
+ }
197
+
198
+ // Reverse engineering
199
+ const re = c.reverse_engineering;
200
+ if (re) {
201
+ if (re.explicit_wants?.length) {
202
+ lines.push('What user explicitly wants:');
203
+ for (const w of re.explicit_wants) lines.push(` - ${w}`);
204
+ }
205
+ if (re.implicit_wants?.length) {
206
+ lines.push('What is implied but not stated:');
207
+ for (const w of re.implicit_wants) lines.push(` - ${w}`);
208
+ }
209
+ if (re.explicit_dont_wants?.length) {
210
+ lines.push('What user explicitly does NOT want:');
211
+ for (const w of re.explicit_dont_wants) lines.push(` - ${w}`);
212
+ }
213
+ if (re.implicit_dont_wants?.length) {
214
+ lines.push('What user would clearly NOT want:');
215
+ for (const w of re.implicit_dont_wants) lines.push(` - ${w}`);
216
+ }
217
+ if (re.gotchas?.length) {
218
+ lines.push('Gotchas and edge cases:');
219
+ for (const g of re.gotchas) lines.push(` - ${g}`);
220
+ }
221
+ }
222
+
223
+ // Suggested capabilities
224
+ if (c.suggested_capabilities?.length) {
225
+ lines.push(`Suggested capabilities: ${c.suggested_capabilities.join(', ')}`);
226
+ }
227
+
228
+ // ISC scaffold
229
+ if (c.isc_scaffold?.length) {
230
+ lines.push('ISC Scaffold (preliminary Ideal State Criteria):');
231
+ for (let i = 0; i < c.isc_scaffold.length; i++) {
232
+ lines.push(` ${i + 1}. ${c.isc_scaffold[i]}`);
233
+ }
234
+ }
235
+
236
+ // Constraints
237
+ if (c.constraints_extracted?.length) {
238
+ lines.push(`Constraints: ${c.constraints_extracted.join('; ')}`);
239
+ }
240
+
241
+ // ── Layer 3: Pre-computed intelligence (Issue #79) ──
242
+
243
+ // Capability audit — pre-computed USE/DECLINE/N/A for 25 capabilities
244
+ const audit = data.capability_audit;
245
+ if (audit?.formatted_summary) {
246
+ lines.push('');
247
+ lines.push(audit.formatted_summary);
248
+ }
249
+
250
+ // Quality gate config — ISC validation rules as data
251
+ const qg = data.quality_gate_config;
252
+ if (qg?.rules?.length) {
253
+ lines.push('');
254
+ lines.push(`ISC Quality Gate: min ${qg.min_criteria || 4} criteria, ${qg.anti_required ? 'anti-criteria required' : 'anti-criteria optional'}, ${qg.word_range?.min || 8}-${qg.word_range?.max || 12} words each`);
255
+ const effortGated = qg.rules.filter(r => r.min_effort);
256
+ if (effortGated.length) {
257
+ lines.push(` Effort-gated rules: ${effortGated.map(r => `${r.id} (${r.min_effort}+)`).join(', ')}`);
258
+ }
259
+ }
260
+
261
+ // Context pre-load plan — what entities to fetch by tier
262
+ const preload = data.context_pre_load_plan;
263
+ if (preload?.entity_types?.length) {
264
+ lines.push(`Context pre-load: ${preload.tier} tier → ${preload.entity_types.join(', ')}`);
265
+ }
266
+
267
+ // ── Behavioral Directives (from steering rules — the product value) ──
268
+ const directives = data.behavioral_directives;
269
+ if (directives?.length) {
270
+ lines.push('');
271
+ lines.push('BEHAVIORAL DIRECTIVES (from GMTR steering rules — follow these):');
272
+ for (const d of directives) lines.push(` - ${d}`);
273
+ }
274
+
275
+ // ── Active Skill (server-delivered workflow — replaces client-side skill files) ──
276
+ const activeSkill = (data as any).active_skill;
277
+ if (activeSkill?.directives?.length) {
278
+ lines.push('');
279
+ lines.push(`ACTIVE SKILL: ${activeSkill.title || activeSkill.name} (phase: ${activeSkill.phase || 'ALL'})`);
280
+ for (const d of activeSkill.directives) lines.push(` - ${d}`);
281
+ }
282
+
283
+ // ── Behavioral Rules (server-delivered Algorithm framework — replaces CLAUDE.md) ──
284
+ const rules = (data as any).behavioral_rules;
285
+ const effort = c.effort_level || 'standard';
286
+ const intent = c.intent_type || 'analyze';
287
+ const memoryTier = c.memory_tier || 'none';
288
+
289
+ if (rules) {
290
+ // Server delivered the full framework — render from packet
291
+ lines.push('');
292
+ lines.push(`ALGORITHM: ${(rules.algorithm_phases || []).join(' → ')}`);
293
+
294
+ if (rules.hard_gates) {
295
+ lines.push('');
296
+ lines.push('HARD GATES:');
297
+ for (const [key, value] of Object.entries(rules.hard_gates)) {
298
+ lines.push(` - ${value}`);
299
+ }
300
+ }
301
+
302
+ if (rules.verification_rules?.length) {
303
+ lines.push('');
304
+ lines.push('VERIFICATION RULES:');
305
+ for (const r of rules.verification_rules) lines.push(` - ${r}`);
306
+ }
307
+
308
+ if (rules.code_rules?.length) {
309
+ lines.push('');
310
+ lines.push('CODE RULES:');
311
+ for (const r of rules.code_rules) lines.push(` - ${r}`);
312
+ }
313
+
314
+ if (rules.safety_rules?.length) {
315
+ lines.push('');
316
+ lines.push('SAFETY RULES:');
317
+ for (const r of rules.safety_rules) lines.push(` - ${r}`);
318
+ }
319
+ }
320
+
321
+ // ── MANDATORY GATES (always present regardless of effort level) ──
322
+
323
+ // Front-load memory query mandate when memory-dependent
324
+ if (memoryTier !== 'none') {
325
+ lines.push('');
326
+ lines.push('═══ MANDATORY: QUERY GMTR MEMORY BEFORE ANY WORK ═══');
327
+ lines.push('Memory tier: ' + memoryTier + ' — You MUST call search_semantic in OBSERVE before creating ISC.');
328
+ lines.push('Do NOT answer from MEMORY.md alone. GMTR has the live knowledge graph.');
329
+ }
330
+
331
+ lines.push('');
332
+ lines.push('═══ MANDATORY: CREATE ISC VIA TaskCreate BEFORE ANY WORK ═══');
333
+ lines.push('You MUST call the TaskCreate tool for each criterion below. This creates visible tracked tasks.');
334
+ lines.push('NEVER write criteria as manual text/tables. ALWAYS use TaskCreate + TaskList tools.');
335
+ lines.push('ALWAYS prefix task subjects with "ISC-C{N}: " for criteria or "ISC-A{N}: " for anti-criteria.');
336
+ lines.push('The ISC prefix is REQUIRED — it signals to the user that GMTR intelligence is driving the criteria.');
337
+ lines.push('This is a HARD GATE — do NOT proceed to any work until TaskCreate calls are complete.');
338
+
339
+ // ── Effort-level-scaled format ──
340
+ if (effort === 'instant') {
341
+ lines.push('');
342
+ lines.push('FORMAT (instant effort — minimal): State, do, confirm.');
343
+ } else if (effort === 'fast') {
344
+ lines.push('');
345
+ lines.push('FORMAT (fast effort — compressed):');
346
+ lines.push(' 1. "Understanding: [wants] | Avoiding: [don\'t wants]"');
347
+ if (c.isc_scaffold?.length) {
348
+ lines.push(' 2. MANDATORY — call TaskCreate for each:');
349
+ for (let i = 0; i < c.isc_scaffold.length; i++) {
350
+ lines.push(` [INVOKE TaskCreate: subject="ISC-C${i + 1}: ${c.isc_scaffold[i]}", description="Binary testable: PASS or FAIL."]`);
351
+ }
352
+ lines.push(' 3. [INVOKE TaskList to display criteria to user]');
353
+ lines.push(' 4. Do the work');
354
+ lines.push(' 5. [INVOKE TaskList], then [INVOKE TaskUpdate] each with PASS/FAIL + evidence');
355
+ } else {
356
+ lines.push(' 2. [INVOKE TaskCreate for at least 4 criteria you identify]');
357
+ lines.push(' 3. [INVOKE TaskList to display criteria to user]');
358
+ lines.push(' 4. Do the work');
359
+ lines.push(' 5. [INVOKE TaskList], then [INVOKE TaskUpdate] each with PASS/FAIL + evidence');
360
+ }
361
+ } else {
362
+ // Standard+ — server behavioral_rules drives the framework
363
+ lines.push('');
364
+ lines.push(`FORMAT (${effort} effort — full phases)`);
365
+
366
+ // Suggested agents for the task
367
+ const agents = data.suggested_agents;
368
+ if (agents?.length) {
369
+ lines.push('');
370
+ lines.push('Suggested agents:');
371
+ for (const a of agents) {
372
+ lines.push(` - ${a.display_name || a.name || 'agent'} (${a.model || 'default'}) — ${a.reason || ''}`);
373
+ }
374
+ }
375
+ }
376
+
377
+ // ── Memory Context (pre-loaded by router) ──
378
+ const mem = data.memory_context;
379
+ if (mem?.results?.length) {
380
+ lines.push('');
381
+ lines.push(`RELEVANT MEMORY (${mem.total_count} matches from GMTR knowledge graph):`);
382
+ for (const r of mem.results.slice(0, 5)) {
383
+ const sim = r.similarity ? ` (${Math.round(r.similarity * 100)}% match)` : '';
384
+ lines.push(` - [${r.entity_type || 'unknown'}] ${r.entity_name || 'unnamed'}${sim}: ${(r.content || '').substring(0, 150)}`);
385
+ }
386
+ }
387
+
388
+ // ── Composed Agents (auto-composed by GMTR for this task) ──
389
+ const composed = data.composed_agents;
390
+ if (composed?.length) {
391
+ lines.push('');
392
+ lines.push('GMTR COMPOSED AGENT (specialized for this task — USE THIS):');
393
+ for (const ca of composed) {
394
+ lines.push(` Agent: ${ca.display_name || ca.name || 'specialist'}`);
395
+ lines.push(` Domain: ${ca.task_domain || 'general'} | Expertise: ${(ca.expertise_areas || []).join(', ')}`);
396
+ lines.push(` Model: ${ca.model_preference || 'default'}`);
397
+ lines.push(` Context: ${ca.context_summary || 'memory-aware'}`);
398
+ lines.push(' ACTION: Use the Task tool with subagent_type="general-purpose" and inject this system prompt:');
399
+ lines.push(' --- AGENT SYSTEM PROMPT START ---');
400
+ // Truncate to avoid overwhelming the context — full prompt available via gmtr_invoke_agent
401
+ const promptPreview = (ca.system_prompt || '').substring(0, 800);
402
+ lines.push(` ${promptPreview}${(ca.system_prompt || '').length > 800 ? '... [truncated — use gmtr_invoke_agent for full prompt]' : ''}`);
403
+ lines.push(' --- AGENT SYSTEM PROMPT END ---');
404
+ }
405
+ }
406
+
407
+ // Token economics
408
+ const saved = ts.total_saved || ts.tokens_saved || 0;
409
+ if (saved > 0) {
410
+ lines.push(`[Token savings: ${saved.toLocaleString()} tokens saved per request (CLAUDE.md: ${(ts.claude_md_reduction || 0).toLocaleString()}, OBSERVE offload: ${(ts.observe_work_offloaded || 0).toLocaleString()})]`);
411
+ }
412
+
413
+ if (data.curated_context) {
414
+ lines.push('');
415
+ lines.push('CURATED CONTEXT:');
416
+ lines.push(data.curated_context.trim());
417
+ }
418
+
419
+ const diagnostics = data.packet_diagnostics;
420
+ if (diagnostics?.memory_context?.status === 'error' || diagnostics?.project_state?.status === 'error') {
421
+ lines.push('');
422
+ lines.push('PACKET DIAGNOSTICS:');
423
+ if (diagnostics.memory_context?.status === 'error') {
424
+ lines.push(` - Memory pre-load degraded: ${diagnostics.memory_context.error || 'unknown error'}`);
425
+ }
426
+ if (diagnostics.project_state?.status === 'error') {
427
+ lines.push(` - Project state degraded: ${diagnostics.project_state.error || 'unknown error'}`);
428
+ }
429
+ }
430
+
431
+ const degraded = data.execution_summary?.degraded_components?.filter((component) =>
432
+ component.startsWith('classification.')
433
+ ) || [];
434
+ if (degraded.length > 0) {
435
+ lines.push('');
436
+ lines.push('CLASSIFIER DIAGNOSTICS:');
437
+ for (const component of degraded) {
438
+ lines.push(` - ${component.replace('classification.', '').replace(/_/g, ' ')} degraded`);
439
+ }
440
+ }
441
+
442
+ return lines.join('\n');
443
+ }
444
+
445
+ // ── Status Line (stderr) ──
446
+
447
+ function emitStatus(data: RouteResponse | null, elapsed: number): void {
448
+ if (!data) {
449
+ if (lastFailure) {
450
+ process.stderr.write(`[gramatr] ✗ ${lastFailure.reason} (${elapsed}ms) — ${lastFailure.detail}\n`);
451
+ } else {
452
+ process.stderr.write(`[gramatr] ✗ no result (${elapsed}ms)\n`);
453
+ }
454
+ return;
455
+ }
456
+
457
+ const c = data.classification || {};
458
+ const es = data.execution_summary || {};
459
+ const st = es.stage_timing || {};
460
+ const version = es.server_version ? `v${es.server_version}` : '';
461
+
462
+ // Summary line
463
+ const classifier = es.qwen_model || 'unknown';
464
+ const confidence = c.confidence ? `${Math.round(c.confidence * 100)}%` : '';
465
+ process.stderr.write(
466
+ `[grāmatr${version ? ' ' + version : ''}] ✓ ${c.effort_level || '?'}/${c.intent_type || '?'} ${confidence} (${classifier}, ${elapsed}ms)\n`
467
+ );
468
+
469
+ // Per-stage breakdown if available
470
+ const stages: string[] = [];
471
+ if (st.distilbert_ms !== undefined) stages.push(`classify:${st.distilbert_ms}ms`);
472
+ if (st.mistral_classify_ms !== undefined) stages.push(`classify:${st.mistral_classify_ms}ms`);
473
+ if (st.tool_calling_ms !== undefined) stages.push(`memory:${st.tool_calling_ms}ms`);
474
+ if (st.reverse_engineering_ms !== undefined) stages.push(`RE:${st.reverse_engineering_ms}ms`);
475
+ if (st.isc_scaffold_ms !== undefined) stages.push(`ISC:${st.isc_scaffold_ms}ms`);
476
+ if (stages.length > 0) {
477
+ process.stderr.write(`[gramatr] stages: ${stages.join(' → ')}\n`);
478
+ }
479
+ }
480
+
481
+ // ── Main ──
482
+
483
+ async function main() {
484
+ // Fast exit if disabled
485
+ if (!ENABLED) {
486
+ console.log(JSON.stringify({}));
487
+ return;
488
+ }
489
+
490
+ let input: HookInput;
491
+ try {
492
+ let raw = '';
493
+ const chunks: Buffer[] = [];
494
+ await new Promise<void>((resolve) => {
495
+ const timeout = setTimeout(() => { process.stdin.destroy(); resolve(); }, 200);
496
+ process.stdin.on('data', (chunk: Buffer) => chunks.push(chunk));
497
+ process.stdin.on('end', () => { clearTimeout(timeout); resolve(); });
498
+ process.stdin.on('error', () => { clearTimeout(timeout); resolve(); });
499
+ process.stdin.resume();
500
+ });
501
+ raw = Buffer.concat(chunks).toString('utf8');
502
+ if (!raw.trim()) {
503
+ console.log(JSON.stringify({}));
504
+ return;
505
+ }
506
+ input = JSON.parse(raw);
507
+ } catch {
508
+ console.log(JSON.stringify({}));
509
+ return;
510
+ }
511
+
512
+ const { prompt, session_id } = input;
513
+ if (!prompt) {
514
+ console.log(JSON.stringify({}));
515
+ return;
516
+ }
517
+
518
+ // Skip trivial prompts — not worth the server call
519
+ if (shouldSkipPromptRouting(prompt)) {
520
+ process.stderr.write(`[gramatr] enricher: trivial prompt, skipped\n`);
521
+ console.log(JSON.stringify({}));
522
+ return;
523
+ }
524
+
525
+ // Detect downstream LLM model from environment (#161)
526
+ // Claude Code exposes the model in the status line and hook input
527
+ const downstreamModel = process.env.ANTHROPIC_MODEL
528
+ || process.env.CLAUDE_MODEL
529
+ || (input as any).model
530
+ || '';
531
+
532
+ // Emit pre-call status
533
+ process.stderr.write(`[gramatr] classifying...\n`);
534
+
535
+ // Call the router
536
+ const t0 = Date.now();
537
+ const routed = await routePrompt({
538
+ prompt,
539
+ projectId: PROJECT_ID || undefined,
540
+ sessionId: session_id,
541
+ timeoutMs: TIMEOUT_MS,
542
+ });
543
+ const result = routed.route;
544
+ const elapsed = Date.now() - t0;
545
+
546
+ if (!result && routed.error) {
547
+ switch (routed.error.reason) {
548
+ case 'auth':
549
+ lastFailure = { reason: 'auth', detail: routed.error.detail };
550
+ break;
551
+ case 'timeout':
552
+ lastFailure = { reason: 'timeout', detail: routed.error.detail };
553
+ break;
554
+ case 'network_error':
555
+ lastFailure = { reason: 'server_down', detail: routed.error.detail };
556
+ break;
557
+ case 'http_error':
558
+ case 'mcp_error':
559
+ lastFailure = { reason: 'server_error', detail: routed.error.detail };
560
+ break;
561
+ case 'parse_error':
562
+ lastFailure = { reason: 'parse_error', detail: routed.error.detail };
563
+ break;
564
+ default:
565
+ lastFailure = { reason: 'unknown', detail: routed.error.detail };
566
+ break;
567
+ }
568
+ } else if (result) {
569
+ lastFailure = null;
570
+ }
571
+
572
+ // Emit status to stderr
573
+ emitStatus(result, elapsed);
574
+
575
+ // Persist classification savings + downstream model for statusline and feedback
576
+ try {
577
+ const ts = result?.token_savings || {};
578
+ const es = result?.execution_summary || {};
579
+ const cl = result?.classification || {};
580
+ const savingsEntry = {
581
+ tokens_saved: ts.total_saved || ts.tokens_saved || 0,
582
+ savings_ratio: ts.savings_ratio || 0,
583
+ qwen_model: es.qwen_model || null,
584
+ qwen_time_ms: es.qwen_time_ms || 0,
585
+ effort: cl.effort_level || null,
586
+ intent: cl.intent_type || null,
587
+ confidence: cl.confidence || null,
588
+ memory_delivered: result?.memory_context?.results?.length || 0,
589
+ downstream_model: downstreamModel || null,
590
+ server_version: es.server_version || null,
591
+ stage_timing: es.stage_timing || null,
592
+ timestamp: Date.now(),
593
+ };
594
+ // Write latest classification savings
595
+ const { writeFileSync, appendFileSync, readFileSync } = await import('fs');
596
+ writeFileSync('/tmp/gmtr-classification-savings.json', JSON.stringify(savingsEntry));
597
+ // Append to history for cumulative tracking
598
+ const historyFile = '/tmp/gmtr-op-history.jsonl';
599
+ if (savingsEntry.tokens_saved > 0) {
600
+ const historyEntry = JSON.stringify({
601
+ tool: 'classification',
602
+ model: savingsEntry.qwen_model,
603
+ time_ms: savingsEntry.qwen_time_ms,
604
+ tokens_saved: savingsEntry.tokens_saved,
605
+ cache_hit: false,
606
+ timestamp: Date.now(),
607
+ });
608
+ appendFileSync(historyFile, historyEntry + '\n');
609
+ }
610
+ } catch {
611
+ // Non-critical — statusline just won't show savings
612
+ }
613
+
614
+ persistLastClassification(prompt, session_id, result, downstreamModel);
615
+
616
+ // If no result — DO NOT silently pass through. Tell the user what's broken.
617
+ if (!result || !result.classification) {
618
+ if (lastFailure) {
619
+ const errorContext = formatFailureWarning(lastFailure);
620
+ console.log(
621
+ JSON.stringify({
622
+ hookSpecificOutput: {
623
+ hookEventName: 'UserPromptSubmit',
624
+ additionalContext: errorContext,
625
+ },
626
+ })
627
+ );
628
+ } else {
629
+ console.log(JSON.stringify({}));
630
+ }
631
+ return;
632
+ }
633
+
634
+ // Format and inject
635
+ const context = formatIntelligence(result);
636
+
637
+ console.log(
638
+ JSON.stringify({
639
+ hookSpecificOutput: {
640
+ hookEventName: 'UserPromptSubmit',
641
+ additionalContext: context,
642
+ },
643
+ })
644
+ );
645
+ }
646
+
647
+ main().catch(() => {
648
+ // Never crash, never block
649
+ console.log(JSON.stringify({}));
650
+ });