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.
- package/CLAUDE.md +18 -0
- package/README.md +78 -0
- package/bin/clean-legacy-install.ts +28 -0
- package/bin/get-token.py +3 -0
- package/bin/gmtr-login.ts +547 -0
- package/bin/gramatr.js +33 -0
- package/bin/gramatr.ts +248 -0
- package/bin/install.ts +756 -0
- package/bin/render-claude-hooks.ts +16 -0
- package/bin/statusline.ts +437 -0
- package/bin/uninstall.ts +289 -0
- package/bin/version-sync.ts +46 -0
- package/codex/README.md +28 -0
- package/codex/hooks/session-start.ts +73 -0
- package/codex/hooks/stop.ts +34 -0
- package/codex/hooks/user-prompt-submit.ts +76 -0
- package/codex/install.ts +99 -0
- package/codex/lib/codex-hook-utils.ts +48 -0
- package/codex/lib/codex-install-utils.ts +123 -0
- package/core/feedback.ts +55 -0
- package/core/formatting.ts +167 -0
- package/core/install.ts +114 -0
- package/core/installer-cli.ts +122 -0
- package/core/migration.ts +244 -0
- package/core/routing.ts +98 -0
- package/core/session.ts +202 -0
- package/core/targets.ts +292 -0
- package/core/types.ts +178 -0
- package/core/version.ts +2 -0
- package/gemini/README.md +95 -0
- package/gemini/hooks/session-start.ts +72 -0
- package/gemini/hooks/stop.ts +30 -0
- package/gemini/hooks/user-prompt-submit.ts +74 -0
- package/gemini/install.ts +272 -0
- package/gemini/lib/gemini-hook-utils.ts +63 -0
- package/gemini/lib/gemini-install-utils.ts +169 -0
- package/hooks/GMTRPromptEnricher.hook.ts +650 -0
- package/hooks/GMTRRatingCapture.hook.ts +198 -0
- package/hooks/GMTRSecurityValidator.hook.ts +399 -0
- package/hooks/GMTRToolTracker.hook.ts +181 -0
- package/hooks/StopOrchestrator.hook.ts +78 -0
- package/hooks/gmtr-tool-tracker-utils.ts +105 -0
- package/hooks/lib/gmtr-hook-utils.ts +771 -0
- package/hooks/lib/identity.ts +227 -0
- package/hooks/lib/notify.ts +46 -0
- package/hooks/lib/paths.ts +104 -0
- package/hooks/lib/transcript-parser.ts +452 -0
- package/hooks/session-end.hook.ts +168 -0
- package/hooks/session-start.hook.ts +490 -0
- package/package.json +54 -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/feedback.ts
ADDED
|
@@ -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,167 @@
|
|
|
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 = ['[GMTR Intelligence]'];
|
|
44
|
+
const classification = route.classification;
|
|
45
|
+
|
|
46
|
+
if (classification) {
|
|
47
|
+
const parts = [
|
|
48
|
+
classification.effort_level ? `effort=${classification.effort_level}` : '',
|
|
49
|
+
classification.intent_type ? `intent=${classification.intent_type}` : '',
|
|
50
|
+
classification.memory_tier ? `memory=${classification.memory_tier}` : '',
|
|
51
|
+
].filter(Boolean);
|
|
52
|
+
if (parts.length > 0) lines.push(parts.join(' | '));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (route.project_state?.current_phase || route.project_state?.active_prd_title) {
|
|
56
|
+
const phase = route.project_state.current_phase || 'unknown';
|
|
57
|
+
const prd = route.project_state.active_prd_title;
|
|
58
|
+
lines.push(`Project state: phase=${phase}${prd ? ` | prd=${prd}` : ''}`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (route.capability_audit?.formatted_summary) {
|
|
62
|
+
lines.push(route.capability_audit.formatted_summary.trim());
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (route.context_pre_load_plan?.entity_types?.length) {
|
|
66
|
+
const tier = route.context_pre_load_plan.tier || 'none';
|
|
67
|
+
lines.push(`Preload plan: tier=${tier} | entities=${route.context_pre_load_plan.entity_types.join(', ')}`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
lines.push(...formatList('Explicit wants:', classification?.reverse_engineering?.explicit_wants));
|
|
71
|
+
lines.push(...formatList('Implicit wants:', classification?.reverse_engineering?.implicit_wants, 4));
|
|
72
|
+
lines.push(...formatList('Gotchas:', classification?.reverse_engineering?.gotchas, 4));
|
|
73
|
+
lines.push(...formatList('Constraints:', classification?.constraints_extracted, 4));
|
|
74
|
+
lines.push(...formatList('ISC scaffold:', classification?.isc_scaffold, 5));
|
|
75
|
+
lines.push(...formatList('Behavioral directives:', route.behavioral_directives, 6));
|
|
76
|
+
lines.push(...formatMemoryContext(route));
|
|
77
|
+
|
|
78
|
+
if (route.curated_context) {
|
|
79
|
+
lines.push('Curated context:');
|
|
80
|
+
lines.push(route.curated_context.trim());
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (route.project_state?.session_history_summary) {
|
|
84
|
+
lines.push('Session history:');
|
|
85
|
+
lines.push(route.project_state.session_history_summary.trim());
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (route.packet_diagnostics?.memory_context?.status === 'error') {
|
|
89
|
+
lines.push('Memory diagnostics:');
|
|
90
|
+
lines.push(`- ${trimLine(route.packet_diagnostics.memory_context.error || 'memory pre-load degraded')}`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (route.packet_diagnostics?.project_state?.status === 'error') {
|
|
94
|
+
lines.push('Project state diagnostics:');
|
|
95
|
+
lines.push(`- ${trimLine(route.packet_diagnostics.project_state.error || 'project state degraded')}`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const degradedClassifierStages = route.execution_summary?.degraded_components?.filter((component) =>
|
|
99
|
+
component.startsWith('classification.'),
|
|
100
|
+
) || [];
|
|
101
|
+
if (degradedClassifierStages.length > 0) {
|
|
102
|
+
lines.push('Classifier diagnostics:');
|
|
103
|
+
for (const component of degradedClassifierStages) {
|
|
104
|
+
lines.push(`- ${trimLine(component.replace('classification.', '').replace(/_/g, ' ') + ' degraded')}`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return lines.join('\n').trim();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function buildSessionStartAdditionalContext(
|
|
112
|
+
projectId: string,
|
|
113
|
+
sessionStart: SessionStartResponse | null,
|
|
114
|
+
handoff: HandoffResponse | null,
|
|
115
|
+
): string {
|
|
116
|
+
const lines = ['[GMTR Session Context]'];
|
|
117
|
+
lines.push(`Project: ${projectId}`);
|
|
118
|
+
|
|
119
|
+
if (sessionStart?.interaction_id) {
|
|
120
|
+
lines.push(`Interaction: ${sessionStart.interaction_id}`);
|
|
121
|
+
}
|
|
122
|
+
if (handoff?.source || handoff?._meta?.platform || handoff?._meta?.branch) {
|
|
123
|
+
const metaParts = [
|
|
124
|
+
handoff.source ? `source=${handoff.source}` : '',
|
|
125
|
+
handoff._meta?.platform ? `platform=${handoff._meta.platform}` : '',
|
|
126
|
+
handoff._meta?.branch ? `branch=${handoff._meta.branch}` : '',
|
|
127
|
+
].filter(Boolean);
|
|
128
|
+
if (metaParts.length > 0) lines.push(`Handoff meta: ${metaParts.join(' | ')}`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (handoff?.where_we_are) {
|
|
132
|
+
lines.push('Where we are:');
|
|
133
|
+
lines.push(handoff.where_we_are.trim());
|
|
134
|
+
}
|
|
135
|
+
if (handoff?.what_shipped) {
|
|
136
|
+
lines.push('What shipped:');
|
|
137
|
+
lines.push(handoff.what_shipped.trim());
|
|
138
|
+
}
|
|
139
|
+
if (handoff?.whats_next) {
|
|
140
|
+
lines.push('What is next:');
|
|
141
|
+
lines.push(handoff.whats_next.trim());
|
|
142
|
+
}
|
|
143
|
+
if (handoff?.key_context) {
|
|
144
|
+
lines.push('Key context:');
|
|
145
|
+
lines.push(handoff.key_context.trim());
|
|
146
|
+
}
|
|
147
|
+
if (handoff?.dont_forget) {
|
|
148
|
+
lines.push('Do not forget:');
|
|
149
|
+
lines.push(handoff.dont_forget.trim());
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (!handoff?.where_we_are && !handoff?.whats_next && !handoff?.key_context) {
|
|
153
|
+
lines.push('No saved handoff was found. Query gramatr memory before doing context recovery.');
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return lines.join('\n').trim();
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function buildHookFailureAdditionalContext(failure: HookFailure): string {
|
|
160
|
+
const lines = ['[GMTR Intelligence Unavailable]'];
|
|
161
|
+
lines.push(failure.title);
|
|
162
|
+
lines.push(`Detail: ${trimLine(failure.detail)}`);
|
|
163
|
+
if (failure.action) {
|
|
164
|
+
lines.push(`Action: ${trimLine(failure.action)}`);
|
|
165
|
+
}
|
|
166
|
+
return lines.join('\n').trim();
|
|
167
|
+
}
|
package/core/install.ts
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
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
|
+
tsRunner?: string;
|
|
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
|
+
* Detect the best available TypeScript runner.
|
|
66
|
+
* Priority: bun (fastest) > npx tsx (universal Node fallback)
|
|
67
|
+
*/
|
|
68
|
+
export function detectTsRunner(): string {
|
|
69
|
+
try {
|
|
70
|
+
const { execSync } = require('child_process');
|
|
71
|
+
execSync('bun --version', { stdio: 'ignore' });
|
|
72
|
+
return 'bun';
|
|
73
|
+
} catch {
|
|
74
|
+
return 'npx tsx';
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function buildHooksFile(clientDir: string, specs: HookSpec[], tsRunner?: string): InstallHooksFile {
|
|
79
|
+
const runner = tsRunner || detectTsRunner();
|
|
80
|
+
const hooks: Record<string, InstallHookMatcherEntry[]> = {};
|
|
81
|
+
|
|
82
|
+
for (const spec of specs) {
|
|
83
|
+
const entry: InstallHookMatcherEntry = {
|
|
84
|
+
hooks: [
|
|
85
|
+
{
|
|
86
|
+
type: 'command',
|
|
87
|
+
command: `${runner} "${clientDir}/${spec.relativeCommand}"`,
|
|
88
|
+
statusMessage: spec.statusMessage,
|
|
89
|
+
timeout: spec.timeout,
|
|
90
|
+
},
|
|
91
|
+
],
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
if (spec.matcher) {
|
|
95
|
+
entry.matcher = spec.matcher;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
hooks[spec.event] ||= [];
|
|
99
|
+
hooks[spec.event].push(entry);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return { hooks };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function buildClaudeHooksFile(
|
|
106
|
+
clientDir: string,
|
|
107
|
+
options: ClaudeHookOptions = {},
|
|
108
|
+
): InstallHooksFile {
|
|
109
|
+
return buildHooksFile(clientDir, CLAUDE_HOOKS, options.tsRunner);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function buildCodexHooksFile(clientDir: string, tsRunner?: string): InstallHooksFile {
|
|
113
|
+
return buildHooksFile(clientDir, CODEX_HOOKS, tsRunner);
|
|
114
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import type { StaleArtifact } from './migration.ts';
|
|
2
|
+
import type { IntegrationTargetId } from './targets.ts';
|
|
3
|
+
|
|
4
|
+
export interface DetectionDisplayTarget {
|
|
5
|
+
id: IntegrationTargetId;
|
|
6
|
+
label: string;
|
|
7
|
+
kind: 'local' | 'remote';
|
|
8
|
+
description: string;
|
|
9
|
+
detection: {
|
|
10
|
+
detected: boolean;
|
|
11
|
+
details?: string;
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function formatDetectionLines(detections: DetectionDisplayTarget[]): string[] {
|
|
16
|
+
const locals = detections.filter((target) => target.kind === 'local');
|
|
17
|
+
const remotes = detections.filter((target) => target.kind === 'remote');
|
|
18
|
+
const lines: string[] = [];
|
|
19
|
+
|
|
20
|
+
lines.push('Detected local integration targets:');
|
|
21
|
+
for (const target of locals) {
|
|
22
|
+
const status = target.detection.detected ? 'detected' : 'not found';
|
|
23
|
+
const suffix = target.detection.details ? ` (${target.detection.details})` : '';
|
|
24
|
+
lines.push(`- ${target.id}: ${status}${suffix}`);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
lines.push('');
|
|
28
|
+
lines.push('Remote / hosted integration targets:');
|
|
29
|
+
for (const target of remotes) {
|
|
30
|
+
const suffix = target.detection.details ? ` (${target.detection.details})` : '';
|
|
31
|
+
lines.push(`- ${target.id}: ${target.description}${suffix}`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return lines;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function formatRemoteGuidanceLines(detections: DetectionDisplayTarget[]): string[] {
|
|
38
|
+
const remotes = detections.filter((target) => target.kind === 'remote');
|
|
39
|
+
const lines: string[] = [];
|
|
40
|
+
lines.push('');
|
|
41
|
+
lines.push('Hosted / remote targets are configured differently:');
|
|
42
|
+
for (const target of remotes) {
|
|
43
|
+
lines.push(`- ${target.id}: ${target.description}`);
|
|
44
|
+
}
|
|
45
|
+
lines.push("Use 'gramatr install remote-mcp' for remote setup guidance.");
|
|
46
|
+
return lines;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function formatInstallMenuLines(
|
|
50
|
+
detections: DetectionDisplayTarget[],
|
|
51
|
+
): { lines: string[]; targetIds: IntegrationTargetId[] } {
|
|
52
|
+
const targets = detections.filter((target) => target.kind === 'local');
|
|
53
|
+
const lines = ['Available local install targets:'];
|
|
54
|
+
for (const [index, target] of targets.entries()) {
|
|
55
|
+
const status = target.detection.detected ? 'detected' : 'not found';
|
|
56
|
+
lines.push(`${index + 1}. ${target.label} (${target.id}) - ${status}`);
|
|
57
|
+
}
|
|
58
|
+
lines.push(`${targets.length + 1}. all detected local targets`);
|
|
59
|
+
return { lines, targetIds: targets.map((target) => target.id) };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function resolveInteractiveSelection(
|
|
63
|
+
answer: string,
|
|
64
|
+
targetIds: IntegrationTargetId[],
|
|
65
|
+
detectedLocalTargets: IntegrationTargetId[],
|
|
66
|
+
resolveTarget: (id: string) => { id: IntegrationTargetId; kind: 'local' | 'remote' } | undefined,
|
|
67
|
+
): IntegrationTargetId[] {
|
|
68
|
+
if (!answer) return [];
|
|
69
|
+
if (answer === 'all') return detectedLocalTargets;
|
|
70
|
+
|
|
71
|
+
const parts = answer.split(',').map((part) => part.trim()).filter(Boolean);
|
|
72
|
+
const selected = new Set<IntegrationTargetId>();
|
|
73
|
+
|
|
74
|
+
for (const part of parts) {
|
|
75
|
+
const numeric = Number(part);
|
|
76
|
+
if (!Number.isNaN(numeric) && Number.isInteger(numeric)) {
|
|
77
|
+
if (numeric === targetIds.length + 1) {
|
|
78
|
+
for (const target of detectedLocalTargets) selected.add(target);
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
const indexed = targetIds[numeric - 1];
|
|
82
|
+
if (indexed) {
|
|
83
|
+
selected.add(indexed);
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const target = resolveTarget(part);
|
|
89
|
+
if (target?.kind === 'local') {
|
|
90
|
+
selected.add(target.id);
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
throw new Error(`Unknown install target selection: ${part}`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return Array.from(selected);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function formatDoctorLines(
|
|
101
|
+
detections: DetectionDisplayTarget[],
|
|
102
|
+
payloadDir: string,
|
|
103
|
+
payloadPresent: boolean,
|
|
104
|
+
staleArtifacts: StaleArtifact[],
|
|
105
|
+
): string[] {
|
|
106
|
+
const lines = formatDetectionLines(detections);
|
|
107
|
+
lines.push('');
|
|
108
|
+
lines.push(`Payload dir: ${payloadDir} ${payloadPresent ? '(present)' : '(missing)'}`);
|
|
109
|
+
|
|
110
|
+
if (staleArtifacts.length === 0) {
|
|
111
|
+
lines.push('Stale legacy artifacts: none detected');
|
|
112
|
+
return lines;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
lines.push('Stale legacy artifacts:');
|
|
116
|
+
for (const artifact of staleArtifacts) {
|
|
117
|
+
lines.push(`- ${artifact.path} (${artifact.reason})`);
|
|
118
|
+
}
|
|
119
|
+
lines.push('Run `gramatr upgrade` after cleanup to re-sync installed targets.');
|
|
120
|
+
return lines;
|
|
121
|
+
}
|
|
122
|
+
|