sticky-note-cli 2.5.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.
@@ -0,0 +1,534 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ /**
4
+ * sticky-attribution.js — Sticky Note Attribution Engine
5
+ *
6
+ * Copyright 2026 Dheeraj Bandaru
7
+ * Licensed under the Apache License, Version 2.0 (the "License");
8
+ * you may not use this file except in compliance with the License.
9
+ * You may obtain a copy of the License at
10
+ *
11
+ * http://www.apache.org/licenses/LICENSE-2.0
12
+ *
13
+ * Unless required by applicable law or agreed to in writing, software
14
+ * distributed under the License is distributed on an "AS IS" BASIS,
15
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16
+ * See the License for the specific language governing permissions and
17
+ * limitations under the License.
18
+ *
19
+ * ---
20
+ * Built-in attribution engine using standard git blame + Git Notes +
21
+ * audit JSONL. No external dependencies — works out of the box for
22
+ * any git-native team.
23
+ *
24
+ * Three-tier SHA → thread resolution:
25
+ * 1. Git Notes (refs/notes/sticky-note) — survives rebase/amend
26
+ * 2. Audit JSONL — commit_sha / commit_shas fields
27
+ * 3. File + date heuristic — fallback for squash merges
28
+ *
29
+ * Call sites:
30
+ * A. PreToolUse hook → lazy injection (file → threads with line ranges)
31
+ * B. resume_thread() → thread discovery + ranking
32
+ * C. get_line_attribution() → enriched audit with line-level detail
33
+ */
34
+
35
+ const { execFileSync } = require("child_process");
36
+ const fs = require("fs");
37
+ const path = require("path");
38
+
39
+ let utils;
40
+ try {
41
+ utils = require("./sticky-utils.js");
42
+ } catch (_) {
43
+ utils = null;
44
+ }
45
+
46
+ let gitNotes;
47
+ try {
48
+ gitNotes = require("./sticky-git-notes.js");
49
+ } catch (_) {
50
+ gitNotes = null;
51
+ }
52
+
53
+ const ATTRIBUTION_TIMEOUT_MS = 500;
54
+ const normalizeSep = utils ? utils.normalizeSep : (p) => typeof p === "string" ? p.replace(/\\/g, "/") : p;
55
+
56
+ // ── Three-tier SHA → session resolution ───────────────────
57
+
58
+ /**
59
+ * Tier 1: Check Git Notes on a commit for session attribution.
60
+ * Returns array of { session_id, user, type, ts } objects.
61
+ */
62
+ function resolveViaNotes(sha) {
63
+ if (!gitNotes) return [];
64
+ const note = gitNotes.readNote(sha);
65
+ if (!note) return [];
66
+
67
+ const entries = Array.isArray(note) ? note : [note];
68
+ return entries
69
+ .filter((e) => e && e.session_id)
70
+ .map((e) => ({
71
+ session_id: e.session_id,
72
+ user: e.user || "",
73
+ type: e.type || "note",
74
+ ts: e.ts || "",
75
+ tier: "git-notes",
76
+ }));
77
+ }
78
+
79
+ /**
80
+ * Tier 2: Search audit JSONL files for entries matching a commit SHA.
81
+ */
82
+ function resolveViaAudit(sha) {
83
+ if (!utils) return [];
84
+ const auditPaths = utils.getAllAuditPaths();
85
+ const results = [];
86
+
87
+ for (const auditPath of auditPaths) {
88
+ try {
89
+ const raw = fs.readFileSync(auditPath, "utf-8");
90
+ for (const line of raw.split(/\r?\n/)) {
91
+ const trimmed = line.trim();
92
+ if (!trimmed) continue;
93
+ if (!trimmed.includes(sha)) continue;
94
+ try {
95
+ const entry = JSON.parse(trimmed);
96
+ if (
97
+ entry.commit_sha === sha ||
98
+ entry.sha === sha ||
99
+ (Array.isArray(entry.commit_shas) && entry.commit_shas.includes(sha))
100
+ ) {
101
+ results.push({
102
+ session_id: entry.session_id || "",
103
+ user: entry.user || "",
104
+ type: entry.type || "",
105
+ ts: entry.ts || "",
106
+ tier: "audit-jsonl",
107
+ });
108
+ }
109
+ } catch (_) {
110
+ continue;
111
+ }
112
+ }
113
+ } catch (_) {
114
+ continue;
115
+ }
116
+ }
117
+
118
+ return results;
119
+ }
120
+
121
+ /**
122
+ * Tier 3: File + date heuristic fallback.
123
+ * When a SHA has no notes and no audit match (e.g. squash merge),
124
+ * check if the file is in any thread's files_touched[] and the
125
+ * commit date falls within a reasonable session window.
126
+ */
127
+ function resolveViaHeuristic(sha, file) {
128
+ if (!utils) return [];
129
+
130
+ // Get commit date for the SHA
131
+ let commitDate;
132
+ try {
133
+ const dateStr = execFileSync("git", ["log", "-1", "--format=%aI", sha], {
134
+ encoding: "utf-8",
135
+ timeout: 3000,
136
+ stdio: ["pipe", "pipe", "pipe"],
137
+ }).trim();
138
+ commitDate = new Date(dateStr);
139
+ if (isNaN(commitDate.getTime())) return [];
140
+ } catch (_) {
141
+ return [];
142
+ }
143
+
144
+ // Also check if the squash commit message references original SHAs
145
+ let commitMsg = "";
146
+ try {
147
+ commitMsg = execFileSync("git", ["log", "-1", "--format=%B", sha], {
148
+ encoding: "utf-8",
149
+ timeout: 3000,
150
+ stdio: ["pipe", "pipe", "pipe"],
151
+ }).trim();
152
+ } catch (_) { /* ignore */ }
153
+
154
+ // Check referenced SHAs in commit message (squash merges often include them)
155
+ const referencedShas = commitMsg.match(/\b[0-9a-f]{7,40}\b/g) || [];
156
+ for (const refSha of referencedShas) {
157
+ const noteResults = resolveViaNotes(refSha);
158
+ if (noteResults.length > 0) return noteResults;
159
+ const auditResults = resolveViaAudit(refSha);
160
+ if (auditResults.length > 0) return auditResults;
161
+ }
162
+
163
+ // Last resort: match by file + date window
164
+ const memory = utils.loadJson(utils.getMemoryPath(), { version: "2", threads: [] });
165
+ const results = [];
166
+ const normalizedFile = file ? normalizeSep(file) : null;
167
+
168
+ for (const thread of memory.threads || []) {
169
+ if (thread.status === "expired") continue;
170
+ if (!normalizedFile) continue;
171
+
172
+ const touchedFiles = (thread.files_touched || []).map((f) => normalizeSep(f));
173
+ const fileMatch = touchedFiles.some(
174
+ (f) => f === normalizedFile || normalizedFile.endsWith(f) || f.endsWith(normalizedFile)
175
+ );
176
+ if (!fileMatch) continue;
177
+
178
+ // Check if commit date is near thread activity
179
+ const threadStart = thread.created_at ? new Date(thread.created_at) : null;
180
+ const threadEnd = thread.last_activity_at ? new Date(thread.last_activity_at) : threadStart;
181
+ if (!threadStart) continue;
182
+
183
+ // Allow 24h window beyond thread activity
184
+ const windowStart = new Date(threadStart.getTime() - 3600000);
185
+ const windowEnd = new Date((threadEnd || threadStart).getTime() + 86400000);
186
+
187
+ if (commitDate >= windowStart && commitDate <= windowEnd) {
188
+ results.push({
189
+ session_id: thread.session_id || thread.id,
190
+ user: thread.user || "",
191
+ type: "heuristic",
192
+ ts: commitDate.toISOString(),
193
+ tier: "heuristic",
194
+ });
195
+ }
196
+ }
197
+
198
+ return results;
199
+ }
200
+
201
+ /**
202
+ * Resolve a commit SHA to session attribution using all three tiers.
203
+ * Returns first non-empty result.
204
+ */
205
+ function resolveSessionForSha(sha, file) {
206
+ // Tier 1: Git Notes
207
+ const noteResults = resolveViaNotes(sha);
208
+ if (noteResults.length > 0) return noteResults;
209
+
210
+ // Tier 2: Audit JSONL
211
+ const auditResults = resolveViaAudit(sha);
212
+ if (auditResults.length > 0) return auditResults;
213
+
214
+ // Tier 3: Heuristic
215
+ return resolveViaHeuristic(sha, file);
216
+ }
217
+
218
+ // ── SHA → Thread resolution ───────────────────────────────
219
+
220
+ /**
221
+ * Given session attribution entries, load corresponding threads.
222
+ */
223
+ function resolveThreads(attributionEntries) {
224
+ if (!utils) return [];
225
+ const sessionIds = new Set(
226
+ attributionEntries.map((e) => e.session_id).filter(Boolean)
227
+ );
228
+ if (sessionIds.size === 0) return [];
229
+
230
+ const memory = utils.loadJson(utils.getMemoryPath(), { version: "2", threads: [] });
231
+ const threads = [];
232
+
233
+ for (const thread of memory.threads || []) {
234
+ if (thread.status === "expired") continue;
235
+ if (sessionIds.has(thread.session_id) || sessionIds.has(thread.id)) {
236
+ threads.push(thread);
237
+ continue;
238
+ }
239
+ for (const sid of thread.related_session_ids || []) {
240
+ if (sessionIds.has(sid)) {
241
+ threads.push(thread);
242
+ break;
243
+ }
244
+ }
245
+ }
246
+
247
+ return threads;
248
+ }
249
+
250
+ /**
251
+ * Given a commit SHA, resolve to threads via three-tier lookup.
252
+ */
253
+ function resolveThreadsBySha(sha, file) {
254
+ const entries = resolveSessionForSha(sha, file);
255
+ return resolveThreads(entries);
256
+ }
257
+
258
+ // ── Core: file → attributed threads ───────────────────────
259
+
260
+ /**
261
+ * Get threads that have authored lines in a file, with line-range detail.
262
+ *
263
+ * Returns {
264
+ * threads: [{ thread, lines: [lineNums], tier: "git-notes"|"audit-jsonl"|"heuristic" }],
265
+ * blame_data: { lines: {lineNum: sha}, shas: string[] },
266
+ * line_map: { lineNum: { sha, session_id, thread_id } }
267
+ * }
268
+ */
269
+ function getFileAttribution(file, options) {
270
+ options = options || {};
271
+ const result = {
272
+ threads: [],
273
+ blame_data: null,
274
+ line_map: {},
275
+ };
276
+
277
+ if (!gitNotes) return result;
278
+
279
+ const blameData = gitNotes.gitBlame(file);
280
+ if (!blameData) return result;
281
+ result.blame_data = blameData;
282
+
283
+ // Filter SHAs by line range if specified
284
+ let shasToCheck;
285
+ if (options.lineRange && options.lineRange.length === 2) {
286
+ shasToCheck = gitNotes.getShasForLineRange(blameData, options.lineRange[0], options.lineRange[1]);
287
+ } else {
288
+ shasToCheck = blameData.shas;
289
+ }
290
+
291
+ if (!shasToCheck || shasToCheck.length === 0) return result;
292
+
293
+ // Resolve each SHA → sessions → threads
294
+ const threadMap = new Map(); // thread.id → { thread, lines: Set, tier }
295
+
296
+ for (const sha of shasToCheck) {
297
+ const entries = resolveSessionForSha(sha, file);
298
+ if (entries.length === 0) continue;
299
+
300
+ const tier = entries[0].tier;
301
+ const threads = resolveThreads(entries);
302
+
303
+ // Find which lines this SHA covers
304
+ const linesForSha = [];
305
+ for (const [lineStr, lineSha] of Object.entries(blameData.lines)) {
306
+ if (lineSha === sha) {
307
+ const lineNum = parseInt(lineStr, 10);
308
+ if (!isNaN(lineNum)) {
309
+ // Apply line range filter
310
+ if (options.lineRange && options.lineRange.length === 2) {
311
+ if (lineNum < options.lineRange[0] || lineNum > options.lineRange[1]) continue;
312
+ }
313
+ linesForSha.push(lineNum);
314
+ }
315
+ }
316
+ }
317
+
318
+ for (const thread of threads) {
319
+ if (!threadMap.has(thread.id)) {
320
+ threadMap.set(thread.id, { thread, lines: new Set(), tier });
321
+ }
322
+ const entry = threadMap.get(thread.id);
323
+ for (const line of linesForSha) {
324
+ entry.lines.add(line);
325
+ result.line_map[line] = {
326
+ sha,
327
+ session_id: entries[0].session_id,
328
+ thread_id: thread.id,
329
+ };
330
+ }
331
+ }
332
+ }
333
+
334
+ // Apply since filter and build result
335
+ for (const [, value] of threadMap) {
336
+ if (options.since) {
337
+ const threadDate = value.thread.last_activity_at || value.thread.created_at || "";
338
+ if (threadDate && threadDate < options.since) continue;
339
+ }
340
+
341
+ const sortedLines = Array.from(value.lines).sort((a, b) => a - b);
342
+ result.threads.push({
343
+ thread: value.thread,
344
+ lines: sortedLines,
345
+ line_ranges: compactLineRanges(sortedLines),
346
+ tier: value.tier,
347
+ });
348
+ }
349
+
350
+ return result;
351
+ }
352
+
353
+ /**
354
+ * Lightweight: get threads for a file (no line-level detail).
355
+ */
356
+ function getThreadsForFile(file) {
357
+ const attr = getFileAttribution(file);
358
+ return attr.threads.map((t) => ({
359
+ ...t.thread,
360
+ _attributed_lines: t.lines,
361
+ _line_ranges: t.line_ranges,
362
+ _tier: t.tier,
363
+ }));
364
+ }
365
+
366
+ // ── Line range compaction ─────────────────────────────────
367
+
368
+ /**
369
+ * Compact sorted line numbers into ranges.
370
+ * [1,2,3,5,6,10] → ["1-3", "5-6", "10"]
371
+ */
372
+ function compactLineRanges(sortedLines) {
373
+ if (sortedLines.length === 0) return [];
374
+ const ranges = [];
375
+ let start = sortedLines[0];
376
+ let end = start;
377
+
378
+ for (let i = 1; i < sortedLines.length; i++) {
379
+ if (sortedLines[i] === end + 1) {
380
+ end = sortedLines[i];
381
+ } else {
382
+ ranges.push(start === end ? `${start}` : `${start}-${end}`);
383
+ start = sortedLines[i];
384
+ end = start;
385
+ }
386
+ }
387
+ ranges.push(start === end ? `${start}` : `${start}-${end}`);
388
+ return ranges;
389
+ }
390
+
391
+ // ── Text similarity for resume_thread ─────────────────────
392
+
393
+ const STOP_WORDS = new Set([
394
+ "the", "a", "an", "is", "was", "are", "were", "be", "been",
395
+ "being", "have", "has", "had", "do", "does", "did", "will",
396
+ "would", "could", "should", "may", "might", "can", "shall",
397
+ "to", "of", "in", "for", "on", "with", "at", "by", "from",
398
+ "as", "into", "through", "during", "before", "after", "above",
399
+ "below", "between", "and", "but", "or", "not", "no", "so",
400
+ "if", "then", "than", "too", "very", "just", "about", "up",
401
+ "out", "off", "over", "under", "again", "further", "once",
402
+ "it", "its", "this", "that", "these", "those", "i", "me",
403
+ "my", "we", "our", "you", "your", "he", "she", "they", "them",
404
+ ]);
405
+
406
+ function tokenize(str) {
407
+ const words = new Set();
408
+ for (const w of str.toLowerCase().replace(/[^a-z0-9\s]/g, " ").split(/\s+/)) {
409
+ if (w.length >= 2 && !STOP_WORDS.has(w)) words.add(w);
410
+ }
411
+ return words;
412
+ }
413
+
414
+ function textSimilarity(query, text) {
415
+ if (!query || !text) return 0;
416
+ const queryWords = tokenize(query);
417
+ const textWords = tokenize(text);
418
+ let matches = 0;
419
+ for (const w of queryWords) {
420
+ if (textWords.has(w)) matches++;
421
+ }
422
+ return queryWords.size > 0 ? matches / queryWords.size : 0;
423
+ }
424
+
425
+ /**
426
+ * Search threads by text similarity to a query.
427
+ */
428
+ function searchThreadsByText(threads, query) {
429
+ const scored = [];
430
+
431
+ for (const thread of threads) {
432
+ if (thread.status === "expired") continue;
433
+
434
+ let score = 0;
435
+ score += textSimilarity(query, thread.narrative || "") * 3;
436
+ score += textSimilarity(query, thread.handoff_summary || "") * 2;
437
+ score += textSimilarity(query, thread.last_note || "") * 1.5;
438
+
439
+ const failedText = (thread.failed_approaches || [])
440
+ .map((fa) => (fa.description || "") + " " + (fa.error || ""))
441
+ .join(" ");
442
+ score += textSimilarity(query, failedText) * 1;
443
+
444
+ const filesText = (thread.files_touched || []).join(" ");
445
+ score += textSimilarity(query, filesText) * 2;
446
+
447
+ if (query.toLowerCase().includes(thread.work_type || "___")) {
448
+ score += 1;
449
+ }
450
+
451
+ if (score > 0) {
452
+ scored.push({ thread, score });
453
+ }
454
+ }
455
+
456
+ scored.sort((a, b) => b.score - a.score);
457
+ return scored;
458
+ }
459
+
460
+ /**
461
+ * Find the best thread to resume, combining text search with
462
+ * file attribution ranking.
463
+ *
464
+ * Options:
465
+ * query: search text
466
+ * user: filter by thread author
467
+ * file: boost threads whose attributed lines overlap with this file
468
+ */
469
+ function findThreadToResume(threads, options) {
470
+ options = options || {};
471
+ const { query, user, file } = options;
472
+
473
+ let candidates;
474
+ if (query) {
475
+ candidates = searchThreadsByText(threads, query);
476
+ } else {
477
+ candidates = threads
478
+ .filter((t) => t.status !== "expired")
479
+ .map((t) => ({ thread: t, score: 0 }));
480
+ }
481
+
482
+ if (user) {
483
+ candidates = candidates.filter(
484
+ (c) => (c.thread.user || "").toLowerCase() === user.toLowerCase()
485
+ );
486
+ }
487
+
488
+ // Boost by file attribution (uses git blame — always available)
489
+ if (file) {
490
+ const fileThreads = getThreadsForFile(file);
491
+ const fileThreadIds = new Set(fileThreads.map((t) => t.id));
492
+ for (const c of candidates) {
493
+ if (fileThreadIds.has(c.thread.id)) {
494
+ c.score += 3;
495
+ c.match_reasons = c.match_reasons || [];
496
+ c.match_reasons.push("file-attribution");
497
+ }
498
+ }
499
+ }
500
+
501
+ for (const c of candidates) {
502
+ c.match_reasons = c.match_reasons || [];
503
+ if (query && c.score > 0) c.match_reasons.push("text-match");
504
+ if (user) c.match_reasons.push("user-filter");
505
+ }
506
+
507
+ candidates.sort((a, b) => b.score - a.score);
508
+ return candidates;
509
+ }
510
+
511
+ // ── Exports ───────────────────────────────────────────────
512
+
513
+ module.exports = {
514
+ // Three-tier resolution
515
+ resolveViaNotes,
516
+ resolveViaAudit,
517
+ resolveViaHeuristic,
518
+ resolveSessionForSha,
519
+ resolveThreads,
520
+ resolveThreadsBySha,
521
+
522
+ // Core attribution
523
+ getFileAttribution,
524
+ getThreadsForFile,
525
+ compactLineRanges,
526
+
527
+ // Text search / resume
528
+ textSimilarity,
529
+ searchThreadsByText,
530
+ findThreadToResume,
531
+
532
+ // Constants
533
+ ATTRIBUTION_TIMEOUT_MS,
534
+ };
@@ -0,0 +1,98 @@
1
+ # sticky-codex.ps1 - Codex wrapper for Sticky Note V2 (Windows)
2
+ #
3
+ # Captures Codex stdout/stderr to a temp session log.
4
+ # On exit: calls session-end.js with the transcript for
5
+ # narrative + failed_approaches extraction.
6
+ #
7
+ # Usage:
8
+ # .\sticky-codex.ps1 [codex args...]
9
+ #
10
+ # Setup:
11
+ # npx sticky-note init --codex
12
+ #
13
+ # Limitation: Context is printed to terminal after Codex exits,
14
+ # not injected into the Codex window. Per-prompt surfacing not available.
15
+
16
+ param(
17
+ [Parameter(ValueFromRemainingArguments = $true)]
18
+ [string[]]$CodexArgs
19
+ )
20
+
21
+ $ErrorActionPreference = "Continue"
22
+
23
+ $ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
24
+ $StickyDir = Join-Path (Split-Path -Parent (Split-Path -Parent $ScriptDir)) ".sticky-note"
25
+ $SessionId = "codex-$([DateTimeOffset]::UtcNow.ToUnixTimeSeconds())-$PID"
26
+ $TranscriptFile = [System.IO.Path]::GetTempFileName()
27
+ $User = if ($env:USER) { $env:USER } elseif ($env:USERNAME) { $env:USERNAME } else { "unknown" }
28
+
29
+ if (-not (Test-Path $StickyDir)) {
30
+ New-Item -ItemType Directory -Path $StickyDir -Force | Out-Null
31
+ }
32
+
33
+ Write-Host ""
34
+ Write-Host "[STICKY-NOTE] Injecting context for Codex session $SessionId"
35
+ Write-Host ""
36
+
37
+ $MemoryFile = Join-Path $StickyDir "sticky-note.json"
38
+ if (Test-Path $MemoryFile) {
39
+ try {
40
+ $inputJson = @{ session_id = $SessionId } | ConvertTo-Json -Compress
41
+ $inputJson | & node "$ScriptDir\session-start.js" 2>$null
42
+ } catch {
43
+ # ignore
44
+ }
45
+ }
46
+
47
+ Write-Host "---------------------------------------------"
48
+ Write-Host ""
49
+
50
+ # Run Codex, capturing output
51
+ $CodexExit = 0
52
+ try {
53
+ & codex @CodexArgs 2>&1 | Tee-Object -FilePath $TranscriptFile
54
+ $CodexExit = $LASTEXITCODE
55
+ } catch {
56
+ $CodexExit = 1
57
+ }
58
+
59
+ Write-Host ""
60
+ Write-Host "---------------------------------------------"
61
+ Write-Host "[STICKY-NOTE] Processing Codex session..."
62
+
63
+ # Parse transcript for narrative + failed_approaches
64
+ $Parsed = '{"narrative":"","failed_approaches":[]}'
65
+ try {
66
+ $Parsed = & node "$ScriptDir\parse-transcript.js" $TranscriptFile 2>$null
67
+ if (-not $Parsed) {
68
+ $Parsed = '{"narrative":"","failed_approaches":[]}'
69
+ }
70
+ } catch {
71
+ # ignore
72
+ }
73
+
74
+ # Call session-end.js with the transcript info + parsed data
75
+ try {
76
+ $endInput = @"
77
+ {
78
+ "session_id": "$SessionId",
79
+ "transcript_path": "$($TranscriptFile -replace '\\', '\\')",
80
+ "parsed_transcript": $Parsed,
81
+ "hook_event_name": "sessionEnd",
82
+ "reason": "codex_exit"
83
+ }
84
+ "@
85
+ $endInput | & node "$ScriptDir\session-end.js" 2>$null
86
+ } catch {
87
+ # ignore
88
+ }
89
+
90
+ # Cleanup transcript
91
+ if (Test-Path $TranscriptFile) {
92
+ Remove-Item -Path $TranscriptFile -Force -ErrorAction SilentlyContinue
93
+ }
94
+
95
+ Write-Host "[OK] Session $SessionId recorded."
96
+ Write-Host ""
97
+
98
+ exit $CodexExit
@@ -0,0 +1,71 @@
1
+ #!/usr/bin/env bash
2
+ # sticky-codex.sh — Codex wrapper for Sticky Note V2
3
+ #
4
+ # Captures Codex stdout/stderr to a temp session log.
5
+ # On exit: calls session-end.js with the transcript for
6
+ # narrative + failed_approaches extraction.
7
+ #
8
+ # Usage:
9
+ # sticky-codex [codex args...]
10
+ #
11
+ # Setup:
12
+ # npx sticky-note init --codex
13
+ # alias sticky-codex="/path/to/.claude/hooks/sticky-codex.sh"
14
+ #
15
+ # Limitation: Context is printed to terminal after Codex exits,
16
+ # not injected into the Codex window. Per-prompt surfacing not available.
17
+
18
+ set -euo pipefail
19
+
20
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
21
+ STICKY_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)/.sticky-note"
22
+ SESSION_ID="codex-$(date +%s)-$$"
23
+ TRANSCRIPT_FILE=$(mktemp "${TMPDIR:-/tmp}/sticky-codex-XXXXXX.log")
24
+ USER="${USER:-${USERNAME:-unknown}}"
25
+
26
+ # Ensure .sticky-note directory exists
27
+ mkdir -p "$STICKY_DIR"
28
+
29
+ # Print context from sticky-note before starting Codex
30
+ echo ""
31
+ echo "📌 Sticky Note — injecting context for Codex session $SESSION_ID"
32
+ echo ""
33
+
34
+ if [ -f "$STICKY_DIR/sticky-note.json" ]; then
35
+ node "$SCRIPT_DIR/session-start.js" <<EOF 2>/dev/null || true
36
+ {"session_id": "$SESSION_ID"}
37
+ EOF
38
+ fi
39
+
40
+ echo "─────────────────────────────────────────────"
41
+ echo ""
42
+
43
+ # Run Codex, capturing output
44
+ codex "$@" 2>&1 | tee "$TRANSCRIPT_FILE"
45
+ CODEX_EXIT=$?
46
+
47
+ echo ""
48
+ echo "─────────────────────────────────────────────"
49
+ echo "📌 Sticky Note — processing Codex session..."
50
+
51
+ # Parse transcript for narrative + failed_approaches
52
+ PARSED=$(node "$SCRIPT_DIR/parse-transcript.js" "$TRANSCRIPT_FILE" 2>/dev/null || echo '{"narrative":"","failed_approaches":[]}')
53
+
54
+ # Call session-end.js with the transcript info + parsed data
55
+ node "$SCRIPT_DIR/session-end.js" <<EOF 2>/dev/null || true
56
+ {
57
+ "session_id": "$SESSION_ID",
58
+ "transcript_path": "$TRANSCRIPT_FILE",
59
+ "parsed_transcript": ${PARSED},
60
+ "hook_event_name": "sessionEnd",
61
+ "reason": "codex_exit"
62
+ }
63
+ EOF
64
+
65
+ # Cleanup transcript
66
+ rm -f "$TRANSCRIPT_FILE"
67
+
68
+ echo "[OK] Session $SESSION_ID recorded."
69
+ echo ""
70
+
71
+ exit $CODEX_EXIT