-
Notifications
You must be signed in to change notification settings - Fork 2
Expand file tree
/
Copy pathcmd_hook.go
More file actions
402 lines (350 loc) · 10.2 KB
/
cmd_hook.go
File metadata and controls
402 lines (350 loc) · 10.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
package main
import (
"bytes"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"time"
)
func cmdHook(args []string) {
if len(args) == 0 {
fmt.Print(hookHelpText)
return
}
hookName := args[0]
if hookName == "--help" || hookName == "-h" {
fmt.Print(hookHelpText)
return
}
// Read stdin (hook input from platform) as raw bytes
var stdinData []byte
if stat, _ := os.Stdin.Stat(); (stat.Mode() & os.ModeCharDevice) == 0 {
data, err := io.ReadAll(os.Stdin)
if err == nil {
stdinData = data
}
}
// Session normalization: extract session info from payload, write state file,
// and augment payload with lore.session block.
stdinData = augmentWithSession(stdinData)
// Session freshness: regenerate projections if stale.
// Only on prompt-submit — pre/post-tool-use fire too frequently.
if hookName == "prompt-submit" {
ensureFreshProjection()
cleanStaleSessions()
}
// Look up all scripts for this event (accumulate from all layers)
hookScripts := readHookScripts()
scripts := hookScripts.ScriptsFor(hookName)
// No scripts configured for this event -> exit silently
if len(scripts) == 0 {
logHookEvent(hookName, "no-op", 0)
return
}
// Filter to scripts that exist on disk
var valid []string
for _, sp := range scripts {
sp = expandHome(sp)
if _, err := os.Stat(sp); err == nil {
valid = append(valid, sp)
} else {
fmt.Fprintf(os.Stderr, "lore hook: script not found: %s\n", sp)
}
}
if len(valid) == 0 {
logHookEvent(hookName, "no-op", 0)
return
}
// Run all scripts in parallel
type scriptResult struct {
path string
stdout []byte
err error
}
results := make([]scriptResult, len(valid))
var wg sync.WaitGroup
cwd, _ := os.Getwd()
for i, sp := range valid {
wg.Add(1)
go func(idx int, scriptPath string) {
defer wg.Done()
cmd := exec.Command("node", scriptPath)
cmd.Dir = cwd
cmd.Stdin = readerFromBytes(stdinData)
cmd.Stderr = os.Stderr
out, err := cmd.Output()
results[idx] = scriptResult{path: scriptPath, stdout: out, err: err}
}(i, sp)
}
wg.Wait()
// Aggregate results
blocking := blockingEvents[hookName]
var blockReasons []string
var combinedOut []byte
for _, r := range results {
if r.err != nil {
logHookEvent(hookName, "error", 0)
if blocking {
// For blocking events, collect the reason from stdout (script's block message)
reason := strings.TrimSpace(string(r.stdout))
if reason == "" {
reason = fmt.Sprintf("%s failed: %v", filepath.Base(r.path), r.err)
}
blockReasons = append(blockReasons, reason)
} else {
fmt.Fprintf(os.Stderr, "lore hook: script error (%s): %v\n", filepath.Base(r.path), r.err)
}
continue
}
logHookEvent(hookName, "dispatched", len(r.stdout))
if len(r.stdout) > 0 {
combinedOut = append(combinedOut, r.stdout...)
}
}
// For blocking events with failures: merge all block reasons into a single response
if blocking && len(blockReasons) > 0 {
// Combine block messages from all failing scripts
merged := strings.Join(blockReasons, "\n")
os.Stdout.WriteString(merged)
os.Exit(1)
}
// Forward combined stdout from all successful scripts
if len(combinedOut) > 0 {
os.Stdout.Write(combinedOut)
}
}
// --- Session normalization ---
// augmentWithSession parses the stdin payload, extracts session info,
// writes a session state file, and flat-merges a lore.session block
// into the payload. Returns the augmented payload bytes.
func augmentWithSession(stdinData []byte) []byte {
if len(bytes.TrimSpace(stdinData)) == 0 {
// No payload — still write a session state file with generated ID
sess := sessionInfo{
ID: generateSessionID(),
Platform: "unknown",
Project: projectSlug(),
}
writeSessionState(sess)
return stdinData
}
var payload map[string]interface{}
if err := json.Unmarshal(stdinData, &payload); err != nil {
// Not valid JSON — write state with generated ID, don't modify payload
sess := sessionInfo{
ID: generateSessionID(),
Platform: "unknown",
Project: projectSlug(),
}
writeSessionState(sess)
return stdinData
}
sess := extractSession(payload)
writeSessionState(sess)
// Flat-merge lore.session block into payload
payload["lore"] = map[string]interface{}{
"session": map[string]interface{}{
"id": sess.ID,
"platform": sess.Platform,
"project": sess.Project,
},
}
augmented, err := json.Marshal(payload)
if err != nil {
return stdinData
}
return augmented
}
type sessionInfo struct {
ID string `json:"id"`
Platform string `json:"platform"`
Project string `json:"project"`
}
// extractSession pulls session ID and platform from a hook payload.
func extractSession(payload map[string]interface{}) sessionInfo {
sess := sessionInfo{
Project: projectSlug(),
}
// Extract session ID: session_id → conversation_id → trajectory_id → generate
if v, ok := payload["session_id"].(string); ok && v != "" {
sess.ID = v
} else if v, ok := payload["conversation_id"].(string); ok && v != "" {
sess.ID = v
} else if v, ok := payload["trajectory_id"].(string); ok && v != "" {
sess.ID = v
} else {
sess.ID = generateSessionID()
}
// Detect platform from payload shape + env vars
sess.Platform = detectPlatform(payload)
return sess
}
// detectPlatform identifies which AI coding platform dispatched the hook.
func detectPlatform(payload map[string]interface{}) string {
_, hasSessionID := payload["session_id"].(string)
_, hasConversationID := payload["conversation_id"].(string)
_, hasTrajectoryID := payload["trajectory_id"].(string)
if hasSessionID && os.Getenv("CLAUDECODE") == "1" {
return "claude"
}
if hasConversationID {
return "cursor"
}
if hasSessionID && os.Getenv("GEMINI_CLI") == "1" {
return "gemini"
}
if hasTrajectoryID {
return "windsurf"
}
return "unknown"
}
// projectSlug returns a dashed path slug for the current working directory.
// e.g. /home/andrew/Github/lore → "home-andrew-Github-lore"
func projectSlug() string {
cwd, err := os.Getwd()
if err != nil {
return "unknown"
}
// Strip leading slash and replace separators with dashes
slug := strings.TrimPrefix(cwd, "/")
return strings.ReplaceAll(slug, string(filepath.Separator), "-")
}
// generateSessionID returns an 8-char hex string for platforms that don't provide one.
func generateSessionID() string {
b := make([]byte, 4)
rand.Read(b)
return hex.EncodeToString(b)
}
// writeSessionState creates or updates .lore/.sessions/{id}.json.
func writeSessionState(sess sessionInfo) {
sessDir := filepath.Join(".lore", ".sessions")
if err := os.MkdirAll(sessDir, 0755); err != nil {
return
}
filePath := filepath.Join(sessDir, sess.ID+".json")
// If file exists, just touch mod time (started_at stays)
if _, err := os.Stat(filePath); err == nil {
now := time.Now()
os.Chtimes(filePath, now, now)
return
}
// New session — write with started_at
state := map[string]string{
"id": sess.ID,
"platform": sess.Platform,
"project": sess.Project,
"started_at": time.Now().UTC().Format(time.RFC3339),
}
data, err := json.MarshalIndent(state, "", " ")
if err != nil {
return
}
os.WriteFile(filePath, append(data, '\n'), 0644)
}
// cleanStaleSessions removes session files older than 24 hours.
func cleanStaleSessions() {
sessDir := filepath.Join(".lore", ".sessions")
entries, err := os.ReadDir(sessDir)
if err != nil {
return
}
cutoff := time.Now().Add(-24 * time.Hour)
for _, e := range entries {
if e.IsDir() {
continue
}
info, err := e.Info()
if err != nil {
continue
}
if info.ModTime().Before(cutoff) {
os.Remove(filepath.Join(sessDir, e.Name()))
}
}
}
// readerFromBytes returns an io.Reader for a byte slice (nil-safe).
func readerFromBytes(data []byte) io.Reader {
if len(data) == 0 {
return nil
}
return bytes.NewReader(data)
}
// --- Hook logging ---
func logHookEvent(hook, event string, outputSize int) {
if os.Getenv("LORE_HOOK_LOG") != "1" {
return
}
cwd, _ := os.Getwd()
logPath := filepath.Join(cwd, ".git", "lore-hook-events.jsonl")
if _, err := os.Stat(filepath.Dir(logPath)); err != nil {
logPath = filepath.Join(os.TempDir(), "lore-hook-events.jsonl")
}
entry := map[string]interface{}{
"ts": time.Now().Format(time.RFC3339),
"hook": hook,
"event": event,
"output_size": outputSize,
}
data, _ := json.Marshal(entry)
f, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
return
}
defer f.Close()
f.WriteString(string(data) + "\n")
}
// ensureFreshProjection checks if projections are stale and regenerates if needed.
// Fails gracefully — errors become stderr warnings, never block the session.
func ensureFreshProjection() {
// Only in Lore projects
if _, err := os.Stat(".lore/config.json"); err != nil {
return
}
root, err := os.Getwd()
if err != nil {
return
}
if !projectionStale(root) {
return
}
platforms, err := readEnabledPlatforms()
if err != nil {
fmt.Fprintf(os.Stderr, "lore: cannot read platforms: %v\n", err)
return
}
if len(platforms) == 0 {
return
}
warnings, err := doProjection(root, platforms)
for _, w := range warnings {
fmt.Fprintf(os.Stderr, "lore: %s\n", w)
}
if err != nil {
fmt.Fprintf(os.Stderr, "lore: projection refresh failed: %v\n", err)
}
}
const hookHelpText = `Handle platform hook callbacks.
Usage: lore hook <name>
This command is called by platform hooks (e.g., Claude Code settings.json),
not directly by users. The binary dispatches to scripts configured in
.lore/config.json or ~/.config/lore/config.json.
Hooks:
pre-tool-use Invoked before a tool executes
post-tool-use Invoked after a tool executes
prompt-submit Invoked before a user message is processed
session-start Invoked when a session begins or resumes
stop Invoked when the agent finishes responding
pre-compact Invoked before context window compression
session-end Invoked when a session terminates
subagent-start Invoked before a subagent spawns (3/7 platforms)
subagent-stop Invoked after a subagent completes (4/7 platforms)
Hook input is read from stdin as JSON (provided by the platform).
Script stdout is forwarded back to the platform.
`