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.
- package/README.md +94 -10
- package/bin/cli.js +1153 -174
- package/bin/mcp-server.js +218 -36
- package/package.json +1 -1
- package/templates/CLAUDE.md +23 -0
- package/templates/gitignore-additions.txt +3 -0
- package/templates/hooks/data-branch.js +256 -0
- package/templates/hooks/inject-context.js +26 -12
- package/templates/hooks/on-error.js +22 -9
- package/templates/hooks/on-stop.js +12 -7
- package/templates/hooks/pre-tool-use.js +49 -52
- package/templates/hooks/session-end.js +190 -21
- package/templates/hooks/session-start.js +81 -9
- package/templates/hooks/sticky-codex.ps1 +20 -4
- package/templates/hooks/sticky-codex.sh +14 -5
- package/templates/hooks/sticky-utils.js +335 -29
- package/templates/hooks/track-work.js +27 -13
- package/templates/hooks.json +47 -47
- package/templates/settings.json +81 -81
- package/templates/sticky-note-config.json +2 -0
- package/templates/sticky-note-install.yml +80 -0
- package/templates/sticky-note.json +5 -5
|
@@ -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
|
-
|
|
42
|
-
|
|
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
|
|
85
|
-
//
|
|
86
|
-
//
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
//
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
|
|
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_.-]
|
|
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"
|
|
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 = `
|
|
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
|
|
195
|
+
`[STICKY-NOTE] ⚠️ Detected ${result.length} stale hook path(s) in ` +
|
|
134
196
|
`${path.relative(process.cwd(), settingsPath) || settingsPath} — rewrote to ` +
|
|
135
|
-
`
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
695
|
+
_cachedBranch = result;
|
|
696
|
+
return _cachedBranch;
|
|
604
697
|
} catch (_) {
|
|
605
|
-
|
|
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
|
-
|
|
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
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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
|
|
package/templates/hooks.json
CHANGED
|
@@ -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
|
+
}
|