sticky-note-cli 2.6.7 → 2.6.9

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.7",
3
+ "version": "2.6.9",
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": {
@@ -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) {
@@ -590,6 +590,32 @@ 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
+ // Session-scoped check: different session → not warned
598
+ if (sessionId && data.session_id && data.session_id !== sessionId) {
599
+ return false;
600
+ }
601
+ // Time-based fallback: if warned more than 60s ago, treat as new session.
602
+ // Copilot CLI sessions share session IDs (via .sticky-session file), so
603
+ // we can't rely on session_id alone to distinguish separate sessions.
604
+ if (data.overlap_warned_at) {
605
+ const elapsed = Date.now() - new Date(data.overlap_warned_at).getTime();
606
+ if (elapsed > 60000) return false;
607
+ }
608
+ return !!data.overlap_warned;
609
+ }
610
+
611
+ function markOverlapWarned(sessionId) {
612
+ const data = loadInjectedSet();
613
+ data.session_id = sessionId || data.session_id;
614
+ data.overlap_warned = true;
615
+ data.overlap_warned_at = new Date().toISOString();
616
+ saveInjectedSet(data);
617
+ }
618
+
593
619
  // ── Active resumed thread tracking (V2.5) ─────────────────
594
620
 
595
621
  const _activeResumePath = () => path.join(_stickyDir(), ".sticky-active-resume");
@@ -673,6 +699,9 @@ module.exports = {
673
699
  getActiveResumeThreadId,
674
700
  setActiveResumeThreadId,
675
701
  clearActiveResumeThreadId,
702
+ // V2.6: overlap warning tracking
703
+ isOverlapWarned,
704
+ markOverlapWarned,
676
705
  // V2.5: cross-platform path helper
677
706
  normalizeSep,
678
707
  };