sticky-note-cli 2.5.4 → 2.6.1

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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sticky-note-cli",
3
- "version": "2.5.4",
3
+ "version": "2.6.1",
4
4
  "description": "Human-to-human handoff for AI coding assistants. Git-backed shared memory for Claude Code and Copilot CLI.",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -11,9 +11,22 @@
11
11
  const crypto = require("crypto");
12
12
  const path = require("path");
13
13
 
14
+ function _isCopilotCli() {
15
+ return process.argv.includes("--copilot-cli") || !!process.env.COPILOT_CLI;
16
+ }
17
+
18
+ function _emit(text) {
19
+ if (text === undefined) text = "";
20
+ if (_isCopilotCli()) {
21
+ process.stdout.write(JSON.stringify({ additionalContext: text }) + "\n");
22
+ } else {
23
+ process.stdout.write(JSON.stringify({ output: text }) + "\n");
24
+ }
25
+ }
26
+
14
27
  function _safeExit() {
15
28
  try {
16
- process.stdout.write(JSON.stringify({ output: "" }) + "\n");
29
+ _emit("");
17
30
  } catch (_) {
18
31
  process.stdout.write('{"output":""}\n');
19
32
  }
@@ -47,6 +60,7 @@ const {
47
60
  markThreadInjected,
48
61
  clearActiveResumeThreadId,
49
62
  getActiveResumeThreadId,
63
+ normalizeSep,
50
64
  } = utils;
51
65
 
52
66
  // ── Stale-thread ageing ───────────────────────────────────
@@ -75,6 +89,34 @@ function ageStaleThreads(memory, staleDays) {
75
89
  return changed;
76
90
  }
77
91
 
92
+ // Auto-close Copilot CLI threads that have been inactive beyond a threshold.
93
+ // Copilot CLI has no session-end signal, so threads stay open indefinitely.
94
+ // This closes them after configurable inactivity (default 24h).
95
+ function autoCloseCopilotCliThreads(memory, autoCloseHours) {
96
+ const now = Date.now();
97
+ let changed = false;
98
+ for (const thread of memory.threads || []) {
99
+ if (thread.status !== "open") continue;
100
+ if (thread.tool !== "copilot-cli") continue;
101
+ const tsField =
102
+ thread.last_activity_at || thread.updated_at || thread.created_at || "";
103
+ if (!tsField) continue;
104
+ try {
105
+ const ts = new Date(tsField).getTime();
106
+ if (isNaN(ts)) continue;
107
+ const diffHours = (now - ts) / (1000 * 60 * 60);
108
+ if (diffHours >= autoCloseHours) {
109
+ thread.status = "closed";
110
+ thread.closed_at = new Date().toISOString();
111
+ changed = true;
112
+ }
113
+ } catch (_) {
114
+ continue;
115
+ }
116
+ }
117
+ return changed;
118
+ }
119
+
78
120
  // ── Formatting helpers ────────────────────────────────────
79
121
 
80
122
  function formatThreadsForInjection(threads, maxThreads, maxTokens) {
@@ -181,9 +223,26 @@ function loadAllPresence() {
181
223
  return data;
182
224
  }
183
225
 
184
- function formatPresence(presenceData) {
226
+ function formatPresence(presenceData, threads) {
185
227
  const now = Date.now();
186
228
  const active = [];
229
+
230
+ // Build a map of user → most recent open/stuck thread
231
+ const userThreads = {};
232
+ for (const t of (threads || [])) {
233
+ if (t.status !== "open" && t.status !== "stuck") continue;
234
+ const user = t.user || t.author || "";
235
+ if (!user) continue;
236
+ const existing = userThreads[user];
237
+ if (!existing) {
238
+ userThreads[user] = t;
239
+ } else {
240
+ const existingTs = existing.last_activity_at || existing.created_at || "";
241
+ const thisTs = t.last_activity_at || t.created_at || "";
242
+ if (thisTs > existingTs) userThreads[user] = t;
243
+ }
244
+ }
245
+
187
246
  for (const [user, info] of Object.entries(presenceData || {})) {
188
247
  const lastSeen = info.last_seen || "";
189
248
  if (!lastSeen) continue;
@@ -192,7 +251,20 @@ function formatPresence(presenceData) {
192
251
  if (isNaN(ts)) continue;
193
252
  if (now - ts < 15 * 60 * 1000) {
194
253
  const files = (info.active_files || []).slice(0, 3).join(", ");
195
- active.push(`- **${user}** active on: ${files}`);
254
+ const thread = userThreads[user];
255
+ let line = `- **${user}**`;
256
+ if (thread) {
257
+ const narrative = thread.narrative || thread.last_note || "";
258
+ const snip = narrative.length > 60
259
+ ? narrative.substring(0, 60) + "…"
260
+ : narrative;
261
+ const status = thread.status === "stuck" ? " [STUCK]" : "";
262
+ line += `${status}: ${snip || files}`;
263
+ if (snip && files) line += ` (${files})`;
264
+ } else {
265
+ line += ` active on: ${files}`;
266
+ }
267
+ active.push(line);
196
268
  }
197
269
  } catch (_) {
198
270
  continue;
@@ -202,6 +274,128 @@ function formatPresence(presenceData) {
202
274
  return "\n## Active Now\n" + active.join("\n");
203
275
  }
204
276
 
277
+ // ── Overlap detection ─────────────────────────────────────
278
+
279
+ function getRecentlyModifiedFiles() {
280
+ const files = new Set();
281
+ const diffTargets = ["HEAD~5", "HEAD~1"];
282
+ for (const target of diffTargets) {
283
+ try {
284
+ const result = require("child_process").execSync(
285
+ `git diff --name-only ${target}`,
286
+ { encoding: "utf-8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] }
287
+ );
288
+ for (const f of result.trim().split(/\r?\n/)) {
289
+ const trimmed = f.trim();
290
+ if (trimmed) files.add(normalizeSep(trimmed));
291
+ }
292
+ break;
293
+ } catch (_) {}
294
+ }
295
+ try {
296
+ const result = require("child_process").execSync("git diff --name-only", {
297
+ encoding: "utf-8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"],
298
+ });
299
+ for (const f of result.trim().split(/\r?\n/)) {
300
+ const trimmed = f.trim();
301
+ if (trimmed) files.add(normalizeSep(trimmed));
302
+ }
303
+ } catch (_) {}
304
+ try {
305
+ const result = require("child_process").execSync(
306
+ "git diff --name-only --cached",
307
+ { encoding: "utf-8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] }
308
+ );
309
+ for (const f of result.trim().split(/\r?\n/)) {
310
+ const trimmed = f.trim();
311
+ if (trimmed) files.add(normalizeSep(trimmed));
312
+ }
313
+ } catch (_) {}
314
+ return files;
315
+ }
316
+
317
+ function detectOverlaps(threads, currentUser) {
318
+ const modifiedFiles = getRecentlyModifiedFiles();
319
+ if (modifiedFiles.size === 0) return { warnings: [], claims: [] };
320
+
321
+ const activeThreads = (threads || []).filter(
322
+ (t) => t.status === "open" || t.status === "stuck"
323
+ );
324
+
325
+ const warnings = [];
326
+ for (const thread of activeThreads) {
327
+ const threadUser = thread.user || thread.author || "";
328
+ if (threadUser === currentUser) continue; // skip own threads
329
+ const threadFiles = (thread.files_touched || []).map(normalizeSep);
330
+ const overlap = threadFiles.filter((f) => modifiedFiles.has(f));
331
+ if (overlap.length === 0) continue;
332
+ warnings.push({ thread, overlap });
333
+ }
334
+
335
+ // Sort: stuck first, then by overlap count
336
+ warnings.sort((a, b) => {
337
+ if (a.thread.status !== b.thread.status) {
338
+ return a.thread.status === "stuck" ? -1 : 1;
339
+ }
340
+ return b.overlap.length - a.overlap.length;
341
+ });
342
+
343
+ return { warnings, modifiedFiles };
344
+ }
345
+
346
+ function formatOverlapWarnings(threads, currentUser, memory) {
347
+ const { warnings, modifiedFiles } = detectOverlaps(threads, currentUser);
348
+ const claims = (memory.claims || []).filter((c) => {
349
+ if (c.user === currentUser) return false;
350
+ if (!modifiedFiles || modifiedFiles.size === 0) return false;
351
+ return (c.files || []).some((f) => modifiedFiles.has(normalizeSep(f)));
352
+ });
353
+
354
+ if (warnings.length === 0 && claims.length === 0) return "";
355
+
356
+ const lines = [
357
+ "## [STICKY-NOTE] ⚠️ OVERLAP DETECTED\n",
358
+ "Someone else is working on files you're touching:\n",
359
+ ];
360
+
361
+ for (const { thread, overlap } of warnings) {
362
+ const user = thread.user || thread.author || "unknown";
363
+ const status = thread.status === "stuck" ? "[STUCK]" : "[OPEN]";
364
+ const narrative = thread.narrative || thread.last_note || "";
365
+ const narrativeSnip = narrative.length > 100
366
+ ? narrative.substring(0, 100) + "…"
367
+ : narrative;
368
+ const branch = thread.branch ? ` on \`${thread.branch}\`` : "";
369
+ const threadId = (thread.id || "").substring(0, 8);
370
+
371
+ lines.push(`- ${status} **${user}**${branch}: ${overlap.join(", ")}`);
372
+ if (narrativeSnip) lines.push(` _${narrativeSnip}_`);
373
+ const failed = thread.failed_approaches || [];
374
+ if (failed.length > 0) {
375
+ lines.push(` ⚠️ ${failed.length} failed approach(es):`);
376
+ for (const fa of failed.slice(0, 2)) {
377
+ lines.push(` - ${(fa.description || "").substring(0, 80)}`);
378
+ }
379
+ }
380
+ if (threadId) {
381
+ lines.push(` → Resume: \`npx sticky-note resume ${threadId}\``);
382
+ }
383
+ }
384
+
385
+ for (const claim of claims) {
386
+ const ago = claim.claimed_at || "";
387
+ lines.push(
388
+ `- 📌 **${claim.user}** claimed: ${(claim.files || []).join(", ")}` +
389
+ (claim.description ? ` — "${claim.description}"` : "")
390
+ );
391
+ }
392
+
393
+ lines.push(
394
+ "\n**Consider coordinating with these teammates before starting work.**"
395
+ );
396
+ return lines.join("\n");
397
+ }
398
+
205
399
  // ── Main ──────────────────────────────────────────────────
206
400
 
207
401
  function main() {
@@ -243,8 +437,13 @@ function main() {
243
437
  });
244
438
  const config = loadJson(getConfigPath(), { stale_days: 14 });
245
439
  const staleDays = config.stale_days != null ? config.stale_days : 14;
440
+ const autoCloseHours =
441
+ config.copilot_cli_auto_close_hours != null
442
+ ? config.copilot_cli_auto_close_hours
443
+ : 24;
246
444
 
247
445
  ageStaleThreads(memory, staleDays);
446
+ autoCloseCopilotCliThreads(memory, autoCloseHours);
248
447
 
249
448
  // ── Resume handling ───────────────────────────────────
250
449
  const resumeThreadId = getResumeThreadId();
@@ -280,7 +479,10 @@ function main() {
280
479
  const threadContext = threadResult.text;
281
480
  const configContext = formatConfigForInjection(config);
282
481
  const presenceData = loadAllPresence();
283
- const presenceContext = formatPresence(presenceData);
482
+ const presenceContext = formatPresence(presenceData, memory.threads || []);
483
+ const overlapContext = formatOverlapWarnings(
484
+ memory.threads || [], getUser(), memory
485
+ );
284
486
 
285
487
  // V2.5: Mark eagerly-injected stuck threads so PreToolUse won't re-inject
286
488
  for (const threadId of threadResult.threadIds) {
@@ -378,12 +580,15 @@ function main() {
378
580
  parts.push(resumeLines.join("\n"));
379
581
  }
380
582
 
583
+ // Overlap warning comes first — most urgent signal
584
+ if (overlapContext) parts.push(overlapContext);
585
+
381
586
  if (threadContext) parts.push(threadContext);
382
587
  if (configContext) parts.push(configContext);
383
588
  if (presenceContext) parts.push(presenceContext);
384
589
 
385
590
  const output = parts.join("\n").trim();
386
- process.stdout.write(JSON.stringify({ output }) + "\n");
591
+ _emit(output);
387
592
  }
388
593
 
389
594
  // ── Entry point ───────────────────────────────────────────
@@ -1,5 +1,6 @@
1
1
  {
2
2
  "stale_days": 14,
3
+ "copilot_cli_auto_close_hours": 24,
3
4
  "inject_token_budget": 1000,
4
5
  "mcp_servers": [],
5
6
  "skills": [],