sticky-note-cli 2.5.3 → 2.6.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/bin/cli.js +284 -0
- package/package.json +1 -1
- package/templates/hooks/on-stop.js +175 -13
- package/templates/hooks/session-start.js +195 -3
- package/templates/hooks/sticky-codex.sh +71 -71
- package/templates/sticky-note-config.json +1 -0
package/bin/cli.js
CHANGED
|
@@ -1372,6 +1372,282 @@ function _relativeTime(tsStr) {
|
|
|
1372
1372
|
}
|
|
1373
1373
|
}
|
|
1374
1374
|
|
|
1375
|
+
// ──────────────────────────────────────────────
|
|
1376
|
+
// OVERLAP command — detect file overlaps with open/stuck threads
|
|
1377
|
+
// ──────────────────────────────────────────────
|
|
1378
|
+
|
|
1379
|
+
function cmdOverlap() {
|
|
1380
|
+
const args = process.argv.slice(3);
|
|
1381
|
+
const stickyDir = path.join(process.cwd(), ".sticky-note");
|
|
1382
|
+
const memoryPath = path.join(stickyDir, "sticky-note.json");
|
|
1383
|
+
|
|
1384
|
+
if (!fs.existsSync(memoryPath)) {
|
|
1385
|
+
print(" [ERR] No sticky-note.json found. Run `npx sticky-note init` first.");
|
|
1386
|
+
process.exit(1);
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
printBanner();
|
|
1390
|
+
|
|
1391
|
+
// Get files to check: either --files flag or git diff
|
|
1392
|
+
let filesToCheck = new Set();
|
|
1393
|
+
const filesIdx = args.indexOf("--files");
|
|
1394
|
+
if (filesIdx !== -1 && args.length > filesIdx + 1) {
|
|
1395
|
+
for (const f of args.slice(filesIdx + 1)) {
|
|
1396
|
+
if (f.startsWith("--")) break;
|
|
1397
|
+
filesToCheck.add(_normSep(f));
|
|
1398
|
+
}
|
|
1399
|
+
} else {
|
|
1400
|
+
// Use git diff (uncommitted + recent commits)
|
|
1401
|
+
try {
|
|
1402
|
+
const uncommitted = execSync("git diff --name-only", {
|
|
1403
|
+
encoding: "utf-8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"],
|
|
1404
|
+
}).trim();
|
|
1405
|
+
for (const f of uncommitted.split(/\r?\n/)) {
|
|
1406
|
+
if (f.trim()) filesToCheck.add(_normSep(f.trim()));
|
|
1407
|
+
}
|
|
1408
|
+
} catch (_) {}
|
|
1409
|
+
try {
|
|
1410
|
+
const staged = execSync("git diff --name-only --cached", {
|
|
1411
|
+
encoding: "utf-8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"],
|
|
1412
|
+
}).trim();
|
|
1413
|
+
for (const f of staged.split(/\r?\n/)) {
|
|
1414
|
+
if (f.trim()) filesToCheck.add(_normSep(f.trim()));
|
|
1415
|
+
}
|
|
1416
|
+
} catch (_) {}
|
|
1417
|
+
const diffTargets = ["HEAD~5", "HEAD~1"];
|
|
1418
|
+
for (const target of diffTargets) {
|
|
1419
|
+
try {
|
|
1420
|
+
const result = execSync(`git diff --name-only ${target}`, {
|
|
1421
|
+
encoding: "utf-8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"],
|
|
1422
|
+
}).trim();
|
|
1423
|
+
for (const f of result.split(/\r?\n/)) {
|
|
1424
|
+
if (f.trim()) filesToCheck.add(_normSep(f.trim()));
|
|
1425
|
+
}
|
|
1426
|
+
break;
|
|
1427
|
+
} catch (_) {}
|
|
1428
|
+
}
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
if (filesToCheck.size === 0) {
|
|
1432
|
+
print(" No modified files detected. Use --files <file1> <file2> to check specific files.\n");
|
|
1433
|
+
return;
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
const currentUserNames = _getCurrentUserNames();
|
|
1437
|
+
const isMe = (user) => currentUserNames.has(user || "");
|
|
1438
|
+
const memory = readJsonSafe(memoryPath, { threads: [] });
|
|
1439
|
+
const threads = (memory.threads || []).filter(
|
|
1440
|
+
(t) => t.status === "open" || t.status === "stuck"
|
|
1441
|
+
);
|
|
1442
|
+
|
|
1443
|
+
// Find overlaps
|
|
1444
|
+
const overlaps = [];
|
|
1445
|
+
for (const thread of threads) {
|
|
1446
|
+
const threadFiles = (thread.files_touched || []).map(_normSep);
|
|
1447
|
+
const threadFileSet = new Set(threadFiles);
|
|
1448
|
+
const matching = [];
|
|
1449
|
+
for (const f of filesToCheck) {
|
|
1450
|
+
if (threadFileSet.has(f)) matching.push(f);
|
|
1451
|
+
}
|
|
1452
|
+
if (matching.length > 0) {
|
|
1453
|
+
overlaps.push({ thread, matching });
|
|
1454
|
+
}
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
print(` Checking ${filesToCheck.size} file(s) against ${threads.length} open/stuck thread(s):\n`);
|
|
1458
|
+
|
|
1459
|
+
if (overlaps.length === 0) {
|
|
1460
|
+
print(" ✅ No overlaps found. You're clear to work.\n");
|
|
1461
|
+
return;
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
// Sort: other users first, then stuck before open, then by overlap count
|
|
1465
|
+
overlaps.sort((a, b) => {
|
|
1466
|
+
const aOther = !isMe(a.thread.user) ? 1 : 0;
|
|
1467
|
+
const bOther = !isMe(b.thread.user) ? 1 : 0;
|
|
1468
|
+
if (aOther !== bOther) return bOther - aOther;
|
|
1469
|
+
if (a.thread.status !== b.thread.status) {
|
|
1470
|
+
return a.thread.status === "stuck" ? -1 : 1;
|
|
1471
|
+
}
|
|
1472
|
+
return b.matching.length - a.matching.length;
|
|
1473
|
+
});
|
|
1474
|
+
|
|
1475
|
+
for (const { thread, matching } of overlaps) {
|
|
1476
|
+
const user = thread.user || thread.author || "unknown";
|
|
1477
|
+
const icon = statusIcon(thread.status);
|
|
1478
|
+
const isSelf = isMe(user);
|
|
1479
|
+
const prefix = isSelf ? " " : " ⚠️ ";
|
|
1480
|
+
const suffix = isSelf ? " (you)" : "";
|
|
1481
|
+
const narrative = thread.narrative || thread.last_note || "";
|
|
1482
|
+
const narrativeSnip = narrative.length > 80
|
|
1483
|
+
? narrative.substring(0, 80) + "…"
|
|
1484
|
+
: narrative;
|
|
1485
|
+
const branch = thread.branch ? ` on ${thread.branch}` : "";
|
|
1486
|
+
const ago = thread.last_activity_at ? _relativeTime(thread.last_activity_at) : "";
|
|
1487
|
+
const agoLabel = ago ? ` (${ago})` : "";
|
|
1488
|
+
|
|
1489
|
+
print(`${prefix}${icon} ${user}${suffix}${branch}${agoLabel}`);
|
|
1490
|
+
if (narrativeSnip) {
|
|
1491
|
+
print(` "${narrativeSnip}"`);
|
|
1492
|
+
}
|
|
1493
|
+
print(` overlapping: ${matching.join(", ")}`);
|
|
1494
|
+
if (thread.failed_approaches && thread.failed_approaches.length > 0) {
|
|
1495
|
+
print(` ⚠️ ${thread.failed_approaches.length} failed approach(es)`);
|
|
1496
|
+
}
|
|
1497
|
+
const threadId = (thread.id || "").substring(0, 8);
|
|
1498
|
+
if (!isSelf && threadId) {
|
|
1499
|
+
print(` → npx sticky-note resume ${threadId}`);
|
|
1500
|
+
}
|
|
1501
|
+
print("");
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
const otherUserOverlaps = overlaps.filter(
|
|
1505
|
+
(o) => !isMe(o.thread.user)
|
|
1506
|
+
);
|
|
1507
|
+
if (otherUserOverlaps.length > 0) {
|
|
1508
|
+
print(` ⚠️ ${otherUserOverlaps.length} thread(s) from other users overlap with your files.`);
|
|
1509
|
+
print(" Consider coordinating before diving in.\n");
|
|
1510
|
+
}
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
function _normSep(p) {
|
|
1514
|
+
return typeof p === "string" ? p.replace(/\\/g, "/") : p;
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
function _getGitUserName() {
|
|
1518
|
+
try {
|
|
1519
|
+
return execSync("git config user.name", {
|
|
1520
|
+
encoding: "utf-8", timeout: 3000, stdio: ["pipe", "pipe", "pipe"],
|
|
1521
|
+
}).trim();
|
|
1522
|
+
} catch (_) {
|
|
1523
|
+
return process.env.USER || process.env.USERNAME || "unknown";
|
|
1524
|
+
}
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1527
|
+
function _getCurrentUserNames() {
|
|
1528
|
+
const names = new Set();
|
|
1529
|
+
const envUser = process.env.USER || process.env.USERNAME || "";
|
|
1530
|
+
if (envUser) names.add(envUser);
|
|
1531
|
+
try {
|
|
1532
|
+
const gitName = execSync("git config user.name", {
|
|
1533
|
+
encoding: "utf-8", timeout: 3000, stdio: ["pipe", "pipe", "pipe"],
|
|
1534
|
+
}).trim();
|
|
1535
|
+
if (gitName) names.add(gitName);
|
|
1536
|
+
} catch (_) {}
|
|
1537
|
+
try {
|
|
1538
|
+
const gitEmail = execSync("git config user.email", {
|
|
1539
|
+
encoding: "utf-8", timeout: 3000, stdio: ["pipe", "pipe", "pipe"],
|
|
1540
|
+
}).trim();
|
|
1541
|
+
if (gitEmail) names.add(gitEmail);
|
|
1542
|
+
} catch (_) {}
|
|
1543
|
+
return names;
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1546
|
+
// ──────────────────────────────────────────────
|
|
1547
|
+
// CLAIM command — declare intent to work on files
|
|
1548
|
+
// ──────────────────────────────────────────────
|
|
1549
|
+
|
|
1550
|
+
function cmdClaim() {
|
|
1551
|
+
const args = process.argv.slice(3);
|
|
1552
|
+
const stickyDir = path.join(process.cwd(), ".sticky-note");
|
|
1553
|
+
const memoryPath = path.join(stickyDir, "sticky-note.json");
|
|
1554
|
+
|
|
1555
|
+
if (!fs.existsSync(memoryPath)) {
|
|
1556
|
+
print(" [ERR] No sticky-note.json found. Run `npx sticky-note init` first.");
|
|
1557
|
+
process.exit(1);
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1560
|
+
// Parse: npx sticky-note claim <file1> [file2...] "description"
|
|
1561
|
+
// or: npx sticky-note claim --clear
|
|
1562
|
+
if (args.includes("--clear")) {
|
|
1563
|
+
const memory = readJsonSafe(memoryPath, { threads: [], claims: [] });
|
|
1564
|
+
const userNames = _getCurrentUserNames();
|
|
1565
|
+
const before = (memory.claims || []).length;
|
|
1566
|
+
memory.claims = (memory.claims || []).filter((c) => !userNames.has(c.user));
|
|
1567
|
+
const removed = before - memory.claims.length;
|
|
1568
|
+
fs.writeFileSync(memoryPath, JSON.stringify(memory, null, 2));
|
|
1569
|
+
print(` [OK] Cleared ${removed} claim(s).`);
|
|
1570
|
+
return;
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1573
|
+
if (args.includes("--list")) {
|
|
1574
|
+
const memory = readJsonSafe(memoryPath, { threads: [], claims: [] });
|
|
1575
|
+
const claims = memory.claims || [];
|
|
1576
|
+
if (claims.length === 0) {
|
|
1577
|
+
print(" No active claims.");
|
|
1578
|
+
return;
|
|
1579
|
+
}
|
|
1580
|
+
printBanner();
|
|
1581
|
+
print(" Active Claims:\n");
|
|
1582
|
+
for (const c of claims) {
|
|
1583
|
+
const ago = c.claimed_at ? _relativeTime(c.claimed_at) : "";
|
|
1584
|
+
const agoLabel = ago ? ` (${ago})` : "";
|
|
1585
|
+
print(` 📌 ${c.user}${agoLabel}: ${c.files.join(", ")}`);
|
|
1586
|
+
if (c.description) print(` "${c.description}"`);
|
|
1587
|
+
}
|
|
1588
|
+
print("");
|
|
1589
|
+
return;
|
|
1590
|
+
}
|
|
1591
|
+
|
|
1592
|
+
if (args.length === 0) {
|
|
1593
|
+
print(" Usage: npx sticky-note claim <file1> [file2...] \"description\"");
|
|
1594
|
+
print(" npx sticky-note claim --list");
|
|
1595
|
+
print(" npx sticky-note claim --clear");
|
|
1596
|
+
return;
|
|
1597
|
+
}
|
|
1598
|
+
|
|
1599
|
+
// Parse files and description (last quoted arg or last arg is description)
|
|
1600
|
+
const files = [];
|
|
1601
|
+
let description = "";
|
|
1602
|
+
for (const a of args) {
|
|
1603
|
+
if (a.startsWith("--")) continue;
|
|
1604
|
+
// If it looks like a file path (has a dot or slash), treat as file
|
|
1605
|
+
if (a.includes("/") || a.includes("\\") || a.includes(".")) {
|
|
1606
|
+
files.push(_normSep(a));
|
|
1607
|
+
} else {
|
|
1608
|
+
// Treat as part of description
|
|
1609
|
+
description += (description ? " " : "") + a;
|
|
1610
|
+
}
|
|
1611
|
+
}
|
|
1612
|
+
|
|
1613
|
+
if (files.length === 0) {
|
|
1614
|
+
print(" [ERR] No files specified. Usage: npx sticky-note claim <file> \"description\"");
|
|
1615
|
+
return;
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
const currentUser = process.env.USER || process.env.USERNAME || _getGitUserName();
|
|
1619
|
+
const userNames = _getCurrentUserNames();
|
|
1620
|
+
const memory = readJsonSafe(memoryPath, { threads: [], claims: [] });
|
|
1621
|
+
if (!memory.claims) memory.claims = [];
|
|
1622
|
+
|
|
1623
|
+
// Remove existing claims from this user for these files
|
|
1624
|
+
memory.claims = memory.claims.filter(
|
|
1625
|
+
(c) => !userNames.has(c.user) || !c.files.some((f) => files.includes(f))
|
|
1626
|
+
);
|
|
1627
|
+
|
|
1628
|
+
memory.claims.push({
|
|
1629
|
+
user: currentUser,
|
|
1630
|
+
files,
|
|
1631
|
+
description,
|
|
1632
|
+
claimed_at: new Date().toISOString(),
|
|
1633
|
+
branch: _getCurrentBranch(),
|
|
1634
|
+
});
|
|
1635
|
+
|
|
1636
|
+
fs.writeFileSync(memoryPath, JSON.stringify(memory, null, 2));
|
|
1637
|
+
print(` ✅ Claimed ${files.join(", ")}${description ? ` — "${description}"` : ""}`);
|
|
1638
|
+
print(` Others will see this at session start. Commit + push to share.`);
|
|
1639
|
+
}
|
|
1640
|
+
|
|
1641
|
+
function _getCurrentBranch() {
|
|
1642
|
+
try {
|
|
1643
|
+
return execSync("git rev-parse --abbrev-ref HEAD", {
|
|
1644
|
+
encoding: "utf-8", timeout: 3000, stdio: ["pipe", "pipe", "pipe"],
|
|
1645
|
+
}).trim();
|
|
1646
|
+
} catch (_) {
|
|
1647
|
+
return "";
|
|
1648
|
+
}
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1375
1651
|
// ──────────────────────────────────────────────
|
|
1376
1652
|
// SWITCH command — safe branch switching
|
|
1377
1653
|
// ──────────────────────────────────────────────
|
|
@@ -1954,6 +2230,12 @@ async function main() {
|
|
|
1954
2230
|
case "checkpoint":
|
|
1955
2231
|
cmdCheckpoint();
|
|
1956
2232
|
break;
|
|
2233
|
+
case "overlap":
|
|
2234
|
+
cmdOverlap();
|
|
2235
|
+
break;
|
|
2236
|
+
case "claim":
|
|
2237
|
+
cmdClaim();
|
|
2238
|
+
break;
|
|
1957
2239
|
case "--version":
|
|
1958
2240
|
case "-v":
|
|
1959
2241
|
print(`sticky-note v${VERSION}`);
|
|
@@ -1977,6 +2259,8 @@ async function main() {
|
|
|
1977
2259
|
print(" reset Wipe all threads and start fresh (--force, --keep-audit)");
|
|
1978
2260
|
print(" get-line-attribution File→thread attribution with line ranges (--file, --lines)");
|
|
1979
2261
|
print(" checkpoint Set work-topic checkpoint for attribution (--topic, --show, --clear)");
|
|
2262
|
+
print(" overlap Detect file overlaps with open/stuck threads");
|
|
2263
|
+
print(" claim Claim ownership of files you're working on (--list, --clear)");
|
|
1980
2264
|
print("");
|
|
1981
2265
|
print(" Options:");
|
|
1982
2266
|
print(" --version Show version");
|
package/package.json
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
const crypto = require("crypto");
|
|
11
|
+
const { execFileSync } = require("child_process");
|
|
11
12
|
|
|
12
13
|
function safeExit() {
|
|
13
14
|
try {
|
|
@@ -35,8 +36,117 @@ const {
|
|
|
35
36
|
getBranch,
|
|
36
37
|
getSessionId,
|
|
37
38
|
detectTool,
|
|
39
|
+
extractSessionFromAudit,
|
|
40
|
+
readHeadSha,
|
|
41
|
+
normalizeSep,
|
|
42
|
+
useCloud,
|
|
43
|
+
cloudReadThreads,
|
|
44
|
+
cloudWriteThread,
|
|
45
|
+
cloudAppendAudit,
|
|
38
46
|
} = utils;
|
|
39
47
|
|
|
48
|
+
// Files that belong to sticky-note internals — exclude from files_touched.
|
|
49
|
+
const STICKY_FILES_PREFIX = [
|
|
50
|
+
".sticky-note/sticky-note.json",
|
|
51
|
+
".sticky-note/sticky-note-audit.jsonl",
|
|
52
|
+
".sticky-note/audit/",
|
|
53
|
+
".sticky-note/presence/",
|
|
54
|
+
".sticky-note/.sticky-session",
|
|
55
|
+
".sticky-note/.sticky-head",
|
|
56
|
+
".sticky-note/.sticky-resume",
|
|
57
|
+
".sticky-note/.sticky-debug.jsonl",
|
|
58
|
+
".sticky-note/sticky-note-config.json",
|
|
59
|
+
".sticky-note/.sticky-presence.json",
|
|
60
|
+
".sticky-note/.sticky-injected",
|
|
61
|
+
".sticky-note/.sticky-active-resume",
|
|
62
|
+
];
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Collect files changed since session start using git diff.
|
|
66
|
+
* Mirrors getGitFilesTouched() from session-end.js.
|
|
67
|
+
*/
|
|
68
|
+
function getGitFilesTouched() {
|
|
69
|
+
const files = new Set();
|
|
70
|
+
const savedSha = readHeadSha();
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
if (savedSha && /^[0-9a-f]+$/i.test(savedSha)) {
|
|
74
|
+
const result = execFileSync("git", ["diff", "--name-only", savedSha, "HEAD"], {
|
|
75
|
+
encoding: "utf-8",
|
|
76
|
+
timeout: 5000,
|
|
77
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
78
|
+
});
|
|
79
|
+
for (const f of result.trim().split(/\r?\n/)) {
|
|
80
|
+
const trimmed = f.trim();
|
|
81
|
+
if (trimmed) files.add(trimmed);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const unstaged = execFileSync("git", ["diff", "--name-only"], {
|
|
86
|
+
encoding: "utf-8",
|
|
87
|
+
timeout: 5000,
|
|
88
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
89
|
+
});
|
|
90
|
+
for (const f of unstaged.trim().split(/\r?\n/)) {
|
|
91
|
+
const trimmed = f.trim();
|
|
92
|
+
if (trimmed) files.add(trimmed);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const staged = execFileSync("git", ["diff", "--cached", "--name-only"], {
|
|
96
|
+
encoding: "utf-8",
|
|
97
|
+
timeout: 5000,
|
|
98
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
99
|
+
});
|
|
100
|
+
for (const f of staged.trim().split(/\r?\n/)) {
|
|
101
|
+
const trimmed = f.trim();
|
|
102
|
+
if (trimmed) files.add(trimmed);
|
|
103
|
+
}
|
|
104
|
+
} catch (_) {
|
|
105
|
+
// ignore — git may not be available
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return Array.from(files).filter((f) => {
|
|
109
|
+
const normalized = normalizeSep(f);
|
|
110
|
+
return !STICKY_FILES_PREFIX.some((p) => normalized === p || normalized.startsWith(p));
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Build a narrative string from audit trail prompts.
|
|
116
|
+
* Mirrors the audit-fallback path from session-end.js extractNarrative().
|
|
117
|
+
*/
|
|
118
|
+
function buildNarrativeFromAudit(auditData) {
|
|
119
|
+
const prompts = auditData.prompts || [];
|
|
120
|
+
if (!prompts.length) return "";
|
|
121
|
+
if (prompts.length === 1) return prompts[0].substring(0, 300).trim();
|
|
122
|
+
let summary = prompts[0].substring(0, 150).trim();
|
|
123
|
+
if (prompts.length > 2) {
|
|
124
|
+
summary += ` ... (${prompts.length} prompts total) ... ${prompts[prompts.length - 1].substring(0, 100).trim()}`;
|
|
125
|
+
} else {
|
|
126
|
+
summary += ` -> ${prompts[prompts.length - 1].substring(0, 100).trim()}`;
|
|
127
|
+
}
|
|
128
|
+
return summary.substring(0, 300);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Classify work type from audit tool counts (lightweight version of
|
|
133
|
+
* analyzeSessionActivities from session-end.js).
|
|
134
|
+
*/
|
|
135
|
+
function classifyWorkType(auditData) {
|
|
136
|
+
const tools = auditData.tools || {};
|
|
137
|
+
const files = auditData.files || [];
|
|
138
|
+
const editLike = new Set(["edit", "create", "write", "Edit", "Write", "Create", "MultiEdit", "multi_edit"]);
|
|
139
|
+
let editCount = 0;
|
|
140
|
+
for (const [t, c] of Object.entries(tools)) {
|
|
141
|
+
if (editLike.has(t)) editCount += c;
|
|
142
|
+
}
|
|
143
|
+
const readCount = tools["Read"] || tools["read"] || 0;
|
|
144
|
+
if (editCount > 0) return "feature-development";
|
|
145
|
+
if (readCount > 0 && editCount === 0) return "code-review";
|
|
146
|
+
if (files.length > 0) return "general";
|
|
147
|
+
return "general";
|
|
148
|
+
}
|
|
149
|
+
|
|
40
150
|
function buildHandoffSummary(thread, reason) {
|
|
41
151
|
const parts = [];
|
|
42
152
|
const files = thread.files_touched || [];
|
|
@@ -67,7 +177,7 @@ function buildHandoffSummary(thread, reason) {
|
|
|
67
177
|
return parts.length ? parts.join(" | ") : "Session stopped -- no summary available";
|
|
68
178
|
}
|
|
69
179
|
|
|
70
|
-
function main() {
|
|
180
|
+
async function main() {
|
|
71
181
|
let hookInput = {};
|
|
72
182
|
try {
|
|
73
183
|
if (!process.stdin.isTTY) {
|
|
@@ -85,15 +195,53 @@ function main() {
|
|
|
85
195
|
const now = new Date().toISOString();
|
|
86
196
|
const reason = hookInput.reason || "";
|
|
87
197
|
|
|
198
|
+
const cloud = useCloud();
|
|
88
199
|
const memoryPath = getMemoryPath();
|
|
89
200
|
const memory = loadJson(memoryPath, { version: "2", project: "", threads: [] });
|
|
90
201
|
|
|
202
|
+
if (cloud) {
|
|
203
|
+
const cloudThreads = await cloudReadThreads();
|
|
204
|
+
if (cloudThreads) memory.threads = cloudThreads;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Extract session data from audit trail and git (the key fix —
|
|
208
|
+
// previously this hook created bare threads with no context).
|
|
209
|
+
const auditData = extractSessionFromAudit(sessionId);
|
|
210
|
+
const auditFiles = auditData.files || [];
|
|
211
|
+
const gitFiles = getGitFilesTouched();
|
|
212
|
+
const allFiles = [...new Set([...auditFiles, ...gitFiles])];
|
|
213
|
+
const narrative = buildNarrativeFromAudit(auditData);
|
|
214
|
+
const workType = classifyWorkType(auditData);
|
|
215
|
+
|
|
91
216
|
const threads = memory.threads || [];
|
|
92
217
|
let found = false;
|
|
93
218
|
|
|
94
219
|
for (const thread of threads) {
|
|
95
220
|
if (thread.session_id === sessionId && (thread.status === "open" || thread.status === "stuck")) {
|
|
96
221
|
thread.last_activity_at = now;
|
|
222
|
+
// Enrich with extracted data
|
|
223
|
+
if (allFiles.length) {
|
|
224
|
+
thread.files_touched = [...new Set([...(thread.files_touched || []), ...allFiles])];
|
|
225
|
+
}
|
|
226
|
+
if (narrative && !thread.narrative) {
|
|
227
|
+
thread.narrative = narrative;
|
|
228
|
+
}
|
|
229
|
+
if (workType !== "general" || !thread.work_type) {
|
|
230
|
+
thread.work_type = workType;
|
|
231
|
+
}
|
|
232
|
+
// Merge audit tool counts
|
|
233
|
+
const auditTools = auditData.tools || {};
|
|
234
|
+
const prevCalls = thread.tool_calls || {};
|
|
235
|
+
for (const [tn, cnt] of Object.entries(auditTools)) {
|
|
236
|
+
prevCalls[tn] = (prevCalls[tn] || 0) + cnt;
|
|
237
|
+
}
|
|
238
|
+
thread.tool_calls = prevCalls;
|
|
239
|
+
// Store prompts if not already present
|
|
240
|
+
const storedPrompts = (auditData.prompts || []).slice(0, 20).map((p) => p.substring(0, 300));
|
|
241
|
+
if (storedPrompts.length && !(thread.prompts || []).length) {
|
|
242
|
+
thread.prompts = storedPrompts;
|
|
243
|
+
}
|
|
244
|
+
|
|
97
245
|
thread.handoff_summary = buildHandoffSummary(thread, reason);
|
|
98
246
|
if (reason) {
|
|
99
247
|
thread.last_note = reason.substring(0, 200);
|
|
@@ -104,6 +252,7 @@ function main() {
|
|
|
104
252
|
}
|
|
105
253
|
|
|
106
254
|
if (!found && sessionId !== "unknown") {
|
|
255
|
+
const storedPrompts = (auditData.prompts || []).slice(0, 20).map((p) => p.substring(0, 300));
|
|
107
256
|
const thread = {
|
|
108
257
|
id: crypto.randomUUID(),
|
|
109
258
|
user,
|
|
@@ -113,29 +262,46 @@ function main() {
|
|
|
113
262
|
created_at: now,
|
|
114
263
|
closed_at: now,
|
|
115
264
|
last_activity_at: now,
|
|
116
|
-
files_touched:
|
|
117
|
-
last_note: reason ? reason.substring(0, 200) : "Session stopped",
|
|
118
|
-
narrative
|
|
265
|
+
files_touched: allFiles,
|
|
266
|
+
last_note: reason ? reason.substring(0, 200) : (narrative || "Session stopped"),
|
|
267
|
+
narrative,
|
|
119
268
|
failed_approaches: [],
|
|
120
|
-
handoff_summary:
|
|
269
|
+
handoff_summary: "",
|
|
121
270
|
related_session_ids: [],
|
|
122
271
|
tool: detectTool(hookInput),
|
|
123
272
|
session_id: sessionId,
|
|
273
|
+
work_type: workType,
|
|
274
|
+
tool_calls: auditData.tools || {},
|
|
275
|
+
prompts: storedPrompts,
|
|
276
|
+
contributors: [user],
|
|
277
|
+
resume_history: [],
|
|
124
278
|
};
|
|
279
|
+
thread.handoff_summary = buildHandoffSummary(thread, reason);
|
|
125
280
|
threads.push(thread);
|
|
126
281
|
}
|
|
127
282
|
|
|
128
|
-
|
|
283
|
+
const auditEntry = {
|
|
129
284
|
type: "stop",
|
|
130
285
|
user,
|
|
131
286
|
ts: now,
|
|
132
287
|
session_id: sessionId,
|
|
133
288
|
reason: reason ? reason.substring(0, 200) : "stop_checkpoint",
|
|
134
|
-
}
|
|
289
|
+
};
|
|
290
|
+
appendAuditLine(auditEntry);
|
|
291
|
+
if (cloud) {
|
|
292
|
+
cloudAppendAudit(auditEntry).catch(() => {});
|
|
293
|
+
}
|
|
135
294
|
|
|
136
295
|
saveMemoryMerged(memoryPath, memory);
|
|
296
|
+
if (cloud) {
|
|
297
|
+
const threadToSync = threads.find(t => t.session_id === sessionId);
|
|
298
|
+
if (threadToSync) {
|
|
299
|
+
cloudWriteThread(threadToSync).catch(() => {});
|
|
300
|
+
}
|
|
301
|
+
}
|
|
137
302
|
const reasonStr = reason ? reason.substring(0, 60) : "checkpoint";
|
|
138
|
-
const
|
|
303
|
+
const fileCount = allFiles.length;
|
|
304
|
+
const statusMsg = `[STICKY-NOTE] Session stopped - handoff saved (${reasonStr}, ${fileCount} file${fileCount !== 1 ? "s" : ""})`;
|
|
139
305
|
try {
|
|
140
306
|
process.stdout.write(JSON.stringify({ output: statusMsg }) + "\n");
|
|
141
307
|
} catch (_) {
|
|
@@ -144,8 +310,4 @@ function main() {
|
|
|
144
310
|
process.exit(0);
|
|
145
311
|
}
|
|
146
312
|
|
|
147
|
-
|
|
148
|
-
main();
|
|
149
|
-
} catch (_) {
|
|
150
|
-
safeExit();
|
|
151
|
-
}
|
|
313
|
+
main().catch(() => safeExit());
|
|
@@ -47,6 +47,7 @@ const {
|
|
|
47
47
|
markThreadInjected,
|
|
48
48
|
clearActiveResumeThreadId,
|
|
49
49
|
getActiveResumeThreadId,
|
|
50
|
+
normalizeSep,
|
|
50
51
|
} = utils;
|
|
51
52
|
|
|
52
53
|
// ── Stale-thread ageing ───────────────────────────────────
|
|
@@ -75,6 +76,34 @@ function ageStaleThreads(memory, staleDays) {
|
|
|
75
76
|
return changed;
|
|
76
77
|
}
|
|
77
78
|
|
|
79
|
+
// Auto-close Copilot CLI threads that have been inactive beyond a threshold.
|
|
80
|
+
// Copilot CLI has no session-end signal, so threads stay open indefinitely.
|
|
81
|
+
// This closes them after configurable inactivity (default 24h).
|
|
82
|
+
function autoCloseCopilotCliThreads(memory, autoCloseHours) {
|
|
83
|
+
const now = Date.now();
|
|
84
|
+
let changed = false;
|
|
85
|
+
for (const thread of memory.threads || []) {
|
|
86
|
+
if (thread.status !== "open") continue;
|
|
87
|
+
if (thread.tool !== "copilot-cli") continue;
|
|
88
|
+
const tsField =
|
|
89
|
+
thread.last_activity_at || thread.updated_at || thread.created_at || "";
|
|
90
|
+
if (!tsField) continue;
|
|
91
|
+
try {
|
|
92
|
+
const ts = new Date(tsField).getTime();
|
|
93
|
+
if (isNaN(ts)) continue;
|
|
94
|
+
const diffHours = (now - ts) / (1000 * 60 * 60);
|
|
95
|
+
if (diffHours >= autoCloseHours) {
|
|
96
|
+
thread.status = "closed";
|
|
97
|
+
thread.closed_at = new Date().toISOString();
|
|
98
|
+
changed = true;
|
|
99
|
+
}
|
|
100
|
+
} catch (_) {
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return changed;
|
|
105
|
+
}
|
|
106
|
+
|
|
78
107
|
// ── Formatting helpers ────────────────────────────────────
|
|
79
108
|
|
|
80
109
|
function formatThreadsForInjection(threads, maxThreads, maxTokens) {
|
|
@@ -181,9 +210,26 @@ function loadAllPresence() {
|
|
|
181
210
|
return data;
|
|
182
211
|
}
|
|
183
212
|
|
|
184
|
-
function formatPresence(presenceData) {
|
|
213
|
+
function formatPresence(presenceData, threads) {
|
|
185
214
|
const now = Date.now();
|
|
186
215
|
const active = [];
|
|
216
|
+
|
|
217
|
+
// Build a map of user → most recent open/stuck thread
|
|
218
|
+
const userThreads = {};
|
|
219
|
+
for (const t of (threads || [])) {
|
|
220
|
+
if (t.status !== "open" && t.status !== "stuck") continue;
|
|
221
|
+
const user = t.user || t.author || "";
|
|
222
|
+
if (!user) continue;
|
|
223
|
+
const existing = userThreads[user];
|
|
224
|
+
if (!existing) {
|
|
225
|
+
userThreads[user] = t;
|
|
226
|
+
} else {
|
|
227
|
+
const existingTs = existing.last_activity_at || existing.created_at || "";
|
|
228
|
+
const thisTs = t.last_activity_at || t.created_at || "";
|
|
229
|
+
if (thisTs > existingTs) userThreads[user] = t;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
187
233
|
for (const [user, info] of Object.entries(presenceData || {})) {
|
|
188
234
|
const lastSeen = info.last_seen || "";
|
|
189
235
|
if (!lastSeen) continue;
|
|
@@ -192,7 +238,20 @@ function formatPresence(presenceData) {
|
|
|
192
238
|
if (isNaN(ts)) continue;
|
|
193
239
|
if (now - ts < 15 * 60 * 1000) {
|
|
194
240
|
const files = (info.active_files || []).slice(0, 3).join(", ");
|
|
195
|
-
|
|
241
|
+
const thread = userThreads[user];
|
|
242
|
+
let line = `- **${user}**`;
|
|
243
|
+
if (thread) {
|
|
244
|
+
const narrative = thread.narrative || thread.last_note || "";
|
|
245
|
+
const snip = narrative.length > 60
|
|
246
|
+
? narrative.substring(0, 60) + "…"
|
|
247
|
+
: narrative;
|
|
248
|
+
const status = thread.status === "stuck" ? " [STUCK]" : "";
|
|
249
|
+
line += `${status}: ${snip || files}`;
|
|
250
|
+
if (snip && files) line += ` (${files})`;
|
|
251
|
+
} else {
|
|
252
|
+
line += ` active on: ${files}`;
|
|
253
|
+
}
|
|
254
|
+
active.push(line);
|
|
196
255
|
}
|
|
197
256
|
} catch (_) {
|
|
198
257
|
continue;
|
|
@@ -202,6 +261,128 @@ function formatPresence(presenceData) {
|
|
|
202
261
|
return "\n## Active Now\n" + active.join("\n");
|
|
203
262
|
}
|
|
204
263
|
|
|
264
|
+
// ── Overlap detection ─────────────────────────────────────
|
|
265
|
+
|
|
266
|
+
function getRecentlyModifiedFiles() {
|
|
267
|
+
const files = new Set();
|
|
268
|
+
const diffTargets = ["HEAD~5", "HEAD~1"];
|
|
269
|
+
for (const target of diffTargets) {
|
|
270
|
+
try {
|
|
271
|
+
const result = require("child_process").execSync(
|
|
272
|
+
`git diff --name-only ${target}`,
|
|
273
|
+
{ encoding: "utf-8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] }
|
|
274
|
+
);
|
|
275
|
+
for (const f of result.trim().split(/\r?\n/)) {
|
|
276
|
+
const trimmed = f.trim();
|
|
277
|
+
if (trimmed) files.add(normalizeSep(trimmed));
|
|
278
|
+
}
|
|
279
|
+
break;
|
|
280
|
+
} catch (_) {}
|
|
281
|
+
}
|
|
282
|
+
try {
|
|
283
|
+
const result = require("child_process").execSync("git diff --name-only", {
|
|
284
|
+
encoding: "utf-8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"],
|
|
285
|
+
});
|
|
286
|
+
for (const f of result.trim().split(/\r?\n/)) {
|
|
287
|
+
const trimmed = f.trim();
|
|
288
|
+
if (trimmed) files.add(normalizeSep(trimmed));
|
|
289
|
+
}
|
|
290
|
+
} catch (_) {}
|
|
291
|
+
try {
|
|
292
|
+
const result = require("child_process").execSync(
|
|
293
|
+
"git diff --name-only --cached",
|
|
294
|
+
{ encoding: "utf-8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] }
|
|
295
|
+
);
|
|
296
|
+
for (const f of result.trim().split(/\r?\n/)) {
|
|
297
|
+
const trimmed = f.trim();
|
|
298
|
+
if (trimmed) files.add(normalizeSep(trimmed));
|
|
299
|
+
}
|
|
300
|
+
} catch (_) {}
|
|
301
|
+
return files;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function detectOverlaps(threads, currentUser) {
|
|
305
|
+
const modifiedFiles = getRecentlyModifiedFiles();
|
|
306
|
+
if (modifiedFiles.size === 0) return { warnings: [], claims: [] };
|
|
307
|
+
|
|
308
|
+
const activeThreads = (threads || []).filter(
|
|
309
|
+
(t) => t.status === "open" || t.status === "stuck"
|
|
310
|
+
);
|
|
311
|
+
|
|
312
|
+
const warnings = [];
|
|
313
|
+
for (const thread of activeThreads) {
|
|
314
|
+
const threadUser = thread.user || thread.author || "";
|
|
315
|
+
if (threadUser === currentUser) continue; // skip own threads
|
|
316
|
+
const threadFiles = (thread.files_touched || []).map(normalizeSep);
|
|
317
|
+
const overlap = threadFiles.filter((f) => modifiedFiles.has(f));
|
|
318
|
+
if (overlap.length === 0) continue;
|
|
319
|
+
warnings.push({ thread, overlap });
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Sort: stuck first, then by overlap count
|
|
323
|
+
warnings.sort((a, b) => {
|
|
324
|
+
if (a.thread.status !== b.thread.status) {
|
|
325
|
+
return a.thread.status === "stuck" ? -1 : 1;
|
|
326
|
+
}
|
|
327
|
+
return b.overlap.length - a.overlap.length;
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
return { warnings, modifiedFiles };
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function formatOverlapWarnings(threads, currentUser, memory) {
|
|
334
|
+
const { warnings, modifiedFiles } = detectOverlaps(threads, currentUser);
|
|
335
|
+
const claims = (memory.claims || []).filter((c) => {
|
|
336
|
+
if (c.user === currentUser) return false;
|
|
337
|
+
if (!modifiedFiles || modifiedFiles.size === 0) return false;
|
|
338
|
+
return (c.files || []).some((f) => modifiedFiles.has(normalizeSep(f)));
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
if (warnings.length === 0 && claims.length === 0) return "";
|
|
342
|
+
|
|
343
|
+
const lines = [
|
|
344
|
+
"## [STICKY-NOTE] ⚠️ OVERLAP DETECTED\n",
|
|
345
|
+
"Someone else is working on files you're touching:\n",
|
|
346
|
+
];
|
|
347
|
+
|
|
348
|
+
for (const { thread, overlap } of warnings) {
|
|
349
|
+
const user = thread.user || thread.author || "unknown";
|
|
350
|
+
const status = thread.status === "stuck" ? "[STUCK]" : "[OPEN]";
|
|
351
|
+
const narrative = thread.narrative || thread.last_note || "";
|
|
352
|
+
const narrativeSnip = narrative.length > 100
|
|
353
|
+
? narrative.substring(0, 100) + "…"
|
|
354
|
+
: narrative;
|
|
355
|
+
const branch = thread.branch ? ` on \`${thread.branch}\`` : "";
|
|
356
|
+
const threadId = (thread.id || "").substring(0, 8);
|
|
357
|
+
|
|
358
|
+
lines.push(`- ${status} **${user}**${branch}: ${overlap.join(", ")}`);
|
|
359
|
+
if (narrativeSnip) lines.push(` _${narrativeSnip}_`);
|
|
360
|
+
const failed = thread.failed_approaches || [];
|
|
361
|
+
if (failed.length > 0) {
|
|
362
|
+
lines.push(` ⚠️ ${failed.length} failed approach(es):`);
|
|
363
|
+
for (const fa of failed.slice(0, 2)) {
|
|
364
|
+
lines.push(` - ${(fa.description || "").substring(0, 80)}`);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
if (threadId) {
|
|
368
|
+
lines.push(` → Resume: \`npx sticky-note resume ${threadId}\``);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
for (const claim of claims) {
|
|
373
|
+
const ago = claim.claimed_at || "";
|
|
374
|
+
lines.push(
|
|
375
|
+
`- 📌 **${claim.user}** claimed: ${(claim.files || []).join(", ")}` +
|
|
376
|
+
(claim.description ? ` — "${claim.description}"` : "")
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
lines.push(
|
|
381
|
+
"\n**Consider coordinating with these teammates before starting work.**"
|
|
382
|
+
);
|
|
383
|
+
return lines.join("\n");
|
|
384
|
+
}
|
|
385
|
+
|
|
205
386
|
// ── Main ──────────────────────────────────────────────────
|
|
206
387
|
|
|
207
388
|
function main() {
|
|
@@ -243,8 +424,13 @@ function main() {
|
|
|
243
424
|
});
|
|
244
425
|
const config = loadJson(getConfigPath(), { stale_days: 14 });
|
|
245
426
|
const staleDays = config.stale_days != null ? config.stale_days : 14;
|
|
427
|
+
const autoCloseHours =
|
|
428
|
+
config.copilot_cli_auto_close_hours != null
|
|
429
|
+
? config.copilot_cli_auto_close_hours
|
|
430
|
+
: 24;
|
|
246
431
|
|
|
247
432
|
ageStaleThreads(memory, staleDays);
|
|
433
|
+
autoCloseCopilotCliThreads(memory, autoCloseHours);
|
|
248
434
|
|
|
249
435
|
// ── Resume handling ───────────────────────────────────
|
|
250
436
|
const resumeThreadId = getResumeThreadId();
|
|
@@ -280,7 +466,10 @@ function main() {
|
|
|
280
466
|
const threadContext = threadResult.text;
|
|
281
467
|
const configContext = formatConfigForInjection(config);
|
|
282
468
|
const presenceData = loadAllPresence();
|
|
283
|
-
const presenceContext = formatPresence(presenceData);
|
|
469
|
+
const presenceContext = formatPresence(presenceData, memory.threads || []);
|
|
470
|
+
const overlapContext = formatOverlapWarnings(
|
|
471
|
+
memory.threads || [], getUser(), memory
|
|
472
|
+
);
|
|
284
473
|
|
|
285
474
|
// V2.5: Mark eagerly-injected stuck threads so PreToolUse won't re-inject
|
|
286
475
|
for (const threadId of threadResult.threadIds) {
|
|
@@ -378,6 +567,9 @@ function main() {
|
|
|
378
567
|
parts.push(resumeLines.join("\n"));
|
|
379
568
|
}
|
|
380
569
|
|
|
570
|
+
// Overlap warning comes first — most urgent signal
|
|
571
|
+
if (overlapContext) parts.push(overlapContext);
|
|
572
|
+
|
|
381
573
|
if (threadContext) parts.push(threadContext);
|
|
382
574
|
if (configContext) parts.push(configContext);
|
|
383
575
|
if (presenceContext) parts.push(presenceContext);
|
|
@@ -1,71 +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
|
|
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
|