sticky-note-cli 2.9.2 → 3.1.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.
@@ -11,7 +11,6 @@ const { execFileSync } = require("child_process");
11
11
 
12
12
  // ── Cross-platform path normalization ──────────────────────
13
13
 
14
- // Normalize path separators to forward slashes for consistent storage/comparison.
15
14
  function normalizeSep(p) {
16
15
  return typeof p === "string" ? p.replace(/\\/g, "/") : p;
17
16
  }
@@ -35,11 +34,64 @@ const RETRY_PATTERNS = new RegExp(
35
34
 
36
35
  const FILE_PATH_PATTERN = /[\w./\\-]+\.\w{1,10}/g;
37
36
 
37
+ // ── Secret redaction (best-effort, applied before transcripts are persisted) ──
38
+ // This is a pattern-matching floor, not a guarantee — it catches common,
39
+ // recognizably-shaped secrets (cloud provider keys, tokens, private key
40
+ // blocks, labeled key=value pairs) before a transcript is written to
41
+ // sticky-note/data and pushed to the team's remote. It will not catch novel
42
+ // or oddly-formatted secrets. Teams handling especially sensitive sessions
43
+ // should still set `capture_transcripts: false` for those repos.
44
+ const REDACTION_PATTERNS = [
45
+ // AWS access key IDs
46
+ { name: "aws_access_key", re: /\bAKIA[0-9A-Z]{16}\b/g, replacement: "[REDACTED:aws_access_key]" },
47
+ // GitHub tokens (ghp_, gho_, ghu_, ghs_, ghr_)
48
+ { name: "github_token", re: /\bgh[pousr]_[A-Za-z0-9]{36,255}\b/g, replacement: "[REDACTED:github_token]" },
49
+ // Slack tokens
50
+ { name: "slack_token", re: /\bxox[baprs]-[A-Za-z0-9-]{10,72}\b/g, replacement: "[REDACTED:slack_token]" },
51
+ // PEM-style private key blocks
52
+ { name: "private_key_block", re: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g, replacement: "[REDACTED:private_key_block]" },
53
+ // Bearer tokens in headers/output
54
+ { name: "bearer_token", re: /\bBearer\s+[A-Za-z0-9\-_.~+/]+=*/gi, replacement: "Bearer [REDACTED:token]" },
55
+ // JWTs (three base64url segments)
56
+ { name: "jwt", re: /\bey[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g, replacement: "[REDACTED:jwt]" },
57
+ // Labeled secrets: api_key=..., token: "...", password=... etc. Keeps the
58
+ // label so the transcript still reads sensibly, redacts only the value.
59
+ //
60
+ // Deliberately no trailing ["']? here: transcripts are stored as raw JSONL
61
+ // text, so a value sitting right before a JSON string's closing quote is
62
+ // common ("...token: abc123"}) — an earlier version of this pattern also
63
+ // matched that closing quote and dropped it in the replacement, corrupting
64
+ // the line's JSON so it failed to parse back. The value class below
65
+ // ([^\s"'\n]{6,}) already excludes quotes, so it naturally stops just
66
+ // before one and the quote is left untouched in the output.
67
+ { name: "labeled_secret", re: /((?:api|secret|access)[_-]?key|token|password|passwd|pwd)(\s*[:=]\s*)["']?[^\s"'\n]{6,}/gi, replacement: "$1$2[REDACTED]" },
68
+ ];
69
+
70
+ function redactSecrets(text) {
71
+ if (!text || typeof text !== "string") return text;
72
+ let out = text;
73
+ for (const pattern of REDACTION_PATTERNS) {
74
+ out = out.replace(pattern.re, pattern.replacement);
75
+ }
76
+ return out;
77
+ }
78
+
38
79
  // ── Path helpers ──────────────────────────────────────────
39
80
 
81
+ let _cachedStickyDir = null;
40
82
  function _stickyDir() {
41
- const scriptDir = path.dirname(path.resolve(__filename));
42
- return path.join(scriptDir, "..", "..", ".sticky-note");
83
+ if (_cachedStickyDir) return _cachedStickyDir;
84
+ try {
85
+ const gitDir = execFileSync("git", ["rev-parse", "--absolute-git-dir"], {
86
+ encoding: "utf-8", timeout: 3000, stdio: ["pipe", "pipe", "pipe"],
87
+ }).trim();
88
+ _cachedStickyDir = path.resolve(gitDir, "sticky-note");
89
+ return _cachedStickyDir;
90
+ } catch (_) {
91
+ // Fallback for non-git environments (e.g., unit tests without git init)
92
+ const scriptDir = path.dirname(path.resolve(__filename));
93
+ return path.join(scriptDir, "..", "..", ".sticky-note");
94
+ }
43
95
  }
44
96
 
45
97
  // ── Error logging ─────────────────────────────────────────
@@ -81,9 +133,11 @@ function reportHookError(hookName, error, hint) {
81
133
  // the first time any hook (e.g. session-start, inject-context) successfully
82
134
  // loads, so the next session starts clean.
83
135
  //
84
- // The rewritten command always uses the relative form `node ".claude/hooks/<basename>"`,
85
- // matching what `templates/settings.json` ships today. We extract `<basename>`
86
- // from the existing command so we never invent hook scripts that don't exist.
136
+ // The rewritten command uses `npx sticky-note run-hook <basename>` which walks
137
+ // up the directory tree to find `.claude/hooks/<basename>.js`, so it works even
138
+ // when Claude Code is launched from a subdirectory of the project root.
139
+ // We extract `<basename>` from the existing command so we never invent hook
140
+ // scripts that don't exist.
87
141
  //
88
142
  // Returns the list of (hookEvent, oldCommand, newCommand) tuples that were
89
143
  // rewritten, or [] if nothing was stale.
@@ -93,19 +147,25 @@ function selfHealHookPaths(settingsPath) {
93
147
  if (!settingsPath) {
94
148
  settingsPath = path.join(process.cwd(), ".claude", "settings.json");
95
149
  }
96
- if (!fs.existsSync(settingsPath)) return result;
97
-
98
- const raw = fs.readFileSync(settingsPath, "utf-8");
150
+ let raw;
151
+ try {
152
+ raw = fs.readFileSync(settingsPath, "utf-8");
153
+ } catch (_) {
154
+ return result;
155
+ }
99
156
  let settings;
100
157
  try { settings = JSON.parse(raw); } catch (_) { return result; }
101
158
  if (!settings || !settings.hooks) return result;
102
159
 
103
- // Match the same shapes findStaleHookPaths() in bin/cli.js looks for:
104
- // any absolute path inside the command string, or a $(git rev-parse ...)
105
- // shell expansion that won't work on Windows / under Claude Code.
106
- const STALE = /["'][A-Za-z]:[\\/]|["']\/(?:Users|home|root|tmp)\/|\$\(git rev-parse/;
160
+ // Two stale patterns:
161
+ // 1. Absolute paths (written by older sticky-note-cli or copied from another machine)
162
+ // 2. Old relative form `node ".claude/hooks/foo.js"` — fails when Claude Code
163
+ // launches from a subdirectory, because the path resolves relative to the
164
+ // launch cwd, not the directory containing .claude/settings.json.
165
+ const STALE_ABSOLUTE = /["'][A-Za-z]:[\\/]|["']\/(?:Users|home|root|tmp)\/|\$\(git rev-parse/;
166
+ const STALE_RELATIVE = /^node\s+["']\.claude[\\/]hooks[\\/][^"']+\.js["']\s*$/;
107
167
  // Match the script basename in either quoted or bare form.
108
- const BASENAME = /([A-Za-z0-9_.-]+\.(?:js|sh|cjs|mjs))(?:["'`\s]|$)/;
168
+ const BASENAME = /([A-Za-z0-9_.-]+)\.(?:js|sh|cjs|mjs)(?:["'`\s]|$)/;
109
169
 
110
170
  let changed = false;
111
171
  for (const [event, entries] of Object.entries(settings.hooks)) {
@@ -113,11 +173,13 @@ function selfHealHookPaths(settingsPath) {
113
173
  for (const entry of entries) {
114
174
  for (const h of (entry && entry.hooks) || []) {
115
175
  const cmd = h && h.command;
116
- if (typeof cmd !== "string" || !STALE.test(cmd)) continue;
176
+ if (typeof cmd !== "string") continue;
177
+ const isStale = STALE_ABSOLUTE.test(cmd) || STALE_RELATIVE.test(cmd);
178
+ if (!isStale) continue;
117
179
  const m = cmd.match(BASENAME);
118
180
  if (!m) continue;
119
181
  const basename = m[1];
120
- const newCmd = `node ".claude/hooks/${basename}"`;
182
+ const newCmd = `npx sticky-note run-hook ${basename}`;
121
183
  result.push({ event, oldCommand: cmd, newCommand: newCmd });
122
184
  h.command = newCmd;
123
185
  changed = true;
@@ -130,10 +192,10 @@ function selfHealHookPaths(settingsPath) {
130
192
  fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n", "utf-8");
131
193
  try {
132
194
  const summary =
133
- `[STICKY-NOTE] ⚠️ Detected ${result.length} stale absolute hook path(s) in ` +
195
+ `[STICKY-NOTE] ⚠️ Detected ${result.length} stale hook path(s) in ` +
134
196
  `${path.relative(process.cwd(), settingsPath) || settingsPath} — rewrote to ` +
135
- `relative paths so hooks work on this machine. Commit the change to share ` +
136
- `the fix with your team.`;
197
+ `"npx sticky-note run-hook <name>" so hooks work from any subdirectory. ` +
198
+ `Commit the change to share the fix with your team.`;
137
199
  process.stderr.write(summary + "\n");
138
200
  for (const { event, oldCommand, newCommand } of result.slice(0, 5)) {
139
201
  process.stderr.write(`[STICKY-NOTE] ${event}: ${oldCommand} → ${newCommand}\n`);
@@ -150,6 +212,7 @@ const _paths = {
150
212
  config: "sticky-note-config.json",
151
213
  auditDir: "audit",
152
214
  presenceDir: "presence",
215
+ transcriptsDir: "transcripts",
153
216
  // Legacy single-file paths (for migration only)
154
217
  legacyAudit: "sticky-note-audit.jsonl",
155
218
  legacyPresence: ".sticky-presence.json",
@@ -166,6 +229,18 @@ function getPresenceDir() { return _p("presenceDir"); }
166
229
  function getLegacyAuditPath() { return _p("legacyAudit"); }
167
230
  function getLegacyPresencePath() { return _p("legacyPresence"); }
168
231
 
232
+ // ── Transcript storage (session-level, keyed by thread ID) ────
233
+ // Transcripts live alongside audit/presence in the sticky-note/data
234
+ // storage layer — one JSONL file per thread, one line per contributing
235
+ // session. Not commit-anchored (git notes) because a thread's transcript
236
+ // belongs to the whole session, not to any single commit it produced.
237
+
238
+ function getTranscriptsDir() { return _p("transcriptsDir"); }
239
+
240
+ function getTranscriptPath(threadId) {
241
+ return path.join(getTranscriptsDir(), threadId + ".jsonl");
242
+ }
243
+
169
244
  function getUserAuditPath(user) {
170
245
  if (!user) user = getUser();
171
246
  return path.join(getAuditDir(), user + ".jsonl");
@@ -230,14 +305,17 @@ function clearSessionFile() {
230
305
 
231
306
  // ── Git sync (auto-commit/push .sticky-note/ files) ──────
232
307
 
308
+ let _cachedGitRepoRoot = null; // null = not yet computed; "" = computed but git failed
233
309
  function _gitRepoRoot() {
310
+ if (_cachedGitRepoRoot !== null) return _cachedGitRepoRoot || null;
234
311
  try {
235
- return execFileSync("git", ["rev-parse", "--show-toplevel"], {
312
+ _cachedGitRepoRoot = execFileSync("git", ["rev-parse", "--show-toplevel"], {
236
313
  encoding: "utf-8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"],
237
314
  }).trim();
238
315
  } catch (_) {
239
- return null;
316
+ _cachedGitRepoRoot = "";
240
317
  }
318
+ return _cachedGitRepoRoot || null;
241
319
  }
242
320
 
243
321
  /**
@@ -252,7 +330,6 @@ function syncStickyNote(opts) {
252
330
  if (!root) { result.skipped = true; return result; }
253
331
 
254
332
  const syncGuard = path.join(_stickyDir(), ".sticky-syncing");
255
- if (fs.existsSync(syncGuard)) { result.skipped = true; return result; }
256
333
 
257
334
  // Check for dirty .sticky-note/ files (staged or unstaged)
258
335
  const status = execFileSync("git", [
@@ -262,8 +339,12 @@ function syncStickyNote(opts) {
262
339
 
263
340
  if (!status) { result.skipped = true; return result; }
264
341
 
265
- // Write guard to prevent recursive post-commit triggers
266
- fs.writeFileSync(syncGuard, process.pid.toString(), "utf-8");
342
+ // Write guard atomically to prevent recursive post-commit triggers
343
+ try {
344
+ fs.writeFileSync(syncGuard, process.pid.toString(), { flag: "wx", encoding: "utf-8" });
345
+ } catch (_) {
346
+ result.skipped = true; return result;
347
+ }
267
348
 
268
349
  try {
269
350
  // Stage the relevant files
@@ -496,9 +577,7 @@ function saveMemoryMerged(memoryPath, memory) {
496
577
  const lockPath = acquireLock(memoryPath);
497
578
  try {
498
579
  // Create backup before writing
499
- if (fs.existsSync(memoryPath)) {
500
- rotateBackup(memoryPath);
501
- }
580
+ rotateBackup(memoryPath);
502
581
 
503
582
  // Load merge helpers (field-level thread merge)
504
583
  let mergeOneThread2Way = null;
@@ -543,6 +622,17 @@ function appendAuditLine(entry) {
543
622
  fs.appendFileSync(filePath, JSON.stringify(entry) + "\n", "utf-8");
544
623
  }
545
624
 
625
+ /**
626
+ * Write an audit entry both to the local JSONL file and (fire-and-forget)
627
+ * to the cloud backend when cloud mode is active.
628
+ */
629
+ function appendAuditLineBoth(entry, cloud) {
630
+ try { appendAuditLine(entry); } catch (_) {}
631
+ if (cloud) {
632
+ cloudAppendAudit(entry).catch(() => {});
633
+ }
634
+ }
635
+
546
636
  // ── Environment helpers ───────────────────────────────────
547
637
 
548
638
  function getUser() {
@@ -593,16 +683,20 @@ function getSessionId(hookInput) {
593
683
  return "unknown";
594
684
  }
595
685
 
686
+ let _cachedBranch = null;
596
687
  function getBranch() {
688
+ if (_cachedBranch !== null) return _cachedBranch;
597
689
  try {
598
690
  const result = execFileSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
599
691
  encoding: "utf-8",
600
692
  timeout: 5000,
601
693
  stdio: ["pipe", "pipe", "pipe"],
602
694
  }).trim();
603
- return result;
695
+ _cachedBranch = result;
696
+ return _cachedBranch;
604
697
  } catch (_) {
605
- return "";
698
+ _cachedBranch = "";
699
+ return _cachedBranch;
606
700
  }
607
701
  }
608
702
 
@@ -1034,6 +1128,11 @@ module.exports = {
1034
1128
  ERROR_PATTERNS,
1035
1129
  RETRY_PATTERNS,
1036
1130
  FILE_PATH_PATTERN,
1131
+ // Transcript capture
1132
+ REDACTION_PATTERNS,
1133
+ redactSecrets,
1134
+ getTranscriptsDir,
1135
+ getTranscriptPath,
1037
1136
  logHookError,
1038
1137
  reportHookError,
1039
1138
  selfHealHookPaths,
@@ -1065,6 +1164,7 @@ module.exports = {
1065
1164
  saveJson,
1066
1165
  saveMemoryMerged,
1067
1166
  appendAuditLine,
1167
+ appendAuditLineBoth,
1068
1168
  getUser,
1069
1169
  detectTool,
1070
1170
  getSessionId,
@@ -1097,6 +1197,22 @@ module.exports = {
1097
1197
  markOverlapDismissed,
1098
1198
  // V2.5: cross-platform path helper
1099
1199
  normalizeSep,
1200
+ // V3: cloud transport (opt-in when STICKY_URL is set)
1201
+ useCloud,
1202
+ getCloudConfig,
1203
+ getProjectName,
1204
+ cloudFetch,
1205
+ cloudReadThreads,
1206
+ cloudReadThread,
1207
+ cloudWriteThread,
1208
+ cloudDeleteThread,
1209
+ cloudAppendAudit,
1210
+ cloudQueryAudit,
1211
+ cloudReadPresence,
1212
+ cloudWritePresence,
1213
+ cloudDeletePresence,
1214
+ cloudReadConfig,
1215
+ cloudWriteConfig,
1100
1216
  // V2.7: auto-sync
1101
1217
  syncStickyNote,
1102
1218
  // V2.9: Copilot CLI MCP config
@@ -1109,4 +1225,194 @@ module.exports = {
1109
1225
  acquireLock,
1110
1226
  releaseLock,
1111
1227
  };
1228
+
1229
+ // ── V3: Cloud transport layer ─────────────────────────────
1230
+
1231
+ function _loadEnvStickyFile() {
1232
+ try {
1233
+ const repoRoot = _gitRepoRoot();
1234
+ if (!repoRoot) return {};
1235
+ const envPath = path.join(repoRoot, ".env.sticky");
1236
+ let raw;
1237
+ try {
1238
+ raw = fs.readFileSync(envPath, "utf-8");
1239
+ } catch (_) {
1240
+ return {};
1241
+ }
1242
+ const vars = {};
1243
+ for (const line of raw.split(/\r?\n/)) {
1244
+ const trimmed = line.trim();
1245
+ if (!trimmed || trimmed.startsWith("#")) continue;
1246
+ const eq = trimmed.indexOf("=");
1247
+ if (eq > 0) {
1248
+ vars[trimmed.slice(0, eq).trim()] = trimmed.slice(eq + 1).trim();
1249
+ }
1250
+ }
1251
+ return vars;
1252
+ } catch (_) {
1253
+ return {};
1254
+ }
1255
+ }
1256
+
1257
+ let _envStickyCache = null;
1258
+ function _getEnvSticky() {
1259
+ if (_envStickyCache === null) {
1260
+ _envStickyCache = _loadEnvStickyFile();
1261
+ }
1262
+ return _envStickyCache;
1263
+ }
1264
+
1265
+ function getCloudConfig() {
1266
+ const envFile = _getEnvSticky();
1267
+ return {
1268
+ url: process.env.STICKY_URL || envFile.STICKY_URL || "",
1269
+ apiKey: process.env.STICKY_API_KEY || envFile.STICKY_API_KEY || "",
1270
+ };
1271
+ }
1272
+
1273
+ let _cachedUseCloud = null;
1274
+ function useCloud() {
1275
+ if (_cachedUseCloud !== null) return _cachedUseCloud;
1276
+ _cachedUseCloud = !!getCloudConfig().url;
1277
+ return _cachedUseCloud;
1278
+ }
1279
+
1280
+ let _cachedProjectName = null;
1281
+ function getProjectName() {
1282
+ if (_cachedProjectName !== null) return _cachedProjectName;
1283
+
1284
+ // Check config override first
1285
+ try {
1286
+ const config = loadJson(getConfigPath(), {});
1287
+ if (config.project) {
1288
+ _cachedProjectName = config.project;
1289
+ return _cachedProjectName;
1290
+ }
1291
+ } catch (_) {}
1292
+
1293
+ // Auto-detect from git remote
1294
+ try {
1295
+ const remote = execFileSync("git", ["remote", "get-url", "origin"], {
1296
+ encoding: "utf-8",
1297
+ timeout: 5000,
1298
+ stdio: ["pipe", "pipe", "pipe"],
1299
+ }).trim();
1300
+ // git@github.com:Owner/Repo.git → Owner/Repo
1301
+ // https://github.com/Owner/Repo.git → Owner/Repo
1302
+ const match = remote.match(/[:/]([^/]+\/[^/]+?)(?:\.git)?$/);
1303
+ if (match) {
1304
+ _cachedProjectName = match[1];
1305
+ return _cachedProjectName;
1306
+ }
1307
+ } catch (_) {}
1308
+
1309
+ _cachedProjectName = "default";
1310
+ return _cachedProjectName;
1311
+ }
1312
+
1313
+ let _cloudWarned = false;
1314
+ async function cloudFetch(method, endpoint, body) {
1315
+ if (typeof fetch === "undefined") {
1316
+ // Node < 18 — cloud transport not supported
1317
+ return null;
1318
+ }
1319
+ const { url, apiKey } = getCloudConfig();
1320
+ if (!url) return null;
1321
+
1322
+ const project = getProjectName();
1323
+ const headers = {
1324
+ "Content-Type": "application/json",
1325
+ "X-Sticky-Project": project,
1326
+ };
1327
+ if (apiKey) {
1328
+ headers["X-Sticky-API-Key"] = apiKey;
1329
+ }
1330
+
1331
+ const opts = { method, headers };
1332
+ if (body && (method === "POST" || method === "PUT")) {
1333
+ opts.body = JSON.stringify(body);
1334
+ }
1335
+
1336
+ try {
1337
+ const resp = await fetch(`${url}${endpoint}`, opts);
1338
+ if (!resp.ok) {
1339
+ return null;
1340
+ }
1341
+ const text = await resp.text();
1342
+ return text ? JSON.parse(text) : {};
1343
+ } catch (err) {
1344
+ if (!_cloudWarned) {
1345
+ _cloudWarned = true;
1346
+ process.stderr.write(
1347
+ "[STICKY-NOTE] Cloud unreachable, using local fallback\n"
1348
+ );
1349
+ }
1350
+ return null;
1351
+ }
1352
+ }
1353
+
1354
+ // ── Cloud: Threads ────────────────────────────────────────
1355
+
1356
+ async function cloudReadThreads(filters) {
1357
+ const params = new URLSearchParams();
1358
+ if (filters && filters.status) params.set("status", filters.status);
1359
+ if (filters && filters.q) params.set("q", filters.q);
1360
+ const qs = params.toString();
1361
+ const result = await cloudFetch("GET", `/threads${qs ? "?" + qs : ""}`);
1362
+ return result ? result.threads || [] : null;
1363
+ }
1364
+
1365
+ async function cloudReadThread(id) {
1366
+ return await cloudFetch("GET", `/threads/${id}`);
1367
+ }
1368
+
1369
+ async function cloudWriteThread(thread) {
1370
+ return await cloudFetch("PUT", `/threads/${thread.id}`, thread);
1371
+ }
1372
+
1373
+ async function cloudDeleteThread(id) {
1374
+ return await cloudFetch("DELETE", `/threads/${id}`);
1375
+ }
1376
+
1377
+ // ── Cloud: Audit ──────────────────────────────────────────
1378
+
1379
+ async function cloudAppendAudit(record) {
1380
+ return await cloudFetch("POST", "/audit", record);
1381
+ }
1382
+
1383
+ async function cloudQueryAudit(filters) {
1384
+ const params = new URLSearchParams();
1385
+ if (filters && filters.user) params.set("user", filters.user);
1386
+ if (filters && filters.file) params.set("file", filters.file);
1387
+ if (filters && filters.tool) params.set("tool", filters.tool);
1388
+ if (filters && filters.since) params.set("since", filters.since);
1389
+ const qs = params.toString();
1390
+ const result = await cloudFetch("GET", `/audit${qs ? "?" + qs : ""}`);
1391
+ return result ? result.audit || [] : null;
1392
+ }
1393
+
1394
+ // ── Cloud: Presence ───────────────────────────────────────
1395
+
1396
+ async function cloudReadPresence() {
1397
+ const result = await cloudFetch("GET", "/presence");
1398
+ return result ? result.presence || [] : null;
1399
+ }
1400
+
1401
+ async function cloudWritePresence(record) {
1402
+ return await cloudFetch("POST", "/presence", record);
1403
+ }
1404
+
1405
+ async function cloudDeletePresence(user) {
1406
+ return await cloudFetch("DELETE", `/presence/${encodeURIComponent(user)}`);
1407
+ }
1408
+
1409
+ // ── Cloud: Config ─────────────────────────────────────────
1410
+
1411
+ async function cloudReadConfig() {
1412
+ return await cloudFetch("GET", "/config");
1413
+ }
1414
+
1415
+ async function cloudWriteConfig(config) {
1416
+ return await cloudFetch("PUT", "/config", config);
1417
+ }
1112
1418
  // tier1 test
@@ -43,9 +43,12 @@ const {
43
43
  saveJson,
44
44
  saveMemoryMerged,
45
45
  appendAuditLine,
46
+ appendAuditLineBoth,
46
47
  getUser,
47
48
  getBranch,
48
49
  getSessionId,
50
+ useCloud,
51
+ cloudWritePresence,
49
52
  normalizeSep,
50
53
  } = utils;
51
54
 
@@ -227,6 +230,7 @@ function main() {
227
230
  }
228
231
 
229
232
  const sessionId = getSessionId(hookInput);
233
+ const cloud = useCloud();
230
234
 
231
235
  let toolName = hookInput.tool_name || "unknown";
232
236
  if (toolName === "unknown") {
@@ -282,27 +286,37 @@ function main() {
282
286
  if (checkpoint) {
283
287
  entry.checkpoint_topic = checkpoint.topic;
284
288
  }
285
- appendAuditLine(entry);
289
+ appendAuditLineBoth(entry, cloud);
286
290
 
287
291
  updatePresence(user, filePath);
292
+ if (cloud) {
293
+ cloudWritePresence({
294
+ user,
295
+ last_seen: now,
296
+ active_files: filePath ? [filePath] : [],
297
+ session_id: sessionId,
298
+ }).catch(() => {});
299
+ }
288
300
 
289
301
  // V2.5: Write Git Note for write tools
290
302
  if (isWriteTool && filePath) {
291
303
  writeEditNote(sessionId, user, filePath, lineRanges, checkpoint);
292
304
  }
293
305
 
294
- const serverName = autoDetectMcp(toolName);
295
- if (serverName) {
296
- const configPath = getConfigPath();
297
- const config = loadJson(configPath, { stale_days: 14, mcp_servers: [] });
298
- const mcpServers = config.mcp_servers || [];
299
- const knownNames = new Set(
300
- mcpServers.map((s) => (typeof s === "object" ? s.name : s))
301
- );
302
- if (!knownNames.has(serverName)) {
303
- mcpServers.push({ name: serverName, source: "auto-detected" });
304
- config.mcp_servers = mcpServers;
305
- saveJson(configPath, config);
306
+ if (toolName && toolName.startsWith("mcp__")) {
307
+ const serverName = autoDetectMcp(toolName);
308
+ if (serverName) {
309
+ const configPath = getConfigPath();
310
+ const config = loadJson(configPath, { stale_days: 14, mcp_servers: [] });
311
+ const mcpServers = config.mcp_servers || [];
312
+ const knownNames = new Set(
313
+ mcpServers.map((s) => (typeof s === "object" ? s.name : s))
314
+ );
315
+ if (!knownNames.has(serverName)) {
316
+ mcpServers.push({ name: serverName, source: "auto-detected" });
317
+ config.mcp_servers = mcpServers;
318
+ saveJson(configPath, config);
319
+ }
306
320
  }
307
321
  }
308
322
 
@@ -1,47 +1,47 @@
1
- {
2
- "version": 1,
3
- "hooks": {
4
- "sessionStart": [
5
- {
6
- "type": "command",
7
- "bash": "COPILOT_CLI=1 node \".claude/hooks/session-start.js\" --copilot-cli",
8
- "powershell": "$env:COPILOT_CLI='1'; node \".claude/hooks/session-start.js\" --copilot-cli"
9
- }
10
- ],
11
- "sessionEnd": [
12
- {
13
- "type": "command",
14
- "bash": "COPILOT_CLI=1 node \".claude/hooks/session-end.js\" --copilot-cli",
15
- "powershell": "$env:COPILOT_CLI='1'; node \".claude/hooks/session-end.js\" --copilot-cli"
16
- }
17
- ],
18
- "userPromptSubmitted": [
19
- {
20
- "type": "command",
21
- "bash": "COPILOT_CLI=1 node \".claude/hooks/inject-context.js\" --copilot-cli",
22
- "powershell": "$env:COPILOT_CLI='1'; node \".claude/hooks/inject-context.js\" --copilot-cli"
23
- }
24
- ],
25
- "preToolUse": [
26
- {
27
- "type": "command",
28
- "bash": "COPILOT_CLI=1 node \".claude/hooks/pre-tool-use.js\" --copilot-cli",
29
- "powershell": "$env:COPILOT_CLI='1'; node \".claude/hooks/pre-tool-use.js\" --copilot-cli"
30
- }
31
- ],
32
- "postToolUse": [
33
- {
34
- "type": "command",
35
- "bash": "COPILOT_CLI=1 node \".claude/hooks/track-work.js\" --copilot-cli",
36
- "powershell": "$env:COPILOT_CLI='1'; node \".claude/hooks/track-work.js\" --copilot-cli"
37
- }
38
- ],
39
- "errorOccurred": [
40
- {
41
- "type": "command",
42
- "bash": "COPILOT_CLI=1 node \".claude/hooks/on-error.js\" --copilot-cli",
43
- "powershell": "$env:COPILOT_CLI='1'; node \".claude/hooks/on-error.js\" --copilot-cli"
44
- }
45
- ]
46
- }
47
- }
1
+ {
2
+ "version": 1,
3
+ "hooks": {
4
+ "sessionStart": [
5
+ {
6
+ "type": "command",
7
+ "bash": "COPILOT_CLI=1 node \".claude/hooks/session-start.js\" --copilot-cli",
8
+ "powershell": "$env:COPILOT_CLI='1'; node \".claude/hooks/session-start.js\" --copilot-cli"
9
+ }
10
+ ],
11
+ "sessionEnd": [
12
+ {
13
+ "type": "command",
14
+ "bash": "COPILOT_CLI=1 node \".claude/hooks/session-end.js\" --copilot-cli",
15
+ "powershell": "$env:COPILOT_CLI='1'; node \".claude/hooks/session-end.js\" --copilot-cli"
16
+ }
17
+ ],
18
+ "userPromptSubmitted": [
19
+ {
20
+ "type": "command",
21
+ "bash": "COPILOT_CLI=1 node \".claude/hooks/inject-context.js\" --copilot-cli",
22
+ "powershell": "$env:COPILOT_CLI='1'; node \".claude/hooks/inject-context.js\" --copilot-cli"
23
+ }
24
+ ],
25
+ "preToolUse": [
26
+ {
27
+ "type": "command",
28
+ "bash": "COPILOT_CLI=1 node \".claude/hooks/pre-tool-use.js\" --copilot-cli",
29
+ "powershell": "$env:COPILOT_CLI='1'; node \".claude/hooks/pre-tool-use.js\" --copilot-cli"
30
+ }
31
+ ],
32
+ "postToolUse": [
33
+ {
34
+ "type": "command",
35
+ "bash": "COPILOT_CLI=1 node \".claude/hooks/track-work.js\" --copilot-cli",
36
+ "powershell": "$env:COPILOT_CLI='1'; node \".claude/hooks/track-work.js\" --copilot-cli"
37
+ }
38
+ ],
39
+ "errorOccurred": [
40
+ {
41
+ "type": "command",
42
+ "bash": "COPILOT_CLI=1 node \".claude/hooks/on-error.js\" --copilot-cli",
43
+ "powershell": "$env:COPILOT_CLI='1'; node \".claude/hooks/on-error.js\" --copilot-cli"
44
+ }
45
+ ]
46
+ }
47
+ }