sticky-note-cli 2.6.6 → 2.6.8

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sticky-note-cli",
3
- "version": "2.6.6",
3
+ "version": "2.6.8",
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": {
@@ -181,6 +181,25 @@ function detectAndFormatOverlaps(threads, currentUser) {
181
181
  lines.push(
182
182
  "\n**Consider coordinating with these teammates before starting work.**\n"
183
183
  );
184
+
185
+ // Write a visible banner directly to stderr so the user sees it in
186
+ // their terminal regardless of whether the AI surfaces it.
187
+ const stderrLines = ["\n⚠️ OVERLAP DETECTED — someone else is touching your files:"];
188
+ for (const { thread, overlap } of warnings) {
189
+ const user = thread.user || thread.author || "unknown";
190
+ const status = thread.status === "stuck" ? "STUCK" : "OPEN";
191
+ const narrative = thread.narrative || thread.last_note || "";
192
+ const narrativeSnip = narrative.length > 80
193
+ ? narrative.substring(0, 80) + "…"
194
+ : narrative;
195
+ stderrLines.push(
196
+ ` [${status}] ${user}: ${overlap.join(", ")}` +
197
+ (narrativeSnip ? ` — ${narrativeSnip}` : "")
198
+ );
199
+ }
200
+ stderrLines.push("");
201
+ process.stderr.write(stderrLines.join("\n") + "\n");
202
+
184
203
  return lines.join("\n");
185
204
  }
186
205
 
@@ -61,6 +61,11 @@ const {
61
61
  appendAuditLine,
62
62
  isThreadInjected,
63
63
  markThreadInjected,
64
+ isOverlapWarned,
65
+ markOverlapWarned,
66
+ getMemoryPath,
67
+ loadJson,
68
+ normalizeSep,
64
69
  } = utils;
65
70
 
66
71
  // ── File path extraction from tool input ──────────────────
@@ -131,6 +136,110 @@ function formatThreadForInjection(threadData, file) {
131
136
  return lines.join("\n");
132
137
  }
133
138
 
139
+ // ── Overlap detection via preToolUse deny (Copilot CLI only) ──
140
+ // Copilot CLI ignores output from all hooks except preToolUse "deny".
141
+ // On the FIRST tool call of a session, if we detect file overlaps with
142
+ // other users' threads, we deny the tool call with the overlap warning
143
+ // as the reason. The AI reports the denial to the user, then retries.
144
+ // Subsequent tool calls proceed normally.
145
+
146
+ const { execSync } = require("child_process");
147
+
148
+ function _getRecentlyModifiedFiles() {
149
+ const files = new Set();
150
+ for (const target of ["HEAD~5", "HEAD~1"]) {
151
+ try {
152
+ const result = execSync(`git diff --name-only ${target}`, {
153
+ encoding: "utf-8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"],
154
+ });
155
+ for (const f of result.trim().split(/\r?\n/)) {
156
+ if (f.trim()) files.add(f.trim());
157
+ }
158
+ break;
159
+ } catch (_) { /* target doesn't exist */ }
160
+ }
161
+ try {
162
+ const result = execSync("git diff --name-only", {
163
+ encoding: "utf-8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"],
164
+ });
165
+ for (const f of result.trim().split(/\r?\n/)) {
166
+ if (f.trim()) files.add(f.trim());
167
+ }
168
+ } catch (_) { /* ignore */ }
169
+ return files;
170
+ }
171
+
172
+ function _checkOverlapsAndDeny(hookInput) {
173
+ if (!_isCopilotCli()) return null;
174
+
175
+ const sessionId = getSessionId(hookInput);
176
+ if (isOverlapWarned(sessionId)) return null;
177
+
178
+ // Load threads
179
+ let threads;
180
+ try {
181
+ const memPath = getMemoryPath();
182
+ const data = loadJson(memPath);
183
+ threads = data.threads || [];
184
+ } catch (_) {
185
+ return null;
186
+ }
187
+
188
+ const currentUser = getUser();
189
+ const modifiedFiles = _getRecentlyModifiedFiles();
190
+ if (modifiedFiles.size === 0) return null;
191
+
192
+ const activeThreads = threads.filter(
193
+ (t) => t.status === "open" || t.status === "stuck"
194
+ );
195
+
196
+ const warnings = [];
197
+ for (const thread of activeThreads) {
198
+ const threadUser = thread.user || thread.author || "";
199
+ if (threadUser === currentUser) continue;
200
+ const threadFiles = (thread.files_touched || []).map(normalizeSep);
201
+ const overlap = threadFiles.filter((f) => modifiedFiles.has(f));
202
+ if (overlap.length === 0) continue;
203
+ warnings.push({ thread, overlap });
204
+ }
205
+
206
+ if (warnings.length === 0) return null;
207
+
208
+ // Build a concise deny reason
209
+ const lines = ["⚠️ OVERLAP DETECTED — someone else is working on files you're touching:"];
210
+ for (const { thread, overlap } of warnings) {
211
+ const user = thread.user || thread.author || "unknown";
212
+ const status = thread.status === "stuck" ? "STUCK" : "OPEN";
213
+ const narrative = thread.narrative || thread.last_note || "";
214
+ const snip = narrative.length > 80 ? narrative.substring(0, 80) + "…" : narrative;
215
+ lines.push(` [${status}] ${user}: ${overlap.join(", ")}${snip ? " — " + snip : ""}`);
216
+ }
217
+ lines.push("Consider coordinating before proceeding. This tool call will be auto-retried.");
218
+
219
+ // Mark as warned so subsequent tool calls aren't blocked
220
+ markOverlapWarned(sessionId);
221
+
222
+ // Audit the overlap deny
223
+ try {
224
+ appendAuditLine({
225
+ type: "overlap_deny",
226
+ user: currentUser,
227
+ ts: new Date().toISOString(),
228
+ session_id: sessionId,
229
+ warnings_count: warnings.length,
230
+ threads: warnings.map((w) => ({
231
+ user: w.thread.user || w.thread.author,
232
+ files: w.overlap,
233
+ })),
234
+ });
235
+ } catch (_) { /* ignore */ }
236
+
237
+ return {
238
+ permissionDecision: "deny",
239
+ permissionDecisionReason: lines.join("\n"),
240
+ };
241
+ }
242
+
134
243
  // ── Main ──────────────────────────────────────────────────
135
244
 
136
245
  function main() {
@@ -146,6 +255,14 @@ function main() {
146
255
 
147
256
  const sessionId = getSessionId(hookInput);
148
257
 
258
+ // ── Overlap deny gate (Copilot CLI only) ──
259
+ // On first tool call, deny with overlap warning if applicable.
260
+ const denyResult = _checkOverlapsAndDeny(hookInput);
261
+ if (denyResult) {
262
+ process.stdout.write(JSON.stringify(denyResult) + "\n");
263
+ return;
264
+ }
265
+
149
266
  // Extract file path from tool input
150
267
  const filePath = extractFilePath(hookInput);
151
268
  if (!filePath) {
@@ -394,6 +394,24 @@ function formatOverlapWarnings(threads, currentUser, memory) {
394
394
  lines.push(
395
395
  "\n**Consider coordinating with these teammates before starting work.**"
396
396
  );
397
+
398
+ // Write a visible banner directly to stderr so the user sees it
399
+ const stderrLines = ["\n⚠️ OVERLAP DETECTED — someone else is touching your files:"];
400
+ for (const { thread, overlap } of warnings) {
401
+ const user = thread.user || thread.author || "unknown";
402
+ const status = thread.status === "stuck" ? "STUCK" : "OPEN";
403
+ const narrative = thread.narrative || thread.last_note || "";
404
+ const narrativeSnip = narrative.length > 80
405
+ ? narrative.substring(0, 80) + "…"
406
+ : narrative;
407
+ stderrLines.push(
408
+ ` [${status}] ${user}: ${overlap.join(", ")}` +
409
+ (narrativeSnip ? ` — ${narrativeSnip}` : "")
410
+ );
411
+ }
412
+ stderrLines.push("");
413
+ process.stderr.write(stderrLines.join("\n") + "\n");
414
+
397
415
  return lines.join("\n");
398
416
  }
399
417
 
@@ -590,6 +590,23 @@ function markThreadInjected(threadId, sessionId) {
590
590
  saveInjectedSet(data);
591
591
  }
592
592
 
593
+ // ── Overlap-warned tracking (V2.6) ────────────────────────
594
+
595
+ function isOverlapWarned(sessionId) {
596
+ const data = loadInjectedSet();
597
+ if (sessionId && data.session_id && data.session_id !== sessionId) {
598
+ return false;
599
+ }
600
+ return !!data.overlap_warned;
601
+ }
602
+
603
+ function markOverlapWarned(sessionId) {
604
+ const data = loadInjectedSet();
605
+ data.session_id = sessionId || data.session_id;
606
+ data.overlap_warned = true;
607
+ saveInjectedSet(data);
608
+ }
609
+
593
610
  // ── Active resumed thread tracking (V2.5) ─────────────────
594
611
 
595
612
  const _activeResumePath = () => path.join(_stickyDir(), ".sticky-active-resume");
@@ -673,6 +690,9 @@ module.exports = {
673
690
  getActiveResumeThreadId,
674
691
  setActiveResumeThreadId,
675
692
  clearActiveResumeThreadId,
693
+ // V2.6: overlap warning tracking
694
+ isOverlapWarned,
695
+ markOverlapWarned,
676
696
  // V2.5: cross-platform path helper
677
697
  normalizeSep,
678
698
  };