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.
- package/LICENSE +21 -0
- package/README.md +456 -0
- package/bin/cli.js +1941 -0
- package/package.json +34 -0
- package/templates/CLAUDE.md +208 -0
- package/templates/copilot-instructions.md +185 -0
- package/templates/gitignore-additions.txt +18 -0
- package/templates/hooks/inject-context.js +525 -0
- package/templates/hooks/on-error.js +135 -0
- package/templates/hooks/on-stop.js +150 -0
- package/templates/hooks/parse-transcript.js +67 -0
- package/templates/hooks/post-rewrite.js +63 -0
- package/templates/hooks/pre-tool-use.js +219 -0
- package/templates/hooks/session-end.js +953 -0
- package/templates/hooks/session-start.js +394 -0
- package/templates/hooks/sticky-attribution.js +534 -0
- package/templates/hooks/sticky-codex.ps1 +98 -0
- package/templates/hooks/sticky-codex.sh +71 -0
- package/templates/hooks/sticky-git-notes.js +337 -0
- package/templates/hooks/sticky-utils.js +647 -0
- package/templates/hooks/track-work.js +321 -0
- package/templates/hooks.json +47 -0
- package/templates/settings.json +81 -0
- package/templates/sticky-note-config.json +8 -0
- package/templates/sticky-note.json +5 -0
|
@@ -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
|