@gramatr/client 0.5.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.
Files changed (65) hide show
  1. package/AGENTS.md +17 -0
  2. package/CLAUDE.md +18 -0
  3. package/README.md +108 -0
  4. package/bin/add-api-key.ts +264 -0
  5. package/bin/clean-legacy-install.ts +28 -0
  6. package/bin/clear-creds.ts +141 -0
  7. package/bin/get-token.py +3 -0
  8. package/bin/gmtr-login.ts +599 -0
  9. package/bin/gramatr.js +36 -0
  10. package/bin/gramatr.ts +374 -0
  11. package/bin/install.ts +716 -0
  12. package/bin/lib/config.ts +57 -0
  13. package/bin/lib/git.ts +111 -0
  14. package/bin/lib/stdin.ts +53 -0
  15. package/bin/logout.ts +76 -0
  16. package/bin/render-claude-hooks.ts +16 -0
  17. package/bin/statusline.ts +81 -0
  18. package/bin/uninstall.ts +289 -0
  19. package/chatgpt/README.md +95 -0
  20. package/chatgpt/install.ts +140 -0
  21. package/chatgpt/lib/chatgpt-install-utils.ts +89 -0
  22. package/codex/README.md +28 -0
  23. package/codex/hooks/session-start.ts +73 -0
  24. package/codex/hooks/stop.ts +34 -0
  25. package/codex/hooks/user-prompt-submit.ts +79 -0
  26. package/codex/install.ts +116 -0
  27. package/codex/lib/codex-hook-utils.ts +48 -0
  28. package/codex/lib/codex-install-utils.ts +123 -0
  29. package/core/auth.ts +170 -0
  30. package/core/feedback.ts +55 -0
  31. package/core/formatting.ts +179 -0
  32. package/core/install.ts +107 -0
  33. package/core/installer-cli.ts +122 -0
  34. package/core/migration.ts +479 -0
  35. package/core/routing.ts +108 -0
  36. package/core/session.ts +202 -0
  37. package/core/targets.ts +292 -0
  38. package/core/types.ts +179 -0
  39. package/core/version-check.ts +219 -0
  40. package/core/version.ts +47 -0
  41. package/desktop/README.md +72 -0
  42. package/desktop/build-mcpb.ts +166 -0
  43. package/desktop/install.ts +136 -0
  44. package/desktop/lib/desktop-install-utils.ts +70 -0
  45. package/gemini/README.md +95 -0
  46. package/gemini/hooks/session-start.ts +72 -0
  47. package/gemini/hooks/stop.ts +30 -0
  48. package/gemini/hooks/user-prompt-submit.ts +77 -0
  49. package/gemini/install.ts +281 -0
  50. package/gemini/lib/gemini-hook-utils.ts +63 -0
  51. package/gemini/lib/gemini-install-utils.ts +169 -0
  52. package/hooks/GMTRPromptEnricher.hook.ts +651 -0
  53. package/hooks/GMTRRatingCapture.hook.ts +198 -0
  54. package/hooks/GMTRSecurityValidator.hook.ts +399 -0
  55. package/hooks/GMTRToolTracker.hook.ts +181 -0
  56. package/hooks/StopOrchestrator.hook.ts +78 -0
  57. package/hooks/gmtr-tool-tracker-utils.ts +105 -0
  58. package/hooks/lib/gmtr-hook-utils.ts +770 -0
  59. package/hooks/lib/identity.ts +227 -0
  60. package/hooks/lib/notify.ts +46 -0
  61. package/hooks/lib/paths.ts +104 -0
  62. package/hooks/lib/transcript-parser.ts +452 -0
  63. package/hooks/session-end.hook.ts +168 -0
  64. package/hooks/session-start.hook.ts +501 -0
  65. package/package.json +63 -0
@@ -0,0 +1,123 @@
1
+ import { buildCodexHooksFile, type InstallHookMatcherEntry, type InstallHooksFile } from '../../core/install.ts';
2
+
3
+ export interface HookCommand {
4
+ type: 'command';
5
+ command: string;
6
+ statusMessage?: string;
7
+ timeout?: number;
8
+ }
9
+
10
+ export interface HookMatcherEntry {
11
+ matcher?: string;
12
+ hooks: HookCommand[];
13
+ }
14
+
15
+ export interface HooksFile {
16
+ hooks?: Record<string, HookMatcherEntry[]>;
17
+ }
18
+
19
+ export function upsertManagedBlock(
20
+ existing: string,
21
+ content: string,
22
+ startMarker: string,
23
+ endMarker: string,
24
+ ): string {
25
+ const block = `${startMarker}\n${content.trim()}\n${endMarker}`;
26
+ const pattern = new RegExp(
27
+ `${escapeRegExp(startMarker)}[\\s\\S]*?${escapeRegExp(endMarker)}`,
28
+ 'm',
29
+ );
30
+
31
+ if (pattern.test(existing)) {
32
+ return existing.replace(pattern, block);
33
+ }
34
+
35
+ const trimmed = existing.trim();
36
+ return trimmed ? `${trimmed}\n\n${block}\n` : `${block}\n`;
37
+ }
38
+
39
+ function escapeRegExp(text: string): string {
40
+ return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
41
+ }
42
+
43
+ function sameHook(a: HookMatcherEntry, b: HookMatcherEntry): boolean {
44
+ if ((a.matcher || '') !== (b.matcher || '')) return false;
45
+ if (a.hooks.length !== b.hooks.length) return false;
46
+ return a.hooks.every((hook, index) => {
47
+ const other = b.hooks[index];
48
+ return (
49
+ hook.type === other.type &&
50
+ hook.command === other.command &&
51
+ hook.statusMessage === other.statusMessage &&
52
+ hook.timeout === other.timeout
53
+ );
54
+ });
55
+ }
56
+
57
+ function mergeHookEntries(
58
+ existing: HookMatcherEntry[] = [],
59
+ managed: HookMatcherEntry[],
60
+ ): HookMatcherEntry[] {
61
+ const managedSuffixes = managed.flatMap((entry) =>
62
+ entry.hooks.map((hook) => hook.command.replace(/^.*\/codex\/hooks\//, '/codex/hooks/')),
63
+ );
64
+
65
+ const filtered = existing.filter(
66
+ (entry) =>
67
+ !managed.some((managedEntry) =>
68
+ managedEntry.hooks.some(() =>
69
+ entry.hooks.some((hook) => {
70
+ const normalized = hook.command.replace(/^.*\/codex\/hooks\//, '/codex/hooks/');
71
+ return managedSuffixes.includes(normalized);
72
+ }),
73
+ ),
74
+ ),
75
+ );
76
+
77
+ return [...filtered, ...managed];
78
+ }
79
+
80
+ export function buildManagedHooks(clientDir: string): HooksFile {
81
+ const built = buildCodexHooksFile(clientDir) as InstallHooksFile;
82
+ return { hooks: built.hooks as Record<string, InstallHookMatcherEntry[]> };
83
+ }
84
+
85
+ export function mergeHooksFile(existing: HooksFile, managed: HooksFile): HooksFile {
86
+ const output: HooksFile = { hooks: { ...(existing.hooks || {}) } };
87
+ const managedHooks = managed.hooks || {};
88
+
89
+ for (const [eventName, managedEntries] of Object.entries(managedHooks)) {
90
+ const currentEntries = output.hooks?.[eventName] || [];
91
+ const merged = mergeHookEntries(currentEntries, managedEntries);
92
+
93
+ if (
94
+ currentEntries.length === merged.length &&
95
+ currentEntries.every((entry, index) => sameHook(entry, merged[index]!))
96
+ ) {
97
+ continue;
98
+ }
99
+
100
+ output.hooks![eventName] = merged;
101
+ }
102
+
103
+ return output;
104
+ }
105
+
106
+ export function ensureCodexHooksFeature(configToml: string): string {
107
+ const text = configToml.trimEnd();
108
+
109
+ if (/^\s*codex_hooks\s*=\s*true\s*$/m.test(text) && /^\s*\[features\]\s*$/m.test(text)) {
110
+ return `${text}\n`;
111
+ }
112
+
113
+ if (/^\s*\[features\]\s*$/m.test(text)) {
114
+ if (/^\s*codex_hooks\s*=.*$/m.test(text)) {
115
+ return `${text.replace(/^\s*codex_hooks\s*=.*$/m, 'codex_hooks = true')}\n`;
116
+ }
117
+
118
+ return `${text.replace(/^\s*\[features\]\s*$/m, '[features]\ncodex_hooks = true')}\n`;
119
+ }
120
+
121
+ const prefix = text ? `${text}\n\n` : '';
122
+ return `${prefix}[features]\ncodex_hooks = true\n`;
123
+ }
package/core/auth.ts ADDED
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Shared installer auth helper — OAuth-first credential resolution.
3
+ *
4
+ * Issue #484: Eliminates the paste-API-key prompt from installer flows.
5
+ * The only interactive auth path is OAuth via gmtr-login.ts. API key
6
+ * management is handled by the explicit `gramatr add-api-key` subcommand.
7
+ *
8
+ * Resolution chain (first non-empty wins):
9
+ * 1. GRAMATR_API_KEY env var
10
+ * 2. GMTR_TOKEN env var (legacy)
11
+ * 3. ~/.gmtr.json `token` field
12
+ * 4. ~/gmtr-client/settings.json `auth.api_key` (legacy, skips placeholder)
13
+ * 5. If interactive + TTY: spawn gmtr-login.ts (OAuth)
14
+ * 6. Otherwise: throw clean actionable error
15
+ *
16
+ * This helper NEVER prompts for paste. If you need to add an API key,
17
+ * use `gramatr add-api-key` (interactive / piped / --from-env).
18
+ */
19
+
20
+ import { spawnSync } from "child_process";
21
+ import { existsSync, readFileSync } from "fs";
22
+ import { homedir } from "os";
23
+ import { dirname, join } from "path";
24
+ import { fileURLToPath } from "url";
25
+
26
+ export interface ResolveAuthTokenOptions {
27
+ interactive: boolean;
28
+ installerLabel: string;
29
+ }
30
+
31
+ const PLACEHOLDER_KEY = "REPLACE_WITH_YOUR_API_KEY";
32
+
33
+ function readJsonSafe(path: string): Record<string, any> | null {
34
+ if (!existsSync(path)) return null;
35
+ try {
36
+ return JSON.parse(readFileSync(path, "utf8"));
37
+ } catch {
38
+ return null;
39
+ }
40
+ }
41
+
42
+ function getHome(): string {
43
+ return process.env.HOME || process.env.USERPROFILE || homedir();
44
+ }
45
+
46
+ function gmtrJsonPath(): string {
47
+ return join(getHome(), ".gmtr.json");
48
+ }
49
+
50
+ function legacySettingsPath(): string {
51
+ const gmtrDir = process.env.GMTR_DIR || join(getHome(), "gmtr-client");
52
+ return join(gmtrDir, "settings.json");
53
+ }
54
+
55
+ function tokenFromEnv(): string | null {
56
+ if (process.env.GRAMATR_API_KEY) return process.env.GRAMATR_API_KEY;
57
+ if (process.env.GMTR_TOKEN) return process.env.GMTR_TOKEN;
58
+ return null;
59
+ }
60
+
61
+ function tokenFromGmtrJson(): string | null {
62
+ const data = readJsonSafe(gmtrJsonPath());
63
+ if (data && typeof data.token === "string" && data.token.trim()) {
64
+ return data.token.trim();
65
+ }
66
+ return null;
67
+ }
68
+
69
+ function tokenFromLegacySettings(): string | null {
70
+ const data = readJsonSafe(legacySettingsPath());
71
+ if (!data) return null;
72
+ const key = data.auth?.api_key;
73
+ if (typeof key === "string" && key && key !== PLACEHOLDER_KEY) {
74
+ return key;
75
+ }
76
+ return null;
77
+ }
78
+
79
+ function findGmtrLoginScript(): string | null {
80
+ // Resolve gmtr-login.ts relative to this file. In source layout it's at
81
+ // ../bin/gmtr-login.ts; in installed layout the same relative path holds.
82
+ try {
83
+ const here = dirname(fileURLToPath(import.meta.url));
84
+ const candidate = join(here, "..", "bin", "gmtr-login.ts");
85
+ if (existsSync(candidate)) return candidate;
86
+ } catch {
87
+ // ignore
88
+ }
89
+ // Fallback to installed client dir
90
+ const installedCandidate = join(
91
+ process.env.GMTR_DIR || join(getHome(), "gmtr-client"),
92
+ "bin",
93
+ "gmtr-login.ts",
94
+ );
95
+ if (existsSync(installedCandidate)) return installedCandidate;
96
+ return null;
97
+ }
98
+
99
+ function spawnOAuthLogin(): { ok: boolean; reason?: string } {
100
+ const script = findGmtrLoginScript();
101
+ if (!script) {
102
+ return { ok: false, reason: "gmtr-login.ts not found on disk" };
103
+ }
104
+ // Match the existing handleAuth() pattern in bin/install.ts:383-401 —
105
+ // npx tsx with inherited stdio so the browser-open message reaches
106
+ // the user and stdin works correctly.
107
+ const npxBin = process.platform === "win32" ? "npx.cmd" : "npx";
108
+ const result = spawnSync(npxBin, ["tsx", script], {
109
+ stdio: "inherit",
110
+ env: { ...process.env },
111
+ });
112
+ if (result.error) {
113
+ return { ok: false, reason: result.error.message };
114
+ }
115
+ if (typeof result.status === "number" && result.status !== 0) {
116
+ return { ok: false, reason: `gmtr-login exited with code ${result.status}` };
117
+ }
118
+ return { ok: true };
119
+ }
120
+
121
+ const HEADLESS_ERROR =
122
+ "No gramatr credentials found. Set one of:\n" +
123
+ " - GRAMATR_API_KEY environment variable\n" +
124
+ " - Run: npx @gramatr/client@latest login (interactive, recommended)\n" +
125
+ " - Run: npx @gramatr/client@latest add-api-key (for headless / CI use)\n" +
126
+ "Then re-run the install.";
127
+
128
+ const OAUTH_FAILED_ERROR =
129
+ 'OAuth login failed. Run "npx @gramatr/client@latest login" to retry, ' +
130
+ 'or "npx @gramatr/client@latest add-api-key" to use an API key instead.';
131
+
132
+ /**
133
+ * Resolve a gramatr auth token, OAuth-first.
134
+ *
135
+ * Never prompts for an API key paste. If interactive and no token is
136
+ * stored, spawns gmtr-login.ts to run the OAuth flow. If headless or
137
+ * non-interactive, throws an actionable error pointing the user at the
138
+ * explicit `gramatr login` and `gramatr add-api-key` commands.
139
+ */
140
+ export async function resolveAuthToken(opts: ResolveAuthTokenOptions): Promise<string> {
141
+ // 1 + 2: env vars
142
+ const envToken = tokenFromEnv();
143
+ if (envToken) return envToken;
144
+
145
+ // 3: ~/.gmtr.json
146
+ const stored = tokenFromGmtrJson();
147
+ if (stored) return stored;
148
+
149
+ // 4: legacy settings.json
150
+ const legacy = tokenFromLegacySettings();
151
+ if (legacy) return legacy;
152
+
153
+ // 5: spawn OAuth if interactive + TTY
154
+ const hasTty = Boolean(process.stdin.isTTY);
155
+ if (opts.interactive && hasTty) {
156
+ process.stdout.write(
157
+ `[${opts.installerLabel}] No gramatr credentials found. Starting OAuth login...\n`,
158
+ );
159
+ const result = spawnOAuthLogin();
160
+ if (!result.ok) {
161
+ throw new Error(OAUTH_FAILED_ERROR);
162
+ }
163
+ const after = tokenFromGmtrJson();
164
+ if (after) return after;
165
+ throw new Error("OAuth completed but no token was stored");
166
+ }
167
+
168
+ // 6: headless / non-interactive — clean actionable error
169
+ throw new Error(HEADLESS_ERROR);
170
+ }
@@ -0,0 +1,55 @@
1
+ import {
2
+ callMcpToolDetailed,
3
+ markClassificationFeedbackSubmitted,
4
+ readGmtrConfig,
5
+ } from '../hooks/lib/gmtr-hook-utils.ts';
6
+
7
+ export interface ClassificationFeedbackSubmitOptions {
8
+ rootDir: string;
9
+ sessionId: string;
10
+ originalPrompt?: string;
11
+ clientType: string;
12
+ agentName: string;
13
+ downstreamProvider?: string;
14
+ }
15
+
16
+ export async function submitPendingClassificationFeedback(
17
+ options: ClassificationFeedbackSubmitOptions,
18
+ ): Promise<{ submitted: boolean; reason: string }> {
19
+ const config = readGmtrConfig(options.rootDir);
20
+ const last = config?.current_session?.last_classification;
21
+
22
+ if (!last?.pending_feedback) {
23
+ return { submitted: false, reason: 'no_pending_feedback' };
24
+ }
25
+
26
+ const originalPrompt = (options.originalPrompt || last.original_prompt || '').trim();
27
+ if (!originalPrompt) {
28
+ return { submitted: false, reason: 'missing_original_prompt' };
29
+ }
30
+
31
+ if (config?.current_session?.session_id && config.current_session.session_id !== options.sessionId) {
32
+ return { submitted: false, reason: 'session_mismatch' };
33
+ }
34
+
35
+ const result = await callMcpToolDetailed(
36
+ 'gmtr_classification_feedback',
37
+ {
38
+ timestamp: last.timestamp,
39
+ was_correct: true,
40
+ original_prompt: originalPrompt,
41
+ downstream_model: last.downstream_model || undefined,
42
+ downstream_provider: options.downstreamProvider,
43
+ client_type: options.clientType,
44
+ agent_name: options.agentName,
45
+ },
46
+ 10000,
47
+ );
48
+
49
+ if (result.data) {
50
+ markClassificationFeedbackSubmitted(options.rootDir);
51
+ return { submitted: true, reason: 'submitted' };
52
+ }
53
+
54
+ return { submitted: false, reason: result.error?.reason || 'unknown_error' };
55
+ }
@@ -0,0 +1,179 @@
1
+ import type {
2
+ HandoffResponse,
3
+ HookFailure,
4
+ RouteResponse,
5
+ SessionStartResponse,
6
+ } from './types.ts';
7
+
8
+ function trimLine(line: string): string {
9
+ return line.replace(/\s+/g, ' ').trim();
10
+ }
11
+
12
+ function formatList(title: string, items: string[] | undefined, maxItems = 5): string[] {
13
+ if (!items || items.length === 0) return [];
14
+ const lines = [title];
15
+ for (const item of items.slice(0, maxItems)) {
16
+ const text = trimLine(item);
17
+ if (text) lines.push(`- ${text}`);
18
+ }
19
+ return lines.length > 1 ? lines : [];
20
+ }
21
+
22
+ function formatMemoryContext(route: RouteResponse): string[] {
23
+ const results = route.memory_context?.results ?? [];
24
+ if (results.length === 0) return [];
25
+
26
+ const lines = ['Memory context:'];
27
+ for (const result of results.slice(0, 3)) {
28
+ const labelParts = [result.entity_name, result.entity_type].filter(Boolean);
29
+ const label = labelParts.join(' / ');
30
+ const content = trimLine(result.content || '').slice(0, 220);
31
+ if (label && content) {
32
+ lines.push(`- ${label}: ${content}`);
33
+ } else if (label) {
34
+ lines.push(`- ${label}`);
35
+ } else if (content) {
36
+ lines.push(`- ${content}`);
37
+ }
38
+ }
39
+ return lines.length > 1 ? lines : [];
40
+ }
41
+
42
+ export function buildUserPromptAdditionalContext(route: RouteResponse): string {
43
+ const lines: string[] = [];
44
+
45
+ // Cross-tool statusline injection (#496 Phase 3). When the server returns
46
+ // a composed markdown block, prepend it so Codex / Gemini surface gramatr
47
+ // status at the top of every turn. Claude Code's routing call leaves the
48
+ // include_statusline flag off (ISC-A1 of #496) because CC has a native
49
+ // terminal statusline and we must not double-inject.
50
+ if (typeof route.statusline_markdown === 'string' && route.statusline_markdown.trim()) {
51
+ lines.push(route.statusline_markdown.trim());
52
+ lines.push('');
53
+ }
54
+
55
+ lines.push('[GMTR Intelligence]');
56
+ const classification = route.classification;
57
+
58
+ if (classification) {
59
+ const parts = [
60
+ classification.effort_level ? `effort=${classification.effort_level}` : '',
61
+ classification.intent_type ? `intent=${classification.intent_type}` : '',
62
+ classification.memory_tier ? `memory=${classification.memory_tier}` : '',
63
+ ].filter(Boolean);
64
+ if (parts.length > 0) lines.push(parts.join(' | '));
65
+ }
66
+
67
+ if (route.project_state?.current_phase || route.project_state?.active_prd_title) {
68
+ const phase = route.project_state.current_phase || 'unknown';
69
+ const prd = route.project_state.active_prd_title;
70
+ lines.push(`Project state: phase=${phase}${prd ? ` | prd=${prd}` : ''}`);
71
+ }
72
+
73
+ if (route.capability_audit?.formatted_summary) {
74
+ lines.push(route.capability_audit.formatted_summary.trim());
75
+ }
76
+
77
+ if (route.context_pre_load_plan?.entity_types?.length) {
78
+ const tier = route.context_pre_load_plan.tier || 'none';
79
+ lines.push(`Preload plan: tier=${tier} | entities=${route.context_pre_load_plan.entity_types.join(', ')}`);
80
+ }
81
+
82
+ lines.push(...formatList('Explicit wants:', classification?.reverse_engineering?.explicit_wants));
83
+ lines.push(...formatList('Implicit wants:', classification?.reverse_engineering?.implicit_wants, 4));
84
+ lines.push(...formatList('Gotchas:', classification?.reverse_engineering?.gotchas, 4));
85
+ lines.push(...formatList('Constraints:', classification?.constraints_extracted, 4));
86
+ lines.push(...formatList('ISC scaffold:', classification?.isc_scaffold, 5));
87
+ lines.push(...formatList('Behavioral directives:', route.behavioral_directives, 6));
88
+ lines.push(...formatMemoryContext(route));
89
+
90
+ if (route.curated_context) {
91
+ lines.push('Curated context:');
92
+ lines.push(route.curated_context.trim());
93
+ }
94
+
95
+ if (route.project_state?.session_history_summary) {
96
+ lines.push('Session history:');
97
+ lines.push(route.project_state.session_history_summary.trim());
98
+ }
99
+
100
+ if (route.packet_diagnostics?.memory_context?.status === 'error') {
101
+ lines.push('Memory diagnostics:');
102
+ lines.push(`- ${trimLine(route.packet_diagnostics.memory_context.error || 'memory pre-load degraded')}`);
103
+ }
104
+
105
+ if (route.packet_diagnostics?.project_state?.status === 'error') {
106
+ lines.push('Project state diagnostics:');
107
+ lines.push(`- ${trimLine(route.packet_diagnostics.project_state.error || 'project state degraded')}`);
108
+ }
109
+
110
+ const degradedClassifierStages = route.execution_summary?.degraded_components?.filter((component) =>
111
+ component.startsWith('classification.'),
112
+ ) || [];
113
+ if (degradedClassifierStages.length > 0) {
114
+ lines.push('Classifier diagnostics:');
115
+ for (const component of degradedClassifierStages) {
116
+ lines.push(`- ${trimLine(component.replace('classification.', '').replace(/_/g, ' ') + ' degraded')}`);
117
+ }
118
+ }
119
+
120
+ return lines.join('\n').trim();
121
+ }
122
+
123
+ export function buildSessionStartAdditionalContext(
124
+ projectId: string,
125
+ sessionStart: SessionStartResponse | null,
126
+ handoff: HandoffResponse | null,
127
+ ): string {
128
+ const lines = ['[GMTR Session Context]'];
129
+ lines.push(`Project: ${projectId}`);
130
+
131
+ if (sessionStart?.interaction_id) {
132
+ lines.push(`Interaction: ${sessionStart.interaction_id}`);
133
+ }
134
+ if (handoff?.source || handoff?._meta?.platform || handoff?._meta?.branch) {
135
+ const metaParts = [
136
+ handoff.source ? `source=${handoff.source}` : '',
137
+ handoff._meta?.platform ? `platform=${handoff._meta.platform}` : '',
138
+ handoff._meta?.branch ? `branch=${handoff._meta.branch}` : '',
139
+ ].filter(Boolean);
140
+ if (metaParts.length > 0) lines.push(`Handoff meta: ${metaParts.join(' | ')}`);
141
+ }
142
+
143
+ if (handoff?.where_we_are) {
144
+ lines.push('Where we are:');
145
+ lines.push(handoff.where_we_are.trim());
146
+ }
147
+ if (handoff?.what_shipped) {
148
+ lines.push('What shipped:');
149
+ lines.push(handoff.what_shipped.trim());
150
+ }
151
+ if (handoff?.whats_next) {
152
+ lines.push('What is next:');
153
+ lines.push(handoff.whats_next.trim());
154
+ }
155
+ if (handoff?.key_context) {
156
+ lines.push('Key context:');
157
+ lines.push(handoff.key_context.trim());
158
+ }
159
+ if (handoff?.dont_forget) {
160
+ lines.push('Do not forget:');
161
+ lines.push(handoff.dont_forget.trim());
162
+ }
163
+
164
+ if (!handoff?.where_we_are && !handoff?.whats_next && !handoff?.key_context) {
165
+ lines.push('No saved handoff was found. Query gramatr memory before doing context recovery.');
166
+ }
167
+
168
+ return lines.join('\n').trim();
169
+ }
170
+
171
+ export function buildHookFailureAdditionalContext(failure: HookFailure): string {
172
+ const lines = ['[GMTR Intelligence Unavailable]'];
173
+ lines.push(failure.title);
174
+ lines.push(`Detail: ${trimLine(failure.detail)}`);
175
+ if (failure.action) {
176
+ lines.push(`Action: ${trimLine(failure.action)}`);
177
+ }
178
+ return lines.join('\n').trim();
179
+ }
@@ -0,0 +1,107 @@
1
+ export interface InstallHookCommand {
2
+ type: 'command';
3
+ command: string;
4
+ statusMessage?: string;
5
+ timeout?: number;
6
+ }
7
+
8
+ export interface InstallHookMatcherEntry {
9
+ matcher?: string;
10
+ hooks: InstallHookCommand[];
11
+ }
12
+
13
+ export interface InstallHooksFile {
14
+ hooks: Record<string, InstallHookMatcherEntry[]>;
15
+ }
16
+
17
+ interface HookSpec {
18
+ event: string;
19
+ matcher?: string;
20
+ relativeCommand: string;
21
+ statusMessage?: string;
22
+ timeout?: number;
23
+ }
24
+
25
+ interface ClaudeHookOptions {
26
+ includeOptionalUx?: boolean;
27
+ }
28
+
29
+ const CLAUDE_HOOKS: HookSpec[] = [
30
+ { event: 'PreToolUse', matcher: 'Bash', relativeCommand: 'hooks/GMTRSecurityValidator.hook.ts' },
31
+ { event: 'PreToolUse', matcher: 'Edit', relativeCommand: 'hooks/GMTRSecurityValidator.hook.ts' },
32
+ { event: 'PreToolUse', matcher: 'Write', relativeCommand: 'hooks/GMTRSecurityValidator.hook.ts' },
33
+ { event: 'PreToolUse', matcher: 'Read', relativeCommand: 'hooks/GMTRSecurityValidator.hook.ts' },
34
+ { event: 'PostToolUse', matcher: 'mcp__.*gramatr.*__', relativeCommand: 'hooks/GMTRToolTracker.hook.ts' },
35
+ { event: 'UserPromptSubmit', relativeCommand: 'hooks/GMTRRatingCapture.hook.ts' },
36
+ { event: 'UserPromptSubmit', relativeCommand: 'hooks/GMTRPromptEnricher.hook.ts' },
37
+ { event: 'SessionStart', relativeCommand: 'hooks/session-start.hook.ts' },
38
+ { event: 'SessionEnd', relativeCommand: 'hooks/session-end.hook.ts' },
39
+ { event: 'Stop', relativeCommand: 'hooks/StopOrchestrator.hook.ts' },
40
+ ];
41
+
42
+ const CODEX_HOOKS: HookSpec[] = [
43
+ {
44
+ event: 'SessionStart',
45
+ matcher: 'startup|resume',
46
+ relativeCommand: 'codex/hooks/session-start.ts',
47
+ statusMessage: 'Loading gramatr session context',
48
+ timeout: 15,
49
+ },
50
+ {
51
+ event: 'UserPromptSubmit',
52
+ relativeCommand: 'codex/hooks/user-prompt-submit.ts',
53
+ statusMessage: 'Routing request through gramatr',
54
+ timeout: 15,
55
+ },
56
+ {
57
+ event: 'Stop',
58
+ relativeCommand: 'codex/hooks/stop.ts',
59
+ statusMessage: 'Submitting gramatr classification feedback',
60
+ timeout: 10,
61
+ },
62
+ ];
63
+
64
+ /**
65
+ * All hook commands are invoked via `npx tsx`. This is the single, portable
66
+ * runner for every gramatr client install. See issue #468 for the architectural
67
+ * rationale: bun detection at install time silently produced broken configs on
68
+ * hosts where the runtime PATH did not match the installer's PATH.
69
+ */
70
+ const TS_RUNNER = 'npx tsx';
71
+
72
+ function buildHooksFile(clientDir: string, specs: HookSpec[]): InstallHooksFile {
73
+ const hooks: Record<string, InstallHookMatcherEntry[]> = {};
74
+
75
+ for (const spec of specs) {
76
+ const entry: InstallHookMatcherEntry = {
77
+ hooks: [
78
+ {
79
+ type: 'command',
80
+ command: `${TS_RUNNER} "${clientDir}/${spec.relativeCommand}"`,
81
+ statusMessage: spec.statusMessage,
82
+ timeout: spec.timeout,
83
+ },
84
+ ],
85
+ };
86
+
87
+ if (spec.matcher) {
88
+ entry.matcher = spec.matcher;
89
+ }
90
+
91
+ hooks[spec.event] ||= [];
92
+ hooks[spec.event].push(entry);
93
+ }
94
+
95
+ return { hooks };
96
+ }
97
+
98
+ export function buildClaudeHooksFile(
99
+ clientDir: string,
100
+ _options: ClaudeHookOptions = {},
101
+ ): InstallHooksFile {
102
+ return buildHooksFile(clientDir, CLAUDE_HOOKS);
103
+ }
104
+
105
+ export function buildCodexHooksFile(clientDir: string): InstallHooksFile {
106
+ return buildHooksFile(clientDir, CODEX_HOOKS);
107
+ }