@synkro-sh/cli 1.6.23 → 1.6.25

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/dist/bootstrap.js CHANGED
@@ -389,6 +389,20 @@ function installCursorHooks(hooksJsonPath, config) {
389
389
  timeout: 10,
390
390
  matcher: "Shell|Bash|terminal|run_terminal_cmd|execute_command|delete_file"
391
391
  });
392
+ h.afterAgentThought = h.afterAgentThought ?? [];
393
+ h.afterAgentThought.push({
394
+ command: bunRunCmd(config.agentCaptureScriptPath),
395
+ timeout: 5,
396
+ failClosed: false,
397
+ [SYNKRO_MARKER2]: true
398
+ });
399
+ h.afterAgentResponse = h.afterAgentResponse ?? [];
400
+ h.afterAgentResponse.push({
401
+ command: bunRunCmd(config.agentCaptureScriptPath),
402
+ timeout: 5,
403
+ failClosed: false,
404
+ [SYNKRO_MARKER2]: true
405
+ });
392
406
  writeHooksFileAtomic(hooksJsonPath, file);
393
407
  }
394
408
  function uninstallCursorHooks(hooksJsonPath) {
@@ -431,7 +445,9 @@ var init_cursorHookConfig = __esm({
431
445
  "afterShellExecution",
432
446
  "preToolUse",
433
447
  "afterFileEdit",
434
- "postToolUse"
448
+ "postToolUse",
449
+ "afterAgentThought",
450
+ "afterAgentResponse"
435
451
  ];
436
452
  }
437
453
  });
@@ -751,7 +767,7 @@ synkro_post_with_retry() {
751
767
  });
752
768
 
753
769
  // cli/installer/hookScriptsTs.ts
754
- var SYNKRO_COMMON_TS, EDIT_PRECHECK_TS, CWE_PRECHECK_TS, CVE_PRECHECK_TS, INSTALL_SCAN_TS, BASH_JUDGE_TS, AGENT_JUDGE_TS, PLAN_JUDGE_TS, STOP_SUMMARY_TS, SESSION_START_TS, BASH_FOLLOWUP_TS, TRANSCRIPT_SYNC_TS, USER_PROMPT_SUBMIT_TS, CURSOR_BASH_JUDGE_TS, CURSOR_EDIT_CAPTURE_TS;
770
+ var SYNKRO_COMMON_TS, EDIT_PRECHECK_TS, CWE_PRECHECK_TS, CVE_PRECHECK_TS, INSTALL_SCAN_TS, BASH_JUDGE_TS, AGENT_JUDGE_TS, PLAN_JUDGE_TS, STOP_SUMMARY_TS, SESSION_START_TS, BASH_FOLLOWUP_TS, TRANSCRIPT_SYNC_TS, USER_PROMPT_SUBMIT_TS, CURSOR_BASH_JUDGE_TS, CURSOR_EDIT_CAPTURE_TS, CURSOR_AGENT_CAPTURE_TS;
755
771
  var init_hookScriptsTs = __esm({
756
772
  "cli/installer/hookScriptsTs.ts"() {
757
773
  "use strict";
@@ -1474,15 +1490,21 @@ export async function runInstallScan(command: string, jwt: string): Promise<Inst
1474
1490
  .flatMap((p: any) => (p.signals || []))
1475
1491
  .filter((s: any) => s.type === 'cve').length;
1476
1492
  const findings: InstallScanResult['findings'] = [];
1493
+ const seenFindingKeys = new Set<string>();
1477
1494
  for (const p of pkgResults) {
1478
1495
  for (const s of (p.signals || [])) {
1479
- if (s.severity === 'critical' || s.severity === 'high') {
1480
- const advisoryMatch = (s.detail || '').match(/\\b(GHSA-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}|CVE-\\d{4}-\\d+)\\b/i);
1481
- findings.push({
1482
- advisoryId: advisoryMatch ? advisoryMatch[1] : s.type,
1483
- name: p.name, version: p.version, severity: s.severity, detail: s.detail,
1484
- });
1485
- }
1496
+ const advisoryMatch = (s.detail || '').match(/\\b(GHSA-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}|CVE-\\d{4}-\\d+)\\b/i);
1497
+ const advisoryId = advisoryMatch ? advisoryMatch[1] : (s.type === 'cve' ? 'pkg:' + p.name : (s.type || 'signal'));
1498
+ const key = advisoryId + ':' + p.name + '@' + p.version;
1499
+ if (seenFindingKeys.has(key)) continue;
1500
+ seenFindingKeys.add(key);
1501
+ findings.push({
1502
+ advisoryId,
1503
+ name: p.name,
1504
+ version: p.version,
1505
+ severity: s.severity || 'high',
1506
+ detail: s.detail || summary,
1507
+ });
1486
1508
  }
1487
1509
  }
1488
1510
  // Preview the top 5 detail lines; the headline carries the true total.
@@ -1741,11 +1763,13 @@ export function dispatchCapture(
1741
1763
  violatedRules?: string[];
1742
1764
  recentUserMessages?: string[];
1743
1765
  ccModel?: string;
1766
+ linesAdded?: number;
1767
+ linesRemoved?: number;
1744
1768
  },
1745
1769
  ): void {
1746
1770
  // Fire-and-forget
1747
1771
  const eventId = 'evt_' + Date.now() + '_' + process.pid;
1748
- const model = opts?.ccModel || 'unknown';
1772
+ const model = normalizeCaptureModel(opts?.ccModel || 'unknown');
1749
1773
 
1750
1774
  const body: Record<string, any> = {
1751
1775
  capture_type: 'local_verdict',
@@ -1774,6 +1798,8 @@ export function dispatchCapture(
1774
1798
  if (opts.rulesChecked) localBody.rules_checked = opts.rulesChecked;
1775
1799
  if (opts.violatedRules) localBody.violated_rules = opts.violatedRules;
1776
1800
  if (opts.recentUserMessages) localBody.recent_user_messages = opts.recentUserMessages;
1801
+ if (opts.linesAdded != null) localBody.lines_added = opts.linesAdded;
1802
+ if (opts.linesRemoved != null) localBody.lines_removed = opts.linesRemoved;
1777
1803
  }
1778
1804
  appendLocalTelemetry(localBody);
1779
1805
 
@@ -1934,7 +1960,7 @@ export function reconstructContent(toolName: string, toolInput: any, filePath: s
1934
1960
  case 'apply_patch':
1935
1961
  return contentFromPatch(patchTextFromToolInput(toolInput));
1936
1962
  case 'Write':
1937
- return toolInput.content || '';
1963
+ return toolInput.content || toolInput.contents || '';
1938
1964
  case 'edit_file':
1939
1965
  case 'reapply':
1940
1966
  return toolInput.content || toolInput.new_string || toolInput.code_edit || '';
@@ -2028,7 +2054,303 @@ export async function readStdin(): Promise<string> {
2028
2054
  return Buffer.concat(chunks).toString('utf-8');
2029
2055
  }
2030
2056
 
2031
- // \u2500\u2500\u2500 Transcript Extraction \u2500\u2500\u2500
2057
+ // \u2500\u2500\u2500 Transcript path + message extraction (CC + Cursor) \u2500\u2500\u2500
2058
+
2059
+ /** Cursor stores transcripts under ~/.cursor/projects/{slug}/agent-transcripts/ */
2060
+ export function cursorProjectSlug(workspaceRoot: string): string {
2061
+ return workspaceRoot.replace(/^/+/, '').replace(/[^a-zA-Z0-9]+/g, '-').replace(/^-+|-+$/g, '');
2062
+ }
2063
+
2064
+ /** Resolve transcript JSONL \u2014 payload field, CURSOR_TRANSCRIPT_PATH, or Cursor agent-transcripts dir. */
2065
+ export function resolveTranscriptPath(payload: Record<string, unknown>): string {
2066
+ const explicit = typeof payload.transcript_path === 'string' ? payload.transcript_path.trim() : '';
2067
+ if (explicit && existsSync(explicit)) return explicit;
2068
+
2069
+ const fromEnv = (process.env.CURSOR_TRANSCRIPT_PATH || '').trim();
2070
+ if (fromEnv && existsSync(fromEnv)) return fromEnv;
2071
+
2072
+ if (!isCursorHookFormat()) return explicit || fromEnv || '';
2073
+
2074
+ const sessionId = hookSessionId(payload);
2075
+ const workspaceRoots = Array.isArray(payload.workspace_roots)
2076
+ ? (payload.workspace_roots as unknown[]).filter((r): r is string => typeof r === 'string')
2077
+ : [];
2078
+ const cwd = (typeof payload.cwd === 'string' && payload.cwd)
2079
+ || workspaceRoots[0]
2080
+ || (process.env.CURSOR_PROJECT_DIR || '');
2081
+ if (!sessionId || !cwd) return explicit || fromEnv || '';
2082
+
2083
+ const base = join(HOME, '.cursor', 'projects', cursorProjectSlug(cwd), 'agent-transcripts');
2084
+ const nested = join(base, sessionId, sessionId + '.jsonl');
2085
+ if (existsSync(nested)) return nested;
2086
+ if (existsSync(join(base, sessionId))) return nested;
2087
+ const flat = join(base, sessionId + '.jsonl');
2088
+ if (existsSync(flat)) return flat;
2089
+
2090
+ return explicit || fromEnv || '';
2091
+ }
2092
+
2093
+ function transcriptEntryType(entry: Record<string, unknown>): string {
2094
+ return String(entry.type || entry.role || '');
2095
+ }
2096
+
2097
+ function transcriptContentBlock(entry: Record<string, unknown>): Record<string, unknown> | null {
2098
+ const msg = entry.message as Record<string, unknown> | string | undefined;
2099
+ const content = (msg && typeof msg === 'object' ? msg.content : undefined) ?? entry.content;
2100
+ if (content && typeof content === 'object' && !Array.isArray(content)) {
2101
+ return content as Record<string, unknown>;
2102
+ }
2103
+ return null;
2104
+ }
2105
+
2106
+ function isProviderRedactedPlaceholder(text: string): boolean {
2107
+ const t = text.trim();
2108
+ if (!t) return false;
2109
+ return t === '[REDACTED]' || /^\\[REDACTED\\](\\s*\\[REDACTED\\])*$/.test(t);
2110
+ }
2111
+
2112
+ function extractContentBlockText(block: Record<string, unknown>): string {
2113
+ const type = String(block.type || '');
2114
+ if (type === 'text' && typeof block.text === 'string') return block.text;
2115
+ if ((type === 'thinking' || type === 'redacted_thinking') && typeof block.thinking === 'string') {
2116
+ return block.thinking;
2117
+ }
2118
+ if (type === 'tool_result') {
2119
+ const c = block.content;
2120
+ if (typeof c === 'string') return c;
2121
+ if (Array.isArray(c)) {
2122
+ return c.map((part: unknown) => {
2123
+ if (typeof part === 'string') return part;
2124
+ const p = part as Record<string, unknown>;
2125
+ return typeof p.text === 'string' ? p.text : '';
2126
+ }).filter(Boolean).join('\\n');
2127
+ }
2128
+ return '';
2129
+ }
2130
+ return '';
2131
+ }
2132
+
2133
+ function thoughtOverlayPath(sessionId: string): string {
2134
+ return join(HOME, '.synkro', 'sessions', sessionId, 'thought-overlay.jsonl');
2135
+ }
2136
+
2137
+ /** Cursor afterAgentThought delivers full thinking; transcript JSONL stores [REDACTED]. Queue for transcript sync. */
2138
+ export function appendThoughtOverlay(sessionId: string, text: string): void {
2139
+ const trimmed = text.trim();
2140
+ if (!sessionId || !trimmed || isProviderRedactedPlaceholder(trimmed)) return;
2141
+ const path = thoughtOverlayPath(sessionId);
2142
+ mkdirSync(dirname(path), { recursive: true });
2143
+ appendFileSync(path, JSON.stringify({ text: trimmed, ts: Date.now() }) + '\\n', 'utf-8');
2144
+ }
2145
+
2146
+ function consumeThoughtOverlay(sessionId: string): string {
2147
+ const path = thoughtOverlayPath(sessionId);
2148
+ if (!existsSync(path)) return '';
2149
+ try {
2150
+ const lines = readFileSync(path, 'utf-8').split('\\n').filter(l => l.trim());
2151
+ if (lines.length === 0) return '';
2152
+ const first = JSON.parse(lines[0]) as { text?: string };
2153
+ const rest = lines.slice(1).join('\\n');
2154
+ if (rest.trim()) writeFileSync(path, rest + (rest.endsWith('\\n') ? '' : '\\n'), 'utf-8');
2155
+ else unlinkSync(path);
2156
+ const text = typeof first.text === 'string' ? first.text.trim() : '';
2157
+ return text && !isProviderRedactedPlaceholder(text) ? text : '';
2158
+ } catch {
2159
+ return '';
2160
+ }
2161
+ }
2162
+
2163
+ /** Push a single conversation turn (used by Cursor afterAgentThought/afterAgentResponse hooks). */
2164
+ export async function pushConversationMessage(
2165
+ sessionId: string,
2166
+ role: 'user' | 'assistant',
2167
+ content: string,
2168
+ opts: { gitRepo?: string; patchRedacted?: boolean; seq?: number } = {},
2169
+ ): Promise<boolean> {
2170
+ const text = content.trim().slice(0, 8000);
2171
+ if (!sessionId || !text || isProviderRedactedPlaceholder(text)) return false;
2172
+
2173
+ const mcpPort = process.env.SYNKRO_MCP_PORT || '18931';
2174
+ let mcpToken = '';
2175
+ try { mcpToken = readFileSync(join(HOME, '.synkro', '.mcp-jwt'), 'utf-8').trim(); } catch {}
2176
+ if (!mcpToken) return false;
2177
+
2178
+ const seq = typeof opts.seq === 'number'
2179
+ ? opts.seq
2180
+ : Date.now() % 1_000_000_000;
2181
+
2182
+ try {
2183
+ const body: Record<string, unknown> = {
2184
+ session_id: sessionId,
2185
+ repo: opts.gitRepo || '',
2186
+ messages: [{
2187
+ type: role,
2188
+ content: text,
2189
+ ts: new Date().toISOString(),
2190
+ patch_redacted: opts.patchRedacted ?? true,
2191
+ }],
2192
+ };
2193
+ if (!opts.patchRedacted) {
2194
+ (body.messages as Record<string, unknown>[])[0].message_index = seq;
2195
+ }
2196
+ const resp = await fetch('http://127.0.0.1:' + mcpPort + '/api/conversation-sync', {
2197
+ method: 'POST',
2198
+ headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + mcpToken },
2199
+ body: JSON.stringify(body),
2200
+ signal: AbortSignal.timeout(5000),
2201
+ });
2202
+ return resp.ok;
2203
+ } catch {
2204
+ return false;
2205
+ }
2206
+ }
2207
+
2208
+ function extractTranscriptEntryText(
2209
+ entry: Record<string, unknown>,
2210
+ maxLen = 8000,
2211
+ sessionId = '',
2212
+ ): string {
2213
+ const block = transcriptContentBlock(entry);
2214
+ if (block) {
2215
+ const type = String(block.type || '');
2216
+ if (type === 'tool_use' || type === 'tool_call') return '';
2217
+ let text = extractContentBlockText(block);
2218
+ if (isProviderRedactedPlaceholder(text) && sessionId) {
2219
+ const overlay = consumeThoughtOverlay(sessionId);
2220
+ if (overlay) text = overlay;
2221
+ }
2222
+ return text ? text.slice(0, maxLen) : '';
2223
+ }
2224
+
2225
+ const msg = entry.message as Record<string, unknown> | string | undefined;
2226
+ const content = (msg && typeof msg === 'object' ? msg.content : undefined) ?? entry.content;
2227
+ if (typeof content === 'string') {
2228
+ let text = content;
2229
+ if (isProviderRedactedPlaceholder(text) && sessionId) {
2230
+ const overlay = consumeThoughtOverlay(sessionId);
2231
+ if (overlay) text = overlay;
2232
+ }
2233
+ return text ? text.slice(0, maxLen) : '';
2234
+ }
2235
+ if (Array.isArray(content)) {
2236
+ const text = content.map((c: unknown) => {
2237
+ if (typeof c === 'string') return c;
2238
+ const b = c as Record<string, unknown>;
2239
+ const type = String(b?.type || '');
2240
+ if (type === 'tool_use' || type === 'tool_call') return '';
2241
+ return extractContentBlockText(b);
2242
+ }).filter(Boolean).join('\\n\\n').trim();
2243
+ if (!text) return '';
2244
+ if (isProviderRedactedPlaceholder(text) && sessionId) {
2245
+ const overlay = consumeThoughtOverlay(sessionId);
2246
+ if (overlay) return overlay.slice(0, maxLen);
2247
+ }
2248
+ return text.slice(0, maxLen);
2249
+ }
2250
+ if (typeof msg === 'string') return msg.slice(0, maxLen);
2251
+ return '';
2252
+ }
2253
+
2254
+ /** Offset-tracked ingest of new user/assistant turns into PGLite conversation_messages. */
2255
+ export async function syncConversationTranscript(
2256
+ sessionId: string,
2257
+ transcriptPath: string,
2258
+ gitRepo = '',
2259
+ ): Promise<{ ingested: number; messages: Array<Record<string, unknown>> }> {
2260
+ if (!sessionId || !transcriptPath || !existsSync(transcriptPath)) return { ingested: 0, messages: [] };
2261
+
2262
+ const offsetDir = join(HOME, '.synkro', '.transcript-offsets');
2263
+ mkdirSync(offsetDir, { recursive: true });
2264
+ const offsetFile = join(offsetDir, sessionId);
2265
+ let offset = 0;
2266
+ if (existsSync(offsetFile)) {
2267
+ try { offset = parseInt(readFileSync(offsetFile, 'utf-8').trim(), 10) || 0; } catch {}
2268
+ }
2269
+
2270
+ const raw = readFileSync(transcriptPath, 'utf-8');
2271
+ const allLines = raw.split('\\n').filter(l => l.trim());
2272
+ const totalLines = allLines.length;
2273
+ if (totalLines <= offset) return { ingested: 0, messages: [] };
2274
+
2275
+ let startIdx = offset;
2276
+ const delta = totalLines - offset;
2277
+ if (delta > 200) startIdx = totalLines - 200;
2278
+
2279
+ const messages: Array<Record<string, unknown>> = [];
2280
+ for (let i = startIdx; i < totalLines; i++) {
2281
+ try {
2282
+ const entry = JSON.parse(allLines[i]) as Record<string, unknown>;
2283
+ const kind = transcriptEntryType(entry);
2284
+ if (kind !== 'user' && kind !== 'assistant') continue;
2285
+ const text = extractTranscriptEntryText(entry, 8000, sessionId);
2286
+ if (!text) continue;
2287
+
2288
+ const msgObj = entry.message as Record<string, unknown> | undefined;
2289
+ const content = (msgObj && typeof msgObj === 'object' ? msgObj.content : undefined) ?? entry.content;
2290
+ const singleBlock = transcriptContentBlock(entry);
2291
+ const msg: Record<string, unknown> = {
2292
+ message_index: i,
2293
+ type: kind,
2294
+ content: text,
2295
+ ts: entry.timestamp || null,
2296
+ };
2297
+ if (kind === 'assistant') {
2298
+ const blocks = Array.isArray(content)
2299
+ ? content
2300
+ : (singleBlock ? [singleBlock] : []);
2301
+ const toolCalls = blocks
2302
+ .filter((c: unknown) => {
2303
+ const b = c as Record<string, unknown>;
2304
+ return b?.type === 'tool_use' || b?.type === 'tool_call';
2305
+ })
2306
+ .map((c: unknown) => {
2307
+ const b = c as Record<string, unknown>;
2308
+ return {
2309
+ name: b.name,
2310
+ input: JSON.stringify(b.input || b.arguments || {}).slice(0, 500),
2311
+ id: b.id,
2312
+ };
2313
+ });
2314
+ if (toolCalls.length > 0) msg.tool_calls = toolCalls;
2315
+ if (msgObj && typeof msgObj === 'object') {
2316
+ msg.model = msgObj.model || null;
2317
+ const u = msgObj.usage as Record<string, unknown> | undefined;
2318
+ if (u) {
2319
+ msg.usage = {
2320
+ input_tokens: u.input_tokens,
2321
+ output_tokens: u.output_tokens,
2322
+ cache_creation_input_tokens: u.cache_creation_input_tokens,
2323
+ cache_read_input_tokens: u.cache_read_input_tokens,
2324
+ };
2325
+ }
2326
+ }
2327
+ }
2328
+ messages.push(msg);
2329
+ } catch {}
2330
+ }
2331
+
2332
+ writeFileSync(offsetFile, String(totalLines), 'utf-8');
2333
+ if (messages.length === 0) return { ingested: 0, messages: [] };
2334
+
2335
+ const mcpPort = process.env.SYNKRO_MCP_PORT || '18931';
2336
+ let mcpToken = '';
2337
+ try { mcpToken = readFileSync(join(HOME, '.synkro', '.mcp-jwt'), 'utf-8').trim(); } catch {}
2338
+ if (!mcpToken) return { ingested: 0, messages };
2339
+
2340
+ try {
2341
+ const resp = await fetch('http://127.0.0.1:' + mcpPort + '/api/conversation-sync', {
2342
+ method: 'POST',
2343
+ headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + mcpToken },
2344
+ body: JSON.stringify({ session_id: sessionId, repo: gitRepo, messages }),
2345
+ signal: AbortSignal.timeout(5000),
2346
+ });
2347
+ if (resp.ok) {
2348
+ const data = await resp.json() as { ingested?: number };
2349
+ return { ingested: data.ingested ?? messages.length, messages };
2350
+ }
2351
+ } catch {}
2352
+ return { ingested: 0, messages };
2353
+ }
2032
2354
 
2033
2355
  export interface TranscriptContext {
2034
2356
  userIntent: string;
@@ -2067,11 +2389,8 @@ export function extractTranscript(transcriptPath: string | undefined): Transcrip
2067
2389
  // Recent user messages (last 5)
2068
2390
  const userMsgs: string[] = [];
2069
2391
  for (const entry of parsed) {
2070
- if (entry.type !== 'user') continue;
2071
- const content = entry.message?.content;
2072
- let text = '';
2073
- if (typeof content === 'string') text = content;
2074
- else if (Array.isArray(content)) text = content.map((c: any) => c.text || '').join(' ');
2392
+ if (transcriptEntryType(entry) !== 'user') continue;
2393
+ const text = extractTranscriptEntryText(entry);
2075
2394
  if (text) userMsgs.push(text);
2076
2395
  }
2077
2396
  ctx.recentUserMessages = userMsgs.slice(-5);
@@ -2080,26 +2399,25 @@ export function extractTranscript(transcriptPath: string | undefined): Transcrip
2080
2399
  // Recent messages (last 10, user + assistant)
2081
2400
  const msgs: Array<{ type: string; text: string }> = [];
2082
2401
  for (const entry of parsed) {
2083
- if (entry.type !== 'user' && entry.type !== 'assistant') continue;
2084
- const content = entry.message?.content;
2085
- let text = '';
2086
- if (typeof content === 'string') text = content.slice(0, 500);
2087
- else if (Array.isArray(content)) text = content.map((c: any) => (c.text || '').slice(0, 300)).join(' ');
2088
- msgs.push({ type: entry.type, text });
2402
+ const kind = transcriptEntryType(entry);
2403
+ if (kind !== 'user' && kind !== 'assistant') continue;
2404
+ const text = extractTranscriptEntryText(entry, 500);
2405
+ if (text) msgs.push({ type: kind, text });
2089
2406
  }
2090
2407
  ctx.recentMessages = msgs.slice(-10);
2091
2408
 
2092
2409
  // Recent tool calls (last 5)
2093
2410
  const actions: Array<{ tool: string; input: string }> = [];
2094
2411
  for (const entry of parsed) {
2095
- if (entry.type !== 'assistant') continue;
2096
- const content = entry.message?.content;
2412
+ if (transcriptEntryType(entry) !== 'assistant') continue;
2413
+ const msg = entry.message;
2414
+ const content = msg?.content ?? entry.content;
2097
2415
  if (!Array.isArray(content)) continue;
2098
2416
  for (const block of content) {
2099
- if (block.type !== 'tool_use') continue;
2417
+ if (block.type !== 'tool_use' && block.type !== 'tool_call') continue;
2100
2418
  actions.push({
2101
2419
  tool: block.name || '',
2102
- input: JSON.stringify(block.input || {}).slice(0, 200),
2420
+ input: JSON.stringify(block.input || block.arguments || {}).slice(0, 200),
2103
2421
  });
2104
2422
  }
2105
2423
  }
@@ -2113,7 +2431,7 @@ export function extractTranscript(transcriptPath: string | undefined): Transcrip
2113
2431
  }
2114
2432
 
2115
2433
  // CC model
2116
- const assistantEntries = parsed.filter(e => e.type === 'assistant');
2434
+ const assistantEntries = parsed.filter(e => transcriptEntryType(e) === 'assistant');
2117
2435
  if (assistantEntries.length > 0) {
2118
2436
  const last = assistantEntries[assistantEntries.length - 1];
2119
2437
  ctx.ccModel = last.message?.model || '';
@@ -2208,39 +2526,254 @@ export function hashCommand(cmd: string): string {
2208
2526
  return createHash('sha256').update(cmd).digest('hex').slice(0, 16);
2209
2527
  }
2210
2528
 
2211
- // \u2500\u2500\u2500 Transcript Usage Aggregation \u2500\u2500\u2500
2529
+ // \u2500\u2500\u2500 Edit line delta + transcript usage \u2500\u2500\u2500
2530
+
2531
+ export function countTextLines(text: string): number {
2532
+ if (!text) return 0;
2533
+ return text.split('\\n').length;
2534
+ }
2535
+
2536
+ /** Count lines added/removed from a write, search-replace, or multi-edit payload. */
2537
+ export function countEditLineDelta(source: {
2538
+ writeContent?: string;
2539
+ /** When set, Write counts net delta vs the file on disk instead of full file length. */
2540
+ existingContent?: string;
2541
+ old_string?: string;
2542
+ new_string?: string;
2543
+ edits?: Array<{ old_string?: string; new_string?: string }>;
2544
+ }): { linesAdded: number; linesRemoved: number } {
2545
+ let linesAdded = 0;
2546
+ let linesRemoved = 0;
2547
+ if (source.writeContent != null && source.writeContent !== '') {
2548
+ const newLines = countTextLines(String(source.writeContent));
2549
+ if (source.existingContent != null && source.existingContent !== '') {
2550
+ const oldLines = countTextLines(String(source.existingContent));
2551
+ return {
2552
+ linesAdded: Math.max(0, newLines - oldLines),
2553
+ linesRemoved: Math.max(0, oldLines - newLines),
2554
+ };
2555
+ }
2556
+ return { linesAdded: newLines, linesRemoved: 0 };
2557
+ }
2558
+ if (source.old_string != null && source.new_string != null) {
2559
+ const oldLines = countTextLines(String(source.old_string));
2560
+ const newLines = countTextLines(String(source.new_string));
2561
+ return {
2562
+ linesAdded: Math.max(0, newLines - oldLines),
2563
+ linesRemoved: Math.max(0, oldLines - newLines),
2564
+ };
2565
+ }
2566
+ if (Array.isArray(source.edits)) {
2567
+ for (const e of source.edits) {
2568
+ if (!e || typeof e !== 'object') continue;
2569
+ const oldLines = countTextLines(String(e.old_string || ''));
2570
+ const newLines = countTextLines(String(e.new_string || ''));
2571
+ linesAdded += Math.max(0, newLines - oldLines);
2572
+ linesRemoved += Math.max(0, oldLines - newLines);
2573
+ }
2574
+ }
2575
+ return { linesAdded, linesRemoved };
2576
+ }
2577
+
2578
+ /** Normalize model names so the same Cursor session doesn't split across agent buckets. */
2579
+ export function normalizeCaptureModel(model: string): string {
2580
+ const m = (model || '').trim();
2581
+ if (!m || m === 'unknown') return 'unknown';
2582
+ if (m.startsWith('cursor/') || m.startsWith('claude-') || m.startsWith('gpt-') || m.startsWith('gemini-')) return m;
2583
+ if (/^composer/i.test(m) || m === 'auto' || m === 'default') return 'cursor/' + m;
2584
+ return m;
2585
+ }
2212
2586
 
2213
- export function aggregateUsage(transcriptPath: string): { model: string; totals: Record<string, number> } {
2214
- const result = { model: '', totals: { in: 0, out: 0, cw: 0, cr: 0 } };
2587
+ /** Line metrics for guard_checks \u2014 Cursor Edit/StrReplace post deltas via afterFileEdit. */
2588
+ export function captureLineMetrics(
2589
+ agentKind: 'cursor' | 'claude_code',
2590
+ toolName: string,
2591
+ linesAdded: number,
2592
+ linesRemoved: number,
2593
+ ): { linesAdded?: number; linesRemoved?: number } {
2594
+ if (agentKind === 'cursor' && toolName !== 'Write') return {};
2595
+ return { linesAdded, linesRemoved };
2596
+ }
2597
+
2598
+ function usageTextFromTranscriptEntry(entry: Record<string, unknown>): string {
2599
+ const msg = entry.message as Record<string, unknown> | undefined;
2600
+ const content = (msg && typeof msg === 'object' ? msg.content : undefined) ?? entry.content;
2601
+ if (typeof content === 'string') return content;
2602
+ if (content && typeof content === 'object' && !Array.isArray(content)) {
2603
+ const block = content as Record<string, unknown>;
2604
+ if (block.type === 'text' && typeof block.text === 'string') return block.text;
2605
+ if (typeof block.text === 'string') return block.text;
2606
+ return '';
2607
+ }
2608
+ if (!Array.isArray(content)) return '';
2609
+ return content.map((c: unknown) => {
2610
+ if (typeof c === 'string') return c;
2611
+ const b = c as Record<string, unknown>;
2612
+ if (b?.type === 'text' && typeof b.text === 'string') return b.text;
2613
+ if (typeof b?.text === 'string') return b.text;
2614
+ if (b?.type === 'tool_use' || b?.type === 'tool_call') {
2615
+ return JSON.stringify(b.input || b.arguments || {});
2616
+ }
2617
+ return '';
2618
+ }).join('\\n');
2619
+ }
2620
+
2621
+ function addUsageBlock(
2622
+ result: { model: string; totals: Record<string, number> },
2623
+ u: Record<string, unknown> | null | undefined,
2624
+ ): boolean {
2625
+ if (!u) return false;
2626
+ const inp = Number(u.input_tokens ?? u.inputTokens ?? 0) || 0;
2627
+ const out = Number(u.output_tokens ?? u.outputTokens ?? 0) || 0;
2628
+ const cw = Number(u.cache_creation_input_tokens ?? u.cacheCreationInputTokens ?? 0) || 0;
2629
+ const cr = Number(u.cache_read_input_tokens ?? u.cacheReadInputTokens ?? 0) || 0;
2630
+ if (inp + out + cw + cr <= 0) return false;
2631
+ result.totals.in += inp;
2632
+ result.totals.out += out;
2633
+ result.totals.cw += cw;
2634
+ result.totals.cr += cr;
2635
+ return true;
2636
+ }
2637
+
2638
+ function msgModel(entry: Record<string, unknown>): string {
2639
+ const msg = entry.message as Record<string, unknown> | undefined;
2640
+ return typeof msg?.model === 'string' ? msg.model : '';
2641
+ }
2642
+
2643
+ /** Sum token usage from CC or Cursor agent-transcript JSONL. */
2644
+ export function aggregateUsage(
2645
+ transcriptPath: string,
2646
+ opts?: { modelFallback?: string },
2647
+ ): { model: string; totals: Record<string, number> } {
2648
+ const result = { model: opts?.modelFallback || '', totals: { in: 0, out: 0, cw: 0, cr: 0 } };
2215
2649
  if (!transcriptPath || !existsSync(transcriptPath)) return result;
2216
2650
  try {
2217
2651
  const raw = readFileSync(transcriptPath, 'utf-8');
2218
- const lines = raw.split('\\n').filter(l => l.trim());
2219
- for (const line of lines) {
2220
- try {
2221
- const entry = JSON.parse(line);
2222
- if (entry.type !== 'assistant') continue;
2223
- result.model = entry.message?.model || result.model;
2224
- const u = entry.message?.usage;
2225
- if (u) {
2226
- result.totals.in += u.input_tokens || 0;
2227
- result.totals.out += u.output_tokens || 0;
2228
- result.totals.cw += u.cache_creation_input_tokens || 0;
2229
- result.totals.cr += u.cache_read_input_tokens || 0;
2230
- }
2231
- } catch {}
2652
+ const entries: Record<string, unknown>[] = [];
2653
+ for (const line of raw.split('\\n')) {
2654
+ if (!line.trim()) continue;
2655
+ try { entries.push(JSON.parse(line) as Record<string, unknown>); } catch {}
2656
+ }
2657
+
2658
+ let sawExplicit = false;
2659
+ for (const entry of entries) {
2660
+ const tokenCount = (entry.tokenCount ?? entry.token_count) as Record<string, unknown> | undefined;
2661
+ const msg = entry.message as Record<string, unknown> | undefined;
2662
+ if (addUsageBlock({ model: '', totals: { in: 0, out: 0, cw: 0, cr: 0 } }, tokenCount)) sawExplicit = true;
2663
+ if (addUsageBlock({ model: '', totals: { in: 0, out: 0, cw: 0, cr: 0 } }, msg?.usage as Record<string, unknown>)) sawExplicit = true;
2664
+ if (addUsageBlock({ model: '', totals: { in: 0, out: 0, cw: 0, cr: 0 } }, entry.usage as Record<string, unknown>)) sawExplicit = true;
2665
+ }
2666
+
2667
+ for (const entry of entries) {
2668
+ const role = String(entry.role || entry.type || '');
2669
+ const modelInfo = entry.modelInfo as Record<string, unknown> | undefined;
2670
+ const model = (typeof modelInfo?.modelName === 'string' && modelInfo.modelName)
2671
+ || (typeof modelInfo?.model === 'string' && modelInfo.model)
2672
+ || (typeof entry.model === 'string' && entry.model)
2673
+ || msgModel(entry);
2674
+ if (model) result.model = model;
2675
+
2676
+ const tokenCount = (entry.tokenCount ?? entry.token_count) as Record<string, unknown> | undefined;
2677
+ const msg = entry.message as Record<string, unknown> | undefined;
2678
+ const hadLine = addUsageBlock(result, tokenCount)
2679
+ || addUsageBlock(result, msg?.usage as Record<string, unknown>)
2680
+ || addUsageBlock(result, entry.usage as Record<string, unknown>);
2681
+
2682
+ if (hadLine || sawExplicit) continue;
2683
+
2684
+ const text = usageTextFromTranscriptEntry(entry);
2685
+ if (!text) continue;
2686
+ const est = Math.ceil(text.length / 4);
2687
+ if (role === 'user' || entry.type === 'user') result.totals.in += est;
2688
+ else if (role === 'assistant' || entry.type === 'assistant') result.totals.out += est;
2232
2689
  }
2233
2690
  } catch {}
2234
2691
  return result;
2235
2692
  }
2236
2693
 
2694
+ /** Emit a usage_tick telemetry row when transcript usage is non-zero. */
2695
+ export function emitUsageTick(params: {
2696
+ sessionId: string;
2697
+ usage: { model: string; totals: Record<string, number> };
2698
+ hookType: string;
2699
+ gitRepo?: string;
2700
+ modelFallback?: string;
2701
+ }): void {
2702
+ const { sessionId, usage, hookType, gitRepo, modelFallback } = params;
2703
+ if (!sessionId || usage.totals.in + usage.totals.out <= 0) return;
2704
+ let model = usage.model || modelFallback || 'unknown';
2705
+ if (modelFallback && !usage.model) model = modelFallback;
2706
+ if (isCursorHookFormat() && model && !model.startsWith('cursor/') && model !== 'cursor') {
2707
+ model = 'cursor/' + model;
2708
+ }
2709
+ appendLocalTelemetry({
2710
+ capture_type: 'usage_tick',
2711
+ event_id: 'usage_' + Date.now() + '_' + process.pid,
2712
+ hook_type: hookType,
2713
+ verdict: 'allow',
2714
+ severity: 'none',
2715
+ session_id: sessionId,
2716
+ model,
2717
+ cc_model: model,
2718
+ cc_usage: {
2719
+ input_tokens: usage.totals.in,
2720
+ output_tokens: usage.totals.out,
2721
+ cache_creation_input_tokens: usage.totals.cw,
2722
+ cache_read_input_tokens: usage.totals.cr,
2723
+ },
2724
+ ...(gitRepo ? { repo: gitRepo } : {}),
2725
+ });
2726
+ }
2727
+
2728
+ export function cursorModelFromPayload(payload: Record<string, unknown>): string {
2729
+ const rawModel = String(payload.model ?? payload.model_id ?? '');
2730
+ if (!rawModel) return 'cursor';
2731
+ return rawModel.startsWith('cursor/') ? rawModel : 'cursor/' + rawModel;
2732
+ }
2733
+
2237
2734
  // \u2500\u2500\u2500 Scan Finding Dispatch \u2500\u2500\u2500
2238
2735
 
2736
+ export type ScanFindingInput = {
2737
+ finding_type: 'cwe' | 'cve';
2738
+ finding_id: string;
2739
+ severity?: string;
2740
+ detail?: string;
2741
+ description?: string;
2742
+ package_name?: string;
2743
+ package_version?: string;
2744
+ fixed_version?: string;
2745
+ aliases?: string[];
2746
+ references?: Array<{ type: string; url: string }>;
2747
+ cwe_name?: string;
2748
+ };
2749
+
2750
+ /** Persist open scan_findings rows for a block and flush the telemetry spool. */
2751
+ export function emitBlockScanFindings(
2752
+ jwt: string,
2753
+ captureDepth: string,
2754
+ ctx: { session_id: string; file_path: string; repo?: string },
2755
+ findings: ScanFindingInput[],
2756
+ fallback?: ScanFindingInput,
2757
+ ): void {
2758
+ const rows = findings.length > 0 ? findings : (fallback ? [fallback] : []);
2759
+ for (const f of rows) {
2760
+ dispatchFinding(jwt, {
2761
+ session_id: ctx.session_id,
2762
+ file_path: ctx.file_path,
2763
+ repo: ctx.repo,
2764
+ status: 'open',
2765
+ ...f,
2766
+ }, captureDepth);
2767
+ }
2768
+ if (rows.length > 0) drainSpool().catch(() => {});
2769
+ }
2770
+
2239
2771
  export function dispatchFinding(
2240
2772
  jwt: string,
2241
2773
  finding: {
2242
2774
  session_id: string;
2243
2775
  file_path: string;
2776
+ repo?: string;
2244
2777
  finding_type: 'cwe' | 'cve';
2245
2778
  finding_id: string;
2246
2779
  severity?: string;
@@ -2270,6 +2803,7 @@ export function dispatchFinding(
2270
2803
  status: finding.status,
2271
2804
  session_id: finding.session_id,
2272
2805
  file_path: finding.file_path,
2806
+ repo: finding.repo,
2273
2807
  package_name: finding.package_name,
2274
2808
  package_version: finding.package_version,
2275
2809
  fixed_version: finding.fixed_version,
@@ -2411,11 +2945,12 @@ import {
2411
2945
  readStdin, extractTranscript, readLastPrompt, findNearestDeps, filePathFromToolInput,
2412
2946
  appendSessionAction, readSessionLog, compressSessionLog, log,
2413
2947
  outputJson, outputEmpty, setupCursorHookSignals, isEditTool, hookSessionId, GATEWAY_URL,
2414
- logGraderUnavailable, filterRules, ruleFilterText, normalizeMode,
2948
+ logGraderUnavailable, filterRules, ruleFilterText, normalizeMode, countEditLineDelta,
2949
+ captureLineMetrics, cursorModelFromPayload,
2415
2950
  type HookConfig, type Rule,
2416
2951
  } from './_synkro-common.ts';
2417
2952
  import { existsSync, readFileSync } from 'node:fs';
2418
- import { basename, dirname, join } from 'node:path';
2953
+ import { basename, join } from 'node:path';
2419
2954
 
2420
2955
  const agentKind = process.env.SYNKRO_HOOK_FORMAT === 'cursor' ? 'cursor' : 'claude_code';
2421
2956
 
@@ -2438,7 +2973,7 @@ async function main() {
2438
2973
  const workspaceRoots = Array.isArray(payload.workspace_roots) ? payload.workspace_roots as string[] : [];
2439
2974
  const cwd = payload.cwd || workspaceRoots[0] || '';
2440
2975
  const permissionMode = payload.permission_mode || '';
2441
- const transcriptPath = payload.transcript_path || '';
2976
+ const transcriptPath = resolveTranscriptPath(payload);
2442
2977
 
2443
2978
  const filePath = filePathFromToolInput(toolInput);
2444
2979
  if (!filePath) { outputEmpty(); return; }
@@ -2469,21 +3004,44 @@ async function main() {
2469
3004
  if (toolInput.edits != null) diffField.edits = toolInput.edits;
2470
3005
  }
2471
3006
 
3007
+ const fullPath = filePath.startsWith('/') ? filePath : (cwd ? join(cwd, filePath) : filePath);
3008
+ let existingContent = '';
3009
+ if (toolName === 'Write' && existsSync(fullPath)) {
3010
+ try { existingContent = readFileSync(fullPath, 'utf-8'); } catch {}
3011
+ }
3012
+
3013
+ // Compute lines added/removed (Write = net delta vs file on disk)
3014
+ const { linesAdded, linesRemoved } = countEditLineDelta(
3015
+ toolName === 'Write'
3016
+ ? {
3017
+ writeContent: toolInput.content || toolInput.contents || '',
3018
+ existingContent,
3019
+ }
3020
+ : {
3021
+ old_string: toolInput.old_string,
3022
+ new_string: toolInput.new_string,
3023
+ edits: Array.isArray(toolInput.edits) ? toolInput.edits : undefined,
3024
+ },
3025
+ );
3026
+ const lineMetrics = captureLineMetrics(agentKind, toolName, linesAdded, linesRemoved);
3027
+
2472
3028
  // Read file before edit for cloud payload
2473
3029
  let fileBefore = '';
2474
- if (toolName !== 'Write' && filePath && isPathUnder(filePath, cwd || '.') && existsSync(filePath)) {
2475
- try { fileBefore = readFileSync(filePath, 'utf-8').slice(0, 65536); } catch {}
3030
+ if (toolName !== 'Write' && filePath && isPathUnder(filePath, cwd || '.') && existsSync(fullPath)) {
3031
+ try { fileBefore = readFileSync(fullPath, 'utf-8').slice(0, 65536); } catch {}
2476
3032
  }
2477
3033
 
2478
3034
  // Extract transcript context
2479
3035
  const transcript = extractTranscript(transcriptPath);
2480
3036
  const lastPrompt = readLastPrompt(sessionId);
2481
3037
 
3038
+ const captureModel = agentKind === 'cursor'
3039
+ ? cursorModelFromPayload(payload)
3040
+ : (transcript.ccModel || String(payload.model ?? payload.model_id ?? ''));
3041
+
2482
3042
  // Model detection: prefer transcript (CC), fall back to payload (Cursor)
2483
3043
  if (!transcript.ccModel) {
2484
- const rawModel = String(payload.model ?? payload.model_id ?? '');
2485
- const KNOWN_MODELS = new Set(['gpt-4', 'gpt-4o', 'gpt-4.1', 'gpt-5', 'o1', 'o3', 'o4-mini', 'claude-sonnet-4-5', 'claude-opus-4-5', 'sonnet-4', 'sonnet-4-thinking', 'gemini-2.5-pro', 'gemini-2.5-flash']);
2486
- transcript.ccModel = rawModel ? (KNOWN_MODELS.has(rawModel) || rawModel.startsWith('claude-') || rawModel.startsWith('gpt-') || rawModel.startsWith('gemini-') || rawModel.startsWith('o1') || rawModel.startsWith('o3') ? rawModel : 'cursor/' + rawModel) : (agentKind === 'cursor' ? 'cursor' : '');
3044
+ transcript.ccModel = captureModel;
2487
3045
  }
2488
3046
 
2489
3047
  // Load config and decide route
@@ -2542,8 +3100,8 @@ async function main() {
2542
3100
  dispatchCapture(jwt, 'edit', 'block', verdict.severity || 'critical', verdict.category || 'security',
2543
3101
  toolName, gitRepo, sessionId, config.captureDepth, {
2544
3102
  command: editContent, reasoning: guardReason,
2545
- rulesChecked: config.rules, violatedRules,
2546
- ccModel: transcript.ccModel,
3103
+ rulesChecked: relevantRules, violatedRules,
3104
+ ccModel: captureModel, ...lineMetrics,
2547
3105
  });
2548
3106
  outputJson({
2549
3107
  systemMessage: tagStr + ' editGuard ' + fileShort + ' \u2192 blocked: ' + guardReason,
@@ -2556,8 +3114,8 @@ async function main() {
2556
3114
  dispatchCapture(jwt, 'edit', 'pass', 'clean', verdict.category || 'trivial_edit',
2557
3115
  toolName, gitRepo, sessionId, config.captureDepth, {
2558
3116
  command: editContent, reasoning: verdict.reason || 'no policy violations detected',
2559
- rulesChecked: config.rules, violatedRules: [],
2560
- ccModel: transcript.ccModel,
3117
+ rulesChecked: relevantRules, violatedRules: [],
3118
+ ccModel: captureModel, ...lineMetrics,
2561
3119
  });
2562
3120
  const passLine = tagStr + ' editGuard ' + fileShort + ' \u2192 pass: ' + (verdict.reason || 'no policy violations detected');
2563
3121
  outputJson({ systemMessage: passLine, hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: 'Synkro local edit judge. ' + (verdict.reason || 'no policy violations detected') } });
@@ -2636,7 +3194,7 @@ main();
2636
3194
  import {
2637
3195
  loadJwt, ensureFreshJwt, detectRepo, loadConfig, cweRoute, tag,
2638
3196
  localGradeCwe, parseVerdict, reconstructContent, readStdin, log,
2639
- outputJson, outputEmpty, setupCursorHookSignals, isEditTool, hookSessionId, filePathFromToolInput, dispatchFinding, dispatchCapture, GATEWAY_URL,
3197
+ outputJson, outputEmpty, setupCursorHookSignals, isEditTool, hookSessionId, filePathFromToolInput, emitBlockScanFindings, dispatchFinding, dispatchCapture, GATEWAY_URL,
2640
3198
  logGraderUnavailable,
2641
3199
  } from './_synkro-common.ts';
2642
3200
  import { basename, extname, resolve, join, dirname } from 'node:path';
@@ -2644,6 +3202,12 @@ import { readFileSync, readdirSync, existsSync } from 'node:fs';
2644
3202
 
2645
3203
  const agentKind = process.env.SYNKRO_HOOK_FORMAT === 'cursor' ? 'cursor' : 'claude_code';
2646
3204
 
3205
+ function detectModel(payload: Record<string, unknown>): string {
3206
+ const raw = String(payload.model ?? payload.model_id ?? '');
3207
+ if (agentKind === 'cursor') return raw ? (raw.startsWith('cursor/') ? raw : 'cursor/' + raw) : 'cursor';
3208
+ return raw || '';
3209
+ }
3210
+
2647
3211
  interface PackageCapability {
2648
3212
  name: string;
2649
3213
  description: string;
@@ -2767,11 +3331,12 @@ async function main() {
2767
3331
  const sessionId = hookSessionId(payload);
2768
3332
  const workspaceRoots = Array.isArray(payload.workspace_roots) ? payload.workspace_roots as string[] : [];
2769
3333
  const cwd = payload.cwd || workspaceRoots[0] || '';
2770
- const transcriptPath = payload.transcript_path || '';
3334
+ const transcriptPath = resolveTranscriptPath(payload);
2771
3335
 
2772
3336
  const filePath = filePathFromToolInput(toolInput);
2773
3337
  if (!filePath) { outputEmpty(); return; }
2774
3338
  const gitRepo = detectRepo(cwd, transcriptPath, filePath, workspaceRoots);
3339
+ const ccModel = detectModel(payload);
2775
3340
 
2776
3341
  if (filePath.includes('/.synkro/hooks/')) { outputEmpty(); return; }
2777
3342
 
@@ -2981,24 +3546,31 @@ async function main() {
2981
3546
  const fixHint = fixLines.length > 0 ? '\n' + fixLines.join('\n') : '';
2982
3547
  const ctx = 'CWE: ' + denyDetail + fixHint + '\nFix all issues before retrying. Do NOT ask the user to make the edit manually — resolve the weakness in code yourself.';
2983
3548
 
2984
- for (const cweId of activeCweIds) {
2985
- dispatchFinding(jwt, {
2986
- session_id: sessionId,
2987
- file_path: filePath,
2988
- finding_type: 'cwe',
3549
+ emitBlockScanFindings(
3550
+ jwt,
3551
+ config.captureDepth,
3552
+ { session_id: sessionId, file_path: filePath, repo: gitRepo || undefined },
3553
+ activeCweIds.map((cweId) => ({
3554
+ finding_type: 'cwe' as const,
2989
3555
  finding_id: cweId,
2990
3556
  severity: verdict.severity || 'high',
2991
- status: 'open',
2992
3557
  detail: verdict.reason || 'code weakness detected',
2993
3558
  cwe_name: cweNameMap.get(cweId.toUpperCase()) || undefined,
2994
- }, config.captureDepth);
2995
- }
3559
+ })),
3560
+ {
3561
+ finding_type: 'cwe',
3562
+ finding_id: activeCweIds[0] || 'CWE-UNKNOWN',
3563
+ severity: verdict.severity || 'high',
3564
+ detail: verdict.reason || denyDetail,
3565
+ },
3566
+ );
2996
3567
 
2997
3568
  dispatchCapture(jwt, 'cwe', 'block', verdict.severity || 'high', verdict.category || 'security',
2998
3569
  toolName, gitRepo, sessionId, config.captureDepth, {
2999
3570
  command: 'edit ' + filePath,
3000
3571
  reasoning: denyDetail,
3001
3572
  violatedRules: activeCweIds,
3573
+ ccModel,
3002
3574
  });
3003
3575
 
3004
3576
  outputJson({
@@ -3076,25 +3648,34 @@ async function main() {
3076
3648
  const denyDetail = '[' + displayIds + '] ' + (findings[0]?.reason || 'code weakness detected');
3077
3649
  const ctx = 'CWE: ' + denyDetail + fixHint + '\nFix all issues before retrying. Do NOT ask the user to make the edit manually \u2014 resolve the weakness in code yourself.';
3078
3650
 
3079
- for (const cweId of activeCweIds) {
3080
- const f = findings.find((x: any) => x.cwe === cweId);
3081
- dispatchFinding(jwt, {
3082
- session_id: sessionId,
3083
- file_path: filePath,
3651
+ emitBlockScanFindings(
3652
+ jwt,
3653
+ config.captureDepth,
3654
+ { session_id: sessionId, file_path: filePath, repo: gitRepo || undefined },
3655
+ activeCweIds.map((cweId) => {
3656
+ const f = findings.find((x: any) => x.cwe === cweId);
3657
+ return {
3658
+ finding_type: 'cwe' as const,
3659
+ finding_id: cweId,
3660
+ severity: f?.severity || 'high',
3661
+ detail: f?.reason || 'code weakness detected',
3662
+ cwe_name: f?.name || undefined,
3663
+ };
3664
+ }),
3665
+ {
3084
3666
  finding_type: 'cwe',
3085
- finding_id: cweId,
3086
- severity: f?.severity || 'high',
3087
- status: 'open',
3088
- detail: f?.reason || 'code weakness detected',
3089
- cwe_name: f?.name || undefined,
3090
- }, config.captureDepth);
3091
- }
3667
+ finding_id: activeCweIds[0] || 'CWE-UNKNOWN',
3668
+ severity: findings[0]?.severity || 'high',
3669
+ detail: denyDetail,
3670
+ },
3671
+ );
3092
3672
 
3093
3673
  dispatchCapture(jwt, 'cwe', 'block', findings[0]?.severity || 'high', 'security',
3094
3674
  toolName, gitRepo, sessionId, config.captureDepth, {
3095
3675
  command: 'edit ' + filePath,
3096
3676
  reasoning: denyDetail,
3097
3677
  violatedRules: activeCweIds,
3678
+ ccModel,
3098
3679
  });
3099
3680
 
3100
3681
  outputJson({
@@ -3126,9 +3707,10 @@ main();
3126
3707
  import {
3127
3708
  loadJwt, ensureFreshJwt, detectRepo, loadConfig, route, tag,
3128
3709
  reconstructContent, readStdin, findNearestDeps, filePathFromToolInput, log,
3129
- outputJson, outputEmpty, setupCursorHookSignals, isEditTool, hookSessionId, dispatchFinding, dispatchCapture, GATEWAY_URL,
3710
+ outputJson, outputEmpty, setupCursorHookSignals, isEditTool, hookSessionId, dispatchFinding, dispatchCapture, extractTranscript, emitBlockScanFindings, GATEWAY_URL,
3130
3711
  } from './_synkro-common.ts';
3131
3712
  import { basename } from 'node:path';
3713
+ import { readFileSync } from 'node:fs';
3132
3714
 
3133
3715
  const MANIFEST_NAMES = new Set([
3134
3716
  'package.json', 'requirements.txt', 'requirements-dev.txt', 'requirements-test.txt',
@@ -3160,7 +3742,7 @@ async function main() {
3160
3742
  const sessionId = hookSessionId(payload);
3161
3743
  const workspaceRoots = Array.isArray(payload.workspace_roots) ? payload.workspace_roots as string[] : [];
3162
3744
  const cwd = payload.cwd || workspaceRoots[0] || '';
3163
- const transcriptPath = payload.transcript_path || '';
3745
+ const transcriptPath = resolveTranscriptPath(payload);
3164
3746
 
3165
3747
  const filePath = filePathFromToolInput(toolInput);
3166
3748
  if (!filePath) { outputEmpty(); return; }
@@ -3199,6 +3781,97 @@ async function main() {
3199
3781
  deps = findNearestDeps(filePath);
3200
3782
  }
3201
3783
 
3784
+ // For package.json edits, diff the proposed content against the file on
3785
+ // disk and scan only the added/upgraded packages. Catches the case the
3786
+ // bash installScan misses: a bare \`pnpm install\` after a manifest bump
3787
+ // arrives with no package tokens on the cmdline, so the install-time
3788
+ // scanner has nothing to feed OSV. By gating at the edit, the install
3789
+ // that follows is implicitly safe \u2014 there's nothing new to scan.
3790
+ if (fileShort === 'package.json') {
3791
+ try {
3792
+ let currentContent = '';
3793
+ try { currentContent = readFileSync(filePath, 'utf-8'); } catch {}
3794
+ const extractMod: any = await import(new URL('./installExtractCore.ts', import.meta.url).href);
3795
+ const deltaPkgs = extractMod.extractPackageJsonDelta(currentContent, proposed) as Array<{ name: string; version: string; ecosystem: string }>;
3796
+ if (deltaPkgs.length > 0) {
3797
+ const pkgScanResp = await fetch(GATEWAY_URL + '/api/v1/pkg-scan', {
3798
+ method: 'POST',
3799
+ headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + jwt },
3800
+ body: JSON.stringify({ command: 'edit ' + filePath, packages: deltaPkgs }),
3801
+ signal: AbortSignal.timeout(8000),
3802
+ }).then(r => r.json()).catch(() => null) as any;
3803
+ const action = pkgScanResp?.action || 'allow';
3804
+ if (action === 'block') {
3805
+ const pkgResults = Array.isArray(pkgScanResp?.packages) ? pkgScanResp.packages : [];
3806
+ const summary = pkgScanResp?.summary || (deltaPkgs.length + ' package(s) flagged');
3807
+ const violatedIds: string[] = [];
3808
+ const cveFindings: Array<{
3809
+ finding_type: 'cve'; finding_id: string; severity?: string; detail?: string;
3810
+ description?: string; package_name?: string; package_version?: string;
3811
+ fixed_version?: string; aliases?: string[]; references?: Array<{ type: string; url: string }>;
3812
+ }> = [];
3813
+ for (const p of pkgResults) {
3814
+ if (Array.isArray(p?.findings)) {
3815
+ for (const f of p.findings) {
3816
+ const aliasCve = Array.isArray(f?.aliases) ? f.aliases.find((a: string) => a.startsWith('CVE-')) : null;
3817
+ const fid = aliasCve || f?.id || 'unknown';
3818
+ if (fid && !violatedIds.includes(fid)) violatedIds.push(fid);
3819
+ cveFindings.push({
3820
+ finding_type: 'cve',
3821
+ finding_id: fid,
3822
+ severity: f?.severity || 'high',
3823
+ detail: f?.summary || f?.title || 'vulnerable dependency',
3824
+ description: f?.details || undefined,
3825
+ package_name: p?.name,
3826
+ package_version: p?.version,
3827
+ fixed_version: f?.fixed || undefined,
3828
+ aliases: f?.aliases || undefined,
3829
+ references: f?.references || undefined,
3830
+ });
3831
+ }
3832
+ }
3833
+ }
3834
+ emitBlockScanFindings(
3835
+ jwt,
3836
+ config.captureDepth,
3837
+ { session_id: sessionId, file_path: filePath, repo: gitRepo || undefined },
3838
+ cveFindings,
3839
+ {
3840
+ finding_type: 'cve',
3841
+ finding_id: violatedIds[0] || 'SYNKRO_PKGSCAN',
3842
+ severity: 'critical',
3843
+ detail: summary.slice(0, 500),
3844
+ package_name: deltaPkgs[0]?.name,
3845
+ package_version: deltaPkgs[0]?.version,
3846
+ },
3847
+ );
3848
+ // Model name isn't in the PreToolUse payload \u2014 pull it from the
3849
+ // transcript (last assistant entry's message.model). Matches what
3850
+ // the bash judge does at capture time.
3851
+ let ccModel: string | undefined = String(payload.model ?? payload.model_id ?? '') || undefined;
3852
+ if (!ccModel && transcriptPath) {
3853
+ try { ccModel = extractTranscript(transcriptPath).ccModel || undefined; } catch {}
3854
+ }
3855
+ dispatchCapture(jwt, 'pkg', 'block', 'critical', 'security',
3856
+ toolName, gitRepo, sessionId, config.captureDepth, {
3857
+ command: 'edit ' + filePath, reasoning: summary.slice(0, 200),
3858
+ violatedRules: violatedIds,
3859
+ ccModel,
3860
+ });
3861
+ const tagStr = '[synkro:' + rt + ':pkgScan]';
3862
+ const denyReason = tagStr + ' BLOCKED: ' + summary + '\\nDo not write this version. Pick a fixed/safe version instead.';
3863
+ outputJson({
3864
+ systemMessage: denyReason,
3865
+ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: denyReason, additionalContext: denyReason },
3866
+ });
3867
+ return;
3868
+ }
3869
+ }
3870
+ } catch (err) {
3871
+ log('pkgDeltaScan error: ' + String(err));
3872
+ }
3873
+ }
3874
+
3202
3875
  // CVE scan via OSV API
3203
3876
  const cveBody = {
3204
3877
  file_path: filePath,
@@ -3222,15 +3895,12 @@ async function main() {
3222
3895
 
3223
3896
  const findings = Array.isArray(cveResp?.findings) ? cveResp.findings : [];
3224
3897
  if (findings.length > 0) {
3225
- for (const f of findings.slice(0, 10)) {
3898
+ const cveRows = findings.slice(0, 10).map((f: any) => {
3226
3899
  const cveId = (f.aliases || []).find((a: string) => a.startsWith('CVE-')) || f.id || 'unknown';
3227
- dispatchFinding(jwt, {
3228
- session_id: sessionId,
3229
- file_path: filePath,
3230
- finding_type: 'cve',
3900
+ return {
3901
+ finding_type: 'cve' as const,
3231
3902
  finding_id: cveId,
3232
3903
  severity: f.severity || 'high',
3233
- status: 'open',
3234
3904
  detail: f.summary || f.title || 'vulnerable dependency',
3235
3905
  description: f.details || undefined,
3236
3906
  package_name: f.package || undefined,
@@ -3238,8 +3908,15 @@ async function main() {
3238
3908
  fixed_version: f.fixed || undefined,
3239
3909
  aliases: f.aliases || undefined,
3240
3910
  references: f.references || undefined,
3241
- }, config.captureDepth);
3242
- }
3911
+ };
3912
+ });
3913
+ emitBlockScanFindings(
3914
+ jwt,
3915
+ config.captureDepth,
3916
+ { session_id: sessionId, file_path: filePath, repo: gitRepo || undefined },
3917
+ cveRows,
3918
+ cveRows[0],
3919
+ );
3243
3920
 
3244
3921
  const formatFinding = (f: any): string => {
3245
3922
  const id = (f.aliases || []).find((a: string) => a.startsWith('CVE-')) || f.id || '?';
@@ -3285,8 +3962,9 @@ main();
3285
3962
  INSTALL_SCAN_TS = `#!/usr/bin/env bun
3286
3963
  import {
3287
3964
  loadJwt, ensureFreshJwt, detectRepo, loadConfig, route, tag,
3288
- readStdin, runInstallScan, dispatchFinding, dispatchCapture, hashCommand,
3965
+ readStdin, runInstallScan, emitBlockScanFindings, dispatchCapture, hashCommand,
3289
3966
  outputJson, outputEmpty, setupCursorHookSignals, isShellTool, hookSessionId, log, GATEWAY_URL,
3967
+ resolveTranscriptPath, isCursorHookFormat,
3290
3968
  } from './_synkro-common.ts';
3291
3969
  import { writeFileSync, mkdirSync } from 'node:fs';
3292
3970
  import { join } from 'node:path';
@@ -3326,30 +4004,41 @@ async function main() {
3326
4004
  const sessionId = hookSessionId(payload);
3327
4005
  const workspaceRoots = Array.isArray(payload.workspace_roots) ? payload.workspace_roots as string[] : [];
3328
4006
  const cwd = payload.cwd || workspaceRoots[0] || '';
3329
- const transcriptPath = payload.transcript_path || '';
4007
+ const transcriptPath = resolveTranscriptPath(payload);
3330
4008
  const repo = detectRepo(cwd, transcriptPath, command, workspaceRoots);
3331
4009
  const config = await loadConfig(jwt);
3332
4010
  const rt = await route(config);
3333
4011
  const tagStr = tag(rt, config);
3334
4012
 
3335
4013
  const rawModel = String(payload.model ?? payload.model_id ?? '');
3336
- const KNOWN_MODELS = new Set(['gpt-4', 'gpt-4o', 'gpt-4.1', 'gpt-5', 'o1', 'o3', 'o4-mini', 'claude-sonnet-4-5', 'claude-opus-4-5', 'sonnet-4', 'sonnet-4-thinking', 'gemini-2.5-pro', 'gemini-2.5-flash']);
3337
- const model = rawModel ? (KNOWN_MODELS.has(rawModel) || rawModel.startsWith('claude-') || rawModel.startsWith('gpt-') || rawModel.startsWith('gemini-') || rawModel.startsWith('o1') || rawModel.startsWith('o3') ? rawModel : 'cursor/' + rawModel) : '';
4014
+ const isCursor = isCursorHookFormat();
4015
+ const model = isCursor ? (rawModel ? (rawModel.startsWith('cursor/') ? rawModel : 'cursor/' + rawModel) : 'cursor') : (rawModel || '');
3338
4016
 
3339
4017
  if (scan.action === 'block') {
3340
- for (const f of scan.findings) {
3341
- dispatchFinding(jwt, {
3342
- session_id: sessionId,
3343
- file_path: command,
4018
+ const pkgLabel = scan.scannedLabel || '';
4019
+ const pkgName = pkgLabel.split('@')[0] || undefined;
4020
+ const pkgVersion = pkgLabel.includes('@') ? pkgLabel.split('@').slice(1).join('@') : undefined;
4021
+ emitBlockScanFindings(
4022
+ jwt,
4023
+ config.captureDepth,
4024
+ { session_id: sessionId, file_path: command, repo: repo || undefined },
4025
+ scan.findings.map((f) => ({
3344
4026
  finding_type: 'cve' as const,
3345
4027
  finding_id: f.advisoryId + ':' + f.name,
3346
4028
  severity: f.severity,
3347
- status: 'open',
3348
4029
  detail: f.detail,
3349
4030
  package_name: f.name,
3350
4031
  package_version: f.version,
3351
- }, config.captureDepth);
3352
- }
4032
+ })),
4033
+ {
4034
+ finding_type: 'cve',
4035
+ finding_id: 'SYNKRO_PKGSCAN',
4036
+ severity: 'critical',
4037
+ detail: scan.blockContext.slice(0, 500) || scan.summary || 'Blocked package install',
4038
+ package_name: pkgName,
4039
+ package_version: pkgVersion,
4040
+ },
4041
+ );
3353
4042
  dispatchCapture(jwt, 'pkg', 'block', 'critical', 'security',
3354
4043
  'Bash', repo, sessionId, config.captureDepth, {
3355
4044
  command, reasoning: scan.blockContext.slice(0, 200),
@@ -3442,7 +4131,7 @@ async function main() {
3442
4131
  const workspaceRoots = Array.isArray(payload.workspace_roots) ? payload.workspace_roots as string[] : [];
3443
4132
  const cwd = payload.cwd || workspaceRoots[0] || '';
3444
4133
  const permissionMode = payload.permission_mode || '';
3445
- const transcriptPath = payload.transcript_path || '';
4134
+ const transcriptPath = resolveTranscriptPath(payload);
3446
4135
  const transcript = extractTranscript(transcriptPath);
3447
4136
 
3448
4137
  let command = '';
@@ -3512,20 +4201,20 @@ async function main() {
3512
4201
  return;
3513
4202
  }
3514
4203
 
3515
- // ─── Install protection: read cached scan from the install-scan hook ───
3516
- // The install-scan hook (INSTALL_SCAN_TS) runs before this hook, calls
3517
- // runInstallScan(), outputs its own system message, and caches the result.
3518
- // We just read the cache to feed scanConcern into the grader prompt so
3519
- // the consent-carryover flow works.
4204
+ // ─── Install protection: install-scan runs first and owns block traces ───
3520
4205
  let scanConcern = '';
3521
4206
  let scanBlockContext = '';
3522
4207
  if (toolName === 'Bash') {
3523
4208
  const scan = readCachedScan(command);
3524
- if (scan && scan.action === 'block') {
3525
- scanBlockContext = scan.blockContext || '';
3526
- scanConcern = 'PACKAGE SCANNER FLAG (authoritative — do NOT re-evaluate whether the vulnerability is real): '
3527
- + scanBlockContext
3528
- + ' For this concern you MUST return ok=false with rule_id "SYNKRO_PKGSCAN", rule_mode "ask", and the reason above — UNLESS the user has explicitly consented in this conversation to installing this despite the warning, in which case return ok=true.';
4209
+ if (scan?.action === 'block') {
4210
+ log('bashGuard deferring to install-scan block: ' + cmdShort);
4211
+ outputEmpty();
4212
+ return;
4213
+ }
4214
+ if (scan?.action === 'warn') {
4215
+ scanBlockContext = scan.blockContext || scan.summary || '';
4216
+ scanConcern = 'PACKAGE SCANNER WARNING: ' + scanBlockContext
4217
+ + ' Mention this to the user before proceeding.';
3529
4218
  }
3530
4219
  }
3531
4220
 
@@ -3590,7 +4279,7 @@ async function main() {
3590
4279
  });
3591
4280
  dispatchCapture(jwt, 'bash', 'block', verdict.severity || 'critical', verdict.category || 'security',
3592
4281
  toolName, gitRepo, sessionId, config.captureDepth, {
3593
- command, reasoning: guardReason, rulesChecked: config.rules, violatedRules,
4282
+ command, reasoning: guardReason, rulesChecked: relevantRules, violatedRules,
3594
4283
  recentUserMessages: transcript.recentUserMessages, ccModel: transcript.ccModel,
3595
4284
  });
3596
4285
  } else {
@@ -3602,7 +4291,7 @@ async function main() {
3602
4291
  dispatchCapture(jwt, 'bash', 'pass', 'clean', verdict.category || 'trivial_utility',
3603
4292
  toolName, gitRepo, sessionId, config.captureDepth, {
3604
4293
  command, reasoning: verdict.reason || 'no policy violations detected',
3605
- rulesChecked: config.rules, violatedRules: [],
4294
+ rulesChecked: relevantRules, violatedRules: [],
3606
4295
  recentUserMessages: transcript.recentUserMessages, ccModel: transcript.ccModel,
3607
4296
  });
3608
4297
  }
@@ -3700,7 +4389,7 @@ async function main() {
3700
4389
  const workspaceRoots = Array.isArray(payload.workspace_roots) ? payload.workspace_roots as string[] : [];
3701
4390
  const cwd = payload.cwd || workspaceRoots[0] || '';
3702
4391
  const permissionMode = payload.permission_mode || '';
3703
- const transcriptPath = payload.transcript_path || '';
4392
+ const transcriptPath = resolveTranscriptPath(payload);
3704
4393
 
3705
4394
  const prompt = toolInput.prompt || '';
3706
4395
  const description = toolInput.description || '';
@@ -3723,8 +4412,11 @@ async function main() {
3723
4412
 
3724
4413
  if (!transcript.ccModel) {
3725
4414
  const rawModel = String(payload.model ?? payload.model_id ?? '');
3726
- const KNOWN_MODELS = new Set(['gpt-4', 'gpt-4o', 'gpt-4.1', 'gpt-5', 'o1', 'o3', 'o4-mini', 'claude-sonnet-4-5', 'claude-opus-4-5', 'sonnet-4', 'sonnet-4-thinking', 'gemini-2.5-pro', 'gemini-2.5-flash']);
3727
- transcript.ccModel = rawModel ? (KNOWN_MODELS.has(rawModel) || rawModel.startsWith('claude-') || rawModel.startsWith('gpt-') || rawModel.startsWith('gemini-') || rawModel.startsWith('o1') || rawModel.startsWith('o3') ? rawModel : 'cursor/' + rawModel) : (agentKind === 'cursor' ? 'cursor' : '');
4415
+ if (agentKind === 'cursor') {
4416
+ transcript.ccModel = rawModel ? (rawModel.startsWith('cursor/') ? rawModel : 'cursor/' + rawModel) : 'cursor';
4417
+ } else {
4418
+ transcript.ccModel = rawModel || '';
4419
+ }
3728
4420
  }
3729
4421
 
3730
4422
  const config = await loadConfig(jwt);
@@ -3782,7 +4474,7 @@ async function main() {
3782
4474
  });
3783
4475
  dispatchCapture(jwt, 'agent', 'block', verdict.severity || 'critical', verdict.category || 'security',
3784
4476
  toolName, gitRepo, sessionId, config.captureDepth, {
3785
- command: agentContent, reasoning: guardReason, rulesChecked: config.rules, violatedRules,
4477
+ command: agentContent, reasoning: guardReason, rulesChecked: relevantRules, violatedRules,
3786
4478
  recentUserMessages: transcript.recentUserMessages, ccModel: transcript.ccModel,
3787
4479
  });
3788
4480
  } else {
@@ -3791,7 +4483,7 @@ async function main() {
3791
4483
  dispatchCapture(jwt, 'agent', 'pass', 'clean', verdict.category || 'subagent_spawn',
3792
4484
  toolName, gitRepo, sessionId, config.captureDepth, {
3793
4485
  command: agentContent, reasoning: verdict.reason || 'no policy violations detected',
3794
- rulesChecked: config.rules, violatedRules: [],
4486
+ rulesChecked: relevantRules, violatedRules: [],
3795
4487
  recentUserMessages: transcript.recentUserMessages, ccModel: transcript.ccModel,
3796
4488
  });
3797
4489
  }
@@ -3917,7 +4609,7 @@ async function main() {
3917
4609
  const sessionId = hookSessionId(payload);
3918
4610
  const workspaceRoots = Array.isArray(payload.workspace_roots) ? payload.workspace_roots as string[] : [];
3919
4611
  const cwd = payload.cwd || workspaceRoots[0] || '';
3920
- const transcriptPath = payload.transcript_path || '';
4612
+ const transcriptPath = resolveTranscriptPath(payload);
3921
4613
  const gitRepo = detectRepo(cwd, transcriptPath, plan, workspaceRoots);
3922
4614
 
3923
4615
  appendSessionAction(sessionId, { ts: new Date().toISOString(), tool: 'ExitPlanMode', summary: 'plan review: ' + plan.slice(0, 80) });
@@ -3930,8 +4622,7 @@ async function main() {
3930
4622
  jwt = await ensureFreshJwt(jwt);
3931
4623
 
3932
4624
  const rawModel = String(payload.model ?? payload.model_id ?? '');
3933
- const KNOWN_MODELS = new Set(['gpt-4', 'gpt-4o', 'gpt-4.1', 'gpt-5', 'o1', 'o3', 'o4-mini', 'claude-sonnet-4-5', 'claude-opus-4-5', 'sonnet-4', 'sonnet-4-thinking', 'gemini-2.5-pro', 'gemini-2.5-flash']);
3934
- const ccModel = rawModel ? (KNOWN_MODELS.has(rawModel) || rawModel.startsWith('claude-') || rawModel.startsWith('gpt-') || rawModel.startsWith('gemini-') || rawModel.startsWith('o1') || rawModel.startsWith('o3') ? rawModel : 'cursor/' + rawModel) : (agentKind === 'cursor' ? 'cursor' : '');
4625
+ const ccModel = agentKind === 'cursor' ? (rawModel ? (rawModel.startsWith('cursor/') ? rawModel : 'cursor/' + rawModel) : 'cursor') : (rawModel || '');
3935
4626
 
3936
4627
  const config = await loadConfig(jwt);
3937
4628
  const rt = await route(config);
@@ -3974,7 +4665,7 @@ async function main() {
3974
4665
  dispatchCapture(jwt, 'plan_review', 'advisory', verdict.severity || 'medium', verdict.category || 'general',
3975
4666
  'ExitPlanMode', gitRepo, sessionId, config.captureDepth, {
3976
4667
  command: planContent, reasoning: verdict.reason || 'check org rules',
3977
- rulesChecked: config.rules, violatedRules, ccModel: ccModel || undefined,
4668
+ rulesChecked: relevantRules, violatedRules, ccModel: ccModel || undefined,
3978
4669
  });
3979
4670
  } else {
3980
4671
  const reviewMsg = verdict.reason || 'no relevant org rules for this plan';
@@ -3984,7 +4675,7 @@ async function main() {
3984
4675
  dispatchCapture(jwt, 'plan_review', 'clean', 'clean', verdict.category || 'general',
3985
4676
  'ExitPlanMode', gitRepo, sessionId, config.captureDepth, {
3986
4677
  command: planContent, reasoning: reviewMsg,
3987
- rulesChecked: config.rules, violatedRules: [], ccModel: ccModel || undefined,
4678
+ rulesChecked: relevantRules, violatedRules: [], ccModel: ccModel || undefined,
3988
4679
  });
3989
4680
  }
3990
4681
  return;
@@ -4032,7 +4723,8 @@ main();
4032
4723
  STOP_SUMMARY_TS = `#!/usr/bin/env bun
4033
4724
  import {
4034
4725
  loadJwt, detectRepo, loadConfig, tag, readStdin, aggregateUsage, cleanupSessionLog,
4035
- outputJson, outputEmpty, appendLocalTelemetry, shipCloud, setupCursorHookSignals, hookSessionId, GATEWAY_URL,
4726
+ outputJson, outputEmpty, shipCloud, setupCursorHookSignals, hookSessionId, GATEWAY_URL,
4727
+ resolveTranscriptPath, emitUsageTick, cursorModelFromPayload, log,
4036
4728
  } from './_synkro-common.ts';
4037
4729
 
4038
4730
  async function main() {
@@ -4047,35 +4739,16 @@ async function main() {
4047
4739
 
4048
4740
  const workspaceRoots = Array.isArray(payload.workspace_roots) ? payload.workspace_roots as string[] : [];
4049
4741
  const cwd = payload.cwd || workspaceRoots[0] || '';
4050
- const transcriptPath = payload.transcript_path || '';
4742
+ const transcriptPath = resolveTranscriptPath(payload);
4051
4743
  const gitRepo = detectRepo(cwd, transcriptPath, '', workspaceRoots);
4744
+ const modelFallback = cursorModelFromPayload(payload);
4052
4745
 
4053
4746
  let jwt = loadJwt();
4054
4747
  if (!jwt) { outputEmpty(); return; }
4055
4748
 
4056
4749
  if (transcriptPath) {
4057
- const usage = aggregateUsage(transcriptPath);
4058
- if (usage.totals.in + usage.totals.out > 0) {
4059
- const usageBody = {
4060
- capture_type: 'usage_tick',
4061
- event_id: 'usage_' + Date.now() + '_' + process.pid,
4062
- hook_type: 'stop',
4063
- verdict: 'allow',
4064
- severity: 'none',
4065
- model: usage.model || 'unknown',
4066
- cc_model: usage.model || '',
4067
- cc_usage: {
4068
- input_tokens: usage.totals.in,
4069
- output_tokens: usage.totals.out,
4070
- cache_creation_input_tokens: usage.totals.cw,
4071
- cache_read_input_tokens: usage.totals.cr,
4072
- },
4073
- ...(gitRepo ? { repo: gitRepo } : {}),
4074
- ...(sessionId ? { session_id: sessionId } : {}),
4075
- };
4076
- appendLocalTelemetry(usageBody);
4077
- shipCloud(jwt, '/api/v1/hook/capture', usageBody);
4078
- }
4750
+ const usage = aggregateUsage(transcriptPath, { modelFallback });
4751
+ emitUsageTick({ sessionId, usage, hookType: 'session_end', gitRepo, modelFallback });
4079
4752
  }
4080
4753
 
4081
4754
  let resp: any;
@@ -4131,7 +4804,7 @@ async function main() {
4131
4804
  const payload = JSON.parse(input);
4132
4805
  const workspaceRoots = Array.isArray(payload.workspace_roots) ? payload.workspace_roots as string[] : [];
4133
4806
  const cwd = payload.cwd || workspaceRoots[0] || '';
4134
- const transcriptPath = payload.transcript_path || '';
4807
+ const transcriptPath = resolveTranscriptPath(payload);
4135
4808
  const sessionId = hookSessionId(payload);
4136
4809
  const gitRepo = detectRepo(cwd, transcriptPath, '', workspaceRoots);
4137
4810
  if (gitRepo) writeCachedRepo(gitRepo);
@@ -4263,10 +4936,9 @@ main();
4263
4936
  import {
4264
4937
  loadJwt, detectRepo, readStdin, aggregateUsage, appendLocalTelemetry,
4265
4938
  outputEmpty, setupCursorHookSignals, hookSessionId, GATEWAY_URL, readSessionLog, shipCloud,
4939
+ resolveTranscriptPath, syncConversationTranscript, emitUsageTick, cursorModelFromPayload,
4266
4940
  } from './_synkro-common.ts';
4267
- import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
4268
- import { join, dirname } from 'node:path';
4269
- import { homedir } from 'node:os';
4941
+ import { readFileSync } from 'node:fs';
4270
4942
 
4271
4943
  async function main() {
4272
4944
  setupCursorHookSignals();
@@ -4276,11 +4948,11 @@ async function main() {
4276
4948
 
4277
4949
  const payload = JSON.parse(input);
4278
4950
  const sessionId = hookSessionId(payload);
4279
- const transcriptPath = payload.transcript_path || '';
4280
4951
  const workspaceRoots = Array.isArray(payload.workspace_roots) ? payload.workspace_roots as string[] : [];
4281
4952
  const cwd = payload.cwd || workspaceRoots[0] || '';
4953
+ const transcriptPath = resolveTranscriptPath(payload);
4282
4954
 
4283
- if (!sessionId || !transcriptPath || !existsSync(transcriptPath)) {
4955
+ if (!sessionId || !transcriptPath) {
4284
4956
  outputEmpty();
4285
4957
  return;
4286
4958
  }
@@ -4288,104 +4960,21 @@ async function main() {
4288
4960
  const jwt = loadJwt();
4289
4961
  if (!jwt) { outputEmpty(); return; }
4290
4962
 
4291
- const usage = aggregateUsage(transcriptPath);
4292
- if (usage.totals.in + usage.totals.out > 0) {
4293
- const usageBody = {
4294
- capture_type: 'usage_tick',
4295
- event_id: 'usage_' + Date.now() + '_' + process.pid,
4296
- hook_type: 'stop',
4297
- verdict: 'allow',
4298
- severity: 'none',
4299
- model: usage.model || 'unknown',
4300
- cc_model: usage.model || '',
4301
- cc_usage: {
4302
- input_tokens: usage.totals.in,
4303
- output_tokens: usage.totals.out,
4304
- cache_creation_input_tokens: usage.totals.cw,
4305
- cache_read_input_tokens: usage.totals.cr,
4306
- },
4307
- session_id: sessionId,
4308
- };
4309
- appendLocalTelemetry(usageBody);
4310
- shipCloud(jwt, '/api/v1/hook/capture', usageBody);
4311
- }
4963
+ const usage = aggregateUsage(transcriptPath, { modelFallback: cursorModelFromPayload(payload) });
4964
+ emitUsageTick({
4965
+ sessionId,
4966
+ usage,
4967
+ hookType: 'stop',
4968
+ gitRepo: detectRepo(cwd, transcriptPath, '', workspaceRoots),
4969
+ modelFallback: cursorModelFromPayload(payload),
4970
+ });
4312
4971
 
4313
- // Transcript consent gates only CLOUD transmission. Local persistence \u2014
4314
- // this machine's own PGLite \u2014 is the same category as the local telemetry
4315
- // already captured for every command, so it always runs.
4316
4972
  const cloudConsent = process.env.SYNKRO_TRANSCRIPT_CONSENT !== 'no';
4317
4973
  const gitRepo = detectRepo(cwd, transcriptPath, '', workspaceRoots);
4318
4974
 
4319
- // Offset-tracked extraction of new user/assistant turns from the transcript.
4320
- const offsetDir = join(homedir(), '.synkro', '.transcript-offsets');
4321
- mkdirSync(offsetDir, { recursive: true });
4322
- const offsetFile = join(offsetDir, sessionId);
4323
- let offset = 0;
4324
- if (existsSync(offsetFile)) {
4325
- try { offset = parseInt(readFileSync(offsetFile, 'utf-8').trim(), 10) || 0; } catch {}
4326
- }
4327
-
4328
- const raw = readFileSync(transcriptPath, 'utf-8');
4329
- const allLines = raw.split('\\n').filter(l => l.trim());
4330
- const totalLines = allLines.length;
4331
-
4332
- if (totalLines <= offset) { outputEmpty(); return; }
4333
-
4334
- let startIdx = offset;
4335
- const delta = totalLines - offset;
4336
- if (delta > 200) startIdx = totalLines - 200;
4337
-
4338
- const messages: any[] = [];
4339
- for (let i = startIdx; i < totalLines; i++) {
4340
- try {
4341
- const entry = JSON.parse(allLines[i]);
4342
- if (entry.type !== 'user' && entry.type !== 'assistant') continue;
4343
- const content = entry.message?.content;
4344
- let text = '';
4345
- if (typeof content === 'string') text = content.slice(0, 8000);
4346
- else if (Array.isArray(content)) {
4347
- text = content.map((c: any) => {
4348
- if (typeof c === 'string') return c;
4349
- if (c?.type === 'text') return c.text || '';
4350
- return '';
4351
- }).join(' ').slice(0, 8000);
4352
- }
4353
-
4354
- const msg: any = { message_index: i, type: entry.type, content: text, ts: entry.timestamp || null };
4355
- if (entry.type === 'assistant') {
4356
- const toolCalls = (Array.isArray(content) ? content : [])
4357
- .filter((c: any) => c?.type === 'tool_use')
4358
- .map((c: any) => ({ name: c.name, input: JSON.stringify(c.input || {}).slice(0, 500), id: c.id }));
4359
- if (toolCalls.length > 0) msg.tool_calls = toolCalls;
4360
- msg.model = entry.message?.model || null;
4361
- const u = entry.message?.usage;
4362
- if (u) msg.usage = { input_tokens: u.input_tokens, output_tokens: u.output_tokens, cache_creation_input_tokens: u.cache_creation_input_tokens, cache_read_input_tokens: u.cache_read_input_tokens };
4363
- }
4364
- messages.push(msg);
4365
- } catch {}
4366
- }
4367
-
4368
- writeFileSync(offsetFile, String(totalLines), 'utf-8');
4369
-
4370
- if (messages.length === 0) { outputEmpty(); return; }
4975
+ const { messages } = await syncConversationTranscript(sessionId, transcriptPath, gitRepo || '');
4371
4976
 
4372
- // Local persist \u2014 always. The conversation timeline lives in PGLite.
4373
- const mcpPort = process.env.SYNKRO_MCP_PORT || '18931';
4374
- let mcpToken = '';
4375
- try { mcpToken = readFileSync(join(homedir(), '.synkro', '.mcp-jwt'), 'utf-8').trim(); } catch {}
4376
- if (mcpToken) {
4377
- fetch('http://127.0.0.1:' + mcpPort + '/api/conversation-sync', {
4378
- method: 'POST',
4379
- headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + mcpToken },
4380
- body: JSON.stringify({ session_id: sessionId, repo: gitRepo || '', messages }),
4381
- signal: AbortSignal.timeout(5000),
4382
- }).catch(() => {});
4383
- }
4384
-
4385
- // Cloud sync \u2014 only when storage mode is cloud (and transcript consent
4386
- // wasn't declined). Carries the transcript + the session step-log so cloud
4387
- // storage matches the local PGLite shape.
4388
- if (cloudConsent && gitRepo && (process.env.SYNKRO_STORAGE_MODE || 'local') === 'cloud') {
4977
+ if (cloudConsent && gitRepo && messages.length > 0 && (process.env.SYNKRO_STORAGE_MODE || 'local') === 'cloud') {
4389
4978
  fetch(GATEWAY_URL + '/api/v1/cli/sync-transcripts', {
4390
4979
  method: 'POST',
4391
4980
  headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + jwt },
@@ -4406,7 +4995,7 @@ async function main() {
4406
4995
  main();
4407
4996
  `;
4408
4997
  USER_PROMPT_SUBMIT_TS = `#!/usr/bin/env bun
4409
- import { readStdin, appendLocalTelemetry, aggregateUsage, outputEmpty, setupCursorHookSignals, hookSessionId } from './_synkro-common.ts';
4998
+ import { readStdin, appendLocalTelemetry, aggregateUsage, outputEmpty, setupCursorHookSignals, hookSessionId, detectRepo, resolveTranscriptPath, syncConversationTranscript, emitUsageTick, cursorModelFromPayload } from './_synkro-common.ts';
4410
4999
  import { writeFileSync, mkdirSync } from 'node:fs';
4411
5000
  import { join, dirname } from 'node:path';
4412
5001
  import { homedir } from 'node:os';
@@ -4431,25 +5020,15 @@ async function main() {
4431
5020
  }
4432
5021
 
4433
5022
  const sessionId = hookSessionId(payload);
4434
- const transcriptPath = payload.transcript_path || '';
5023
+ const workspaceRoots = Array.isArray(payload.workspace_roots) ? payload.workspace_roots as string[] : [];
5024
+ const cwd = payload.cwd || workspaceRoots[0] || '';
5025
+ const transcriptPath = resolveTranscriptPath(payload);
4435
5026
  if (sessionId && transcriptPath) {
4436
- const usage = aggregateUsage(transcriptPath);
4437
- if (usage.totals.in + usage.totals.out > 0) {
4438
- appendLocalTelemetry({
4439
- capture_type: 'usage_tick',
4440
- event_id: 'usage_' + Date.now() + '_' + process.pid,
4441
- hook_type: 'prompt_submit',
4442
- session_id: sessionId,
4443
- model: usage.model || 'unknown',
4444
- cc_model: usage.model || '',
4445
- cc_usage: {
4446
- input_tokens: usage.totals.in,
4447
- output_tokens: usage.totals.out,
4448
- cache_creation_input_tokens: usage.totals.cw,
4449
- cache_read_input_tokens: usage.totals.cr,
4450
- },
4451
- });
4452
- }
5027
+ const gitRepo = detectRepo(cwd, transcriptPath, '', workspaceRoots);
5028
+ syncConversationTranscript(sessionId, transcriptPath, gitRepo || '').catch(() => {});
5029
+ const modelFallback = cursorModelFromPayload(payload);
5030
+ const usage = aggregateUsage(transcriptPath, { modelFallback });
5031
+ emitUsageTick({ sessionId, usage, hookType: 'prompt_submit', gitRepo, modelFallback });
4453
5032
  }
4454
5033
  outputEmpty();
4455
5034
  } catch {
@@ -4463,7 +5042,7 @@ main();
4463
5042
  import {
4464
5043
  loadJwt, ensureFreshJwt, detectRepo, loadConfig, route, tag, localGrade,
4465
5044
  parseVerdict, dispatchCapture, dispatchFinding, ruleMode, normalizeMode, filterRules, ruleFilterText,
4466
- isSafeInRepoRead, postWithRetry, readStdin, hashCommand,
5045
+ isSafeInRepoRead, resolveTranscriptPath, postWithRetry, readStdin, hashCommand,
4467
5046
  extractTranscript, readLastPrompt, readSessionLog, compressSessionLog,
4468
5047
  appendLocalTelemetry, logGraderUnavailable, log, GATEWAY_URL,
4469
5048
  type Rule,
@@ -4576,10 +5155,9 @@ async function main() {
4576
5155
  finishAllow();
4577
5156
  }
4578
5157
 
4579
- const transcriptPath = typeof payload.transcript_path === 'string' ? payload.transcript_path : '';
5158
+ const transcriptPath = resolveTranscriptPath(payload);
4580
5159
  const rawModel = String(payload.model ?? payload.model_id ?? '');
4581
- const KNOWN_MODELS = new Set(['gpt-4', 'gpt-4o', 'gpt-4.1', 'gpt-5', 'o1', 'o3', 'o4-mini', 'claude-sonnet-4-5', 'claude-opus-4-5', 'sonnet-4', 'sonnet-4-thinking', 'gemini-2.5-pro', 'gemini-2.5-flash']);
4582
- const model = rawModel ? (KNOWN_MODELS.has(rawModel) || rawModel.startsWith('claude-') || rawModel.startsWith('gpt-') || rawModel.startsWith('gemini-') || rawModel.startsWith('o1') || rawModel.startsWith('o3') ? rawModel : 'cursor/' + rawModel) : 'cursor';
5160
+ const model = rawModel ? (rawModel.startsWith('cursor/') ? rawModel : 'cursor/' + rawModel) : 'cursor';
4583
5161
  const repo = detectRepo(cwd, transcriptPath, command, workspaceRoots);
4584
5162
 
4585
5163
  const cmdShort = command.slice(0, 80);
@@ -4612,16 +5190,19 @@ async function main() {
4612
5190
  const rt = await route(config);
4613
5191
  const tagStr = tag(rt, config);
4614
5192
 
4615
- // Install protection \u2014 read cached scan from the install-scan hook.
5193
+ // Install protection \u2014 install-scan runs first and owns block traces.
4616
5194
  let scanConcern = '';
4617
5195
  let scanBlockContext = '';
4618
5196
  if (SHELL_TOOL_NAMES.has(toolName)) {
4619
5197
  const scan = readCachedScan(command);
4620
- if (scan && scan.action === 'block') {
4621
- scanBlockContext = scan.blockContext || '';
4622
- scanConcern = 'PACKAGE SCANNER FLAG (authoritative \u2014 do NOT re-evaluate whether the vulnerability is real): '
4623
- + scanBlockContext
4624
- + ' For this concern you MUST return ok=false with rule_id "SYNKRO_PKGSCAN", rule_mode "ask", and the reason above \u2014 UNLESS the user has explicitly consented in this conversation to installing this despite the warning, in which case return ok=true.';
5198
+ if (scan?.action === 'block') {
5199
+ log('bashGuard deferring to install-scan block: ' + cmdShort);
5200
+ finishAllow();
5201
+ }
5202
+ if (scan?.action === 'warn') {
5203
+ scanBlockContext = scan.blockContext || scan.summary || '';
5204
+ scanConcern = 'PACKAGE SCANNER WARNING: ' + scanBlockContext
5205
+ + ' Mention this to the user before proceeding.';
4625
5206
  }
4626
5207
  }
4627
5208
 
@@ -4676,7 +5257,7 @@ async function main() {
4676
5257
  dispatchCapture(jwt, 'bash', 'block', verdict.severity || 'critical', verdict.category || 'security',
4677
5258
  'Bash', repo, sessionId, config.captureDepth, {
4678
5259
  command, reasoning: guardReason,
4679
- rulesChecked: config.rules, violatedRules: verdict.ruleId ? [verdict.ruleId] : [],
5260
+ rulesChecked: relevantRules, violatedRules: verdict.ruleId ? [verdict.ruleId] : [],
4680
5261
  ccModel: model,
4681
5262
  });
4682
5263
  finishWith({
@@ -4688,7 +5269,7 @@ async function main() {
4688
5269
  dispatchCapture(jwt, 'bash', 'pass', 'clean', verdict.category || 'clean',
4689
5270
  'Bash', repo, sessionId, config.captureDepth, {
4690
5271
  command, reasoning: verdict.reason || 'no policy violations detected',
4691
- rulesChecked: config.rules, violatedRules: [],
5272
+ rulesChecked: relevantRules, violatedRules: [],
4692
5273
  ccModel: model,
4693
5274
  });
4694
5275
  }
@@ -4755,8 +5336,9 @@ main().catch((e) => {
4755
5336
  });`;
4756
5337
  CURSOR_EDIT_CAPTURE_TS = `#!/usr/bin/env bun
4757
5338
  import {
4758
- loadJwt, ensureFreshJwt, detectRepo, readStdin,
5339
+ loadJwt, ensureFreshJwt, detectRepo, readStdin, resolveTranscriptPath,
4759
5340
  appendSessionAction, appendLocalTelemetry, shipCloud, log, GATEWAY_URL,
5341
+ countEditLineDelta, dispatchCapture, hookSessionId, cursorModelFromPayload,
4760
5342
  } from './_synkro-common.ts';
4761
5343
  import { existsSync, readFileSync } from 'node:fs';
4762
5344
  import { basename, dirname, join } from 'node:path';
@@ -4779,23 +5361,31 @@ async function main() {
4779
5361
  const input = await readStdin();
4780
5362
  if (!input.trim()) finish();
4781
5363
 
4782
- const payload = JSON.parse(input);
4783
- const filePath = payload.file_path || payload.path || payload.target_file || '';
5364
+ const payload = JSON.parse(input) as Record<string, unknown>;
5365
+ const filePath = String(payload.file_path || payload.path || payload.target_file || '');
4784
5366
  if (!filePath) finish();
4785
5367
 
4786
- const workspaceRoots = Array.isArray(payload.workspace_roots) ? (payload.workspace_roots as unknown[]).filter((r): r is string => typeof r === 'string') : [];
4787
- const cwd = payload.cwd || workspaceRoots[0] || '';
4788
- const transcriptPath = typeof payload.transcript_path === 'string' ? payload.transcript_path : '';
4789
- const sessionId = payload.conversation_id || '';
5368
+ const workspaceRoots = Array.isArray(payload.workspace_roots)
5369
+ ? (payload.workspace_roots as unknown[]).filter((r): r is string => typeof r === 'string')
5370
+ : [];
5371
+ const cwd = (typeof payload.cwd === 'string' && payload.cwd) || workspaceRoots[0] || '';
5372
+ const transcriptPath = resolveTranscriptPath(payload);
5373
+ const sessionId = hookSessionId(payload);
4790
5374
  const repo = detectRepo(cwd, transcriptPath, filePath, workspaceRoots);
5375
+ const model = cursorModelFromPayload(payload);
4791
5376
 
4792
- const rawModel = String(payload.model ?? payload.model_id ?? '');
4793
- const KNOWN_MODELS = new Set(['gpt-4', 'gpt-4o', 'gpt-4.1', 'gpt-5', 'o1', 'o3', 'o4-mini', 'claude-sonnet-4-5', 'claude-opus-4-5', 'sonnet-4', 'sonnet-4-thinking', 'gemini-2.5-pro', 'gemini-2.5-flash']);
4794
- const model = rawModel ? (KNOWN_MODELS.has(rawModel) || rawModel.startsWith('claude-') || rawModel.startsWith('gpt-') || rawModel.startsWith('gemini-') || rawModel.startsWith('o1') || rawModel.startsWith('o3') ? rawModel : 'cursor/' + rawModel) : 'cursor';
5377
+ const edits = Array.isArray(payload.edits) ? payload.edits as Array<{ old_string?: string; new_string?: string }> : [];
5378
+ const { linesAdded, linesRemoved } = countEditLineDelta({ edits });
4795
5379
 
4796
- log('editScan ' + basename(filePath));
5380
+ log('editScan ' + basename(filePath) + ' +' + linesAdded + '/-' + linesRemoved);
4797
5381
 
4798
- appendSessionAction(sessionId, { ts: new Date().toISOString(), tool: 'Edit', summary: 'wrote ' + basename(filePath), file: filePath, outcome: 'ok' });
5382
+ appendSessionAction(sessionId, {
5383
+ ts: new Date().toISOString(),
5384
+ tool: 'Edit',
5385
+ summary: 'wrote ' + basename(filePath) + (linesAdded || linesRemoved ? (' (+' + linesAdded + '/-' + linesRemoved + ')') : ''),
5386
+ file: filePath,
5387
+ outcome: 'ok',
5388
+ });
4799
5389
 
4800
5390
  let jwt = loadJwt();
4801
5391
  if (!jwt) finish();
@@ -4841,6 +5431,16 @@ async function main() {
4841
5431
  appendLocalTelemetry(captureBody);
4842
5432
  shipCloud(jwt, '/api/v1/hook/capture', captureBody, 10000);
4843
5433
 
5434
+ if (sessionId) {
5435
+ dispatchCapture(jwt, 'edit', 'pass', 'clean', 'trivial_edit', 'Edit', repo, sessionId, 'full', {
5436
+ command: 'file=' + filePath,
5437
+ reasoning: 'Cursor afterFileEdit',
5438
+ ccModel: model,
5439
+ linesAdded,
5440
+ linesRemoved,
5441
+ });
5442
+ }
5443
+
4844
5444
  finish();
4845
5445
  } catch (e) {
4846
5446
  log('editScan error: ' + String(e));
@@ -4852,6 +5452,47 @@ main().catch((e) => {
4852
5452
  log('editScan fatal: ' + String(e));
4853
5453
  finish();
4854
5454
  });`;
5455
+ CURSOR_AGENT_CAPTURE_TS = `#!/usr/bin/env bun
5456
+ /** Capture Cursor agent thinking/response text before transcript JSONL redacts it. */
5457
+ import {
5458
+ readStdin, outputEmpty, setupCursorHookSignals, hookSessionId, detectRepo,
5459
+ appendThoughtOverlay, pushConversationMessage,
5460
+ } from './_synkro-common.ts';
5461
+
5462
+ async function main() {
5463
+ setupCursorHookSignals();
5464
+ try {
5465
+ const input = await readStdin();
5466
+ if (!input.trim()) { outputEmpty(); return; }
5467
+ const payload = JSON.parse(input) as Record<string, unknown>;
5468
+ const event = String(payload.hook_event_name || '');
5469
+ const text = typeof payload.text === 'string' ? payload.text.trim() : '';
5470
+ if (!text || text === '[REDACTED]') { outputEmpty(); return; }
5471
+
5472
+ const sessionId = hookSessionId(payload);
5473
+ if (!sessionId) { outputEmpty(); return; }
5474
+
5475
+ const workspaceRoots = Array.isArray(payload.workspace_roots)
5476
+ ? payload.workspace_roots.filter((r): r is string => typeof r === 'string')
5477
+ : [];
5478
+ const cwd = (typeof payload.cwd === 'string' && payload.cwd) || workspaceRoots[0] || '';
5479
+ const gitRepo = detectRepo(cwd, '', '', workspaceRoots);
5480
+
5481
+ if (event === 'afterAgentThought') {
5482
+ appendThoughtOverlay(sessionId, text);
5483
+ await pushConversationMessage(sessionId, 'assistant', text, { gitRepo, patchRedacted: true });
5484
+ } else if (event === 'afterAgentResponse') {
5485
+ await pushConversationMessage(sessionId, 'assistant', text, { gitRepo, patchRedacted: false });
5486
+ }
5487
+
5488
+ outputEmpty();
5489
+ } catch {
5490
+ outputEmpty();
5491
+ }
5492
+ }
5493
+
5494
+ main();
5495
+ `;
4855
5496
  }
4856
5497
  });
4857
5498
 
@@ -6793,6 +7434,7 @@ function writeHookScripts() {
6793
7434
  const installScanScriptPath = join8(HOOKS_DIR, "cc-install-scan.ts");
6794
7435
  const cursorBashJudgePath = join8(HOOKS_DIR, "cursor-bash-judge.ts");
6795
7436
  const cursorEditCapturePath = join8(HOOKS_DIR, "cursor-edit-capture.ts");
7437
+ const cursorAgentCapturePath = join8(HOOKS_DIR, "cursor-agent-capture.ts");
6796
7438
  const mcpStdioProxyPath = join8(HOOKS_DIR, "mcp-stdio-proxy.ts");
6797
7439
  writeFileSync7(bashScriptPath, BASH_JUDGE_TS, "utf-8");
6798
7440
  writeFileSync7(bashFollowupScriptPath, BASH_FOLLOWUP_TS, "utf-8");
@@ -6810,8 +7452,9 @@ function writeHookScripts() {
6810
7452
  writeFileSync7(installScanScriptPath, INSTALL_SCAN_TS, "utf-8");
6811
7453
  writeFileSync7(cursorBashJudgePath, CURSOR_BASH_JUDGE_TS, "utf-8");
6812
7454
  writeFileSync7(cursorEditCapturePath, CURSOR_EDIT_CAPTURE_TS, "utf-8");
7455
+ writeFileSync7(cursorAgentCapturePath, CURSOR_AGENT_CAPTURE_TS, "utf-8");
6813
7456
  writeFileSync7(mcpStdioProxyPath, MCP_STDIO_PROXY_SRC, "utf-8");
6814
- writeFileSync7(installExtractCorePath, "/**\n * Deterministic install-command extraction \u2014 no LLM, no network.\n * Shared by the API pkg-scan route and the hook scripts (copied to ~/.synkro/hooks/).\n */\nimport { parse as shellParse } from 'shell-quote';\n\nexport interface DeterministicPkgRequest {\n name: string;\n version: string;\n ecosystem: string;\n}\n\ninterface RawInstall {\n ecosystem: string;\n name: string;\n versionSpec: string | null;\n source: 'registry' | 'git' | 'local' | 'url' | 'unknown';\n}\n\nconst SEPARATOR_OPS = new Set(['&&', '||', ';', '|', '&', '\\n']);\n\n/** Split a shell command into command segments (each an argv string array). */\nexport function segmentCommand(command: string): string[][] {\n let tokens: unknown[];\n try {\n tokens = shellParse(command) as unknown[];\n } catch {\n return [command.split(/\\s+/).filter(Boolean)];\n }\n const segments: string[][] = [];\n let current: string[] = [];\n for (const tok of tokens) {\n if (typeof tok === 'string') {\n current.push(tok);\n continue;\n }\n if (tok && typeof tok === 'object') {\n const op = (tok as { op?: string }).op;\n const pattern = (tok as { pattern?: string }).pattern;\n if (op && SEPARATOR_OPS.has(op)) {\n if (current.length) segments.push(current);\n current = [];\n } else if (typeof pattern === 'string') {\n current.push(pattern);\n }\n }\n }\n if (current.length) segments.push(current);\n return segments;\n}\n\nconst PM_TABLE: Record<string, { subs: Set<string>; ecosystem: string }> = {\n npm: { subs: new Set(['install', 'i', 'add', 'ci']), ecosystem: 'npm' },\n pnpm: { subs: new Set(['add', 'install', 'i', 'dlx']), ecosystem: 'npm' },\n yarn: { subs: new Set(['add', 'install']), ecosystem: 'npm' },\n bun: { subs: new Set(['add', 'install', 'i']), ecosystem: 'npm' },\n pip: { subs: new Set(['install']), ecosystem: 'PyPI' },\n pip3: { subs: new Set(['install']), ecosystem: 'PyPI' },\n cargo: { subs: new Set(['add', 'install']), ecosystem: 'crates.io' },\n go: { subs: new Set(['get', 'install']), ecosystem: 'Go' },\n gem: { subs: new Set(['install']), ecosystem: 'RubyGems' },\n composer: { subs: new Set(['require']), ecosystem: 'Packagist' },\n};\nconst SYSTEM_PMS = new Set(['apt', 'apt-get', 'apk', 'brew', 'dnf', 'yum', 'pacman']);\nconst SYSTEM_SUBS = new Set(['install', 'add']);\n\nconst WRAPPERS = new Set(['sudo', 'doas', 'command', 'env', 'xargs', 'nice', 'time']);\nconst VALUE_FLAGS = new Set([\n '--filter', '-F', '-C', '--dir', '--prefix', '--registry', '--tag', '--features',\n '-v', '--version', '--index-url', '--extra-index-url', '--target', '-t',\n]);\n\nfunction basename(p: string): string {\n const i = p.lastIndexOf('/');\n return i >= 0 ? p.slice(i + 1) : p;\n}\n\nfunction stripPrefixes(argv: string[]): string[] {\n let i = 0;\n while (i < argv.length) {\n const t = argv[i];\n if (WRAPPERS.has(basename(t).toLowerCase())) { i++; continue; }\n if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(t)) { i++; continue; }\n break;\n }\n return argv.slice(i);\n}\n\nfunction looksLikePath(tok: string): boolean {\n return tok === '.' || tok === '..' || /^\\.{0,2}\\//.test(tok) || tok.startsWith('~/') || tok.startsWith('file:');\n}\n\n/** Shell redirect fragments (e.g. `2>&1` \u2192 argv `2`, `1`) \u2014 not package names. */\nfunction isRedirectFragment(tok: string): boolean {\n if (/^\\d+$/.test(tok)) return true;\n if (/^[<>]|[<>]$/.test(tok)) return true;\n if (tok === '&' || tok === '|') return true;\n if (/^\\d*[<>]/.test(tok)) return true;\n return false;\n}\n\nfunction parsePackageToken(tok: string, ecosystem: string): RawInstall | null {\n if (/^(https?:)?\\/\\//.test(tok) || tok.startsWith('git+') || tok.startsWith('git:')) {\n return { ecosystem, name: tok, versionSpec: null, source: tok.includes('git') ? 'git' : 'url' };\n }\n if (looksLikePath(tok)) {\n return { ecosystem, name: basename(tok.replace(/\\/+$/, '')) || tok, versionSpec: null, source: 'local' };\n }\n if (ecosystem === 'PyPI') {\n const noExtras = tok.replace(/\\[[^\\]]*\\]/g, '');\n const m = noExtras.match(/^([A-Za-z0-9_.-]+)\\s*([=~!<>].*)?$/);\n if (!m) return null;\n return { ecosystem, name: m[1], versionSpec: m[2] ? m[2].trim() : null, source: 'registry' };\n }\n const at = tok.lastIndexOf('@');\n if (at > 0) {\n return { ecosystem, name: tok.slice(0, at), versionSpec: tok.slice(at + 1) || null, source: 'registry' };\n }\n return { ecosystem, name: tok, versionSpec: null, source: 'registry' };\n}\n\n/** Deterministic extraction for a single command segment. */\nexport function extractSegment(rawArgv: string[]): RawInstall[] {\n let argv = stripPrefixes(rawArgv);\n if (argv.length < 2) return [];\n let bin = basename(argv[0]).toLowerCase();\n\n if (bin === 'uv' && argv[1] === 'pip') { argv = argv.slice(1); bin = 'pip'; }\n if ((bin === 'python' || bin === 'python3') && argv.includes('-m')) {\n const mi = argv.indexOf('-m');\n if (argv[mi + 1] === 'pip') { argv = ['pip', ...argv.slice(mi + 2)]; bin = 'pip'; }\n }\n\n const isSystem = SYSTEM_PMS.has(bin);\n const entry = PM_TABLE[bin];\n if (!entry && !isSystem) return [];\n const ecosystem = entry ? entry.ecosystem : 'system';\n const subs = entry ? entry.subs : SYSTEM_SUBS;\n\n let subIdx = -1;\n for (let i = 1; i < argv.length; i++) {\n if (subs.has(argv[i].toLowerCase())) { subIdx = i; break; }\n }\n if (subIdx === -1) return [];\n\n const installs: RawInstall[] = [];\n for (let i = subIdx + 1; i < argv.length; i++) {\n const tok = argv[i];\n if (isRedirectFragment(tok)) break;\n if (tok.startsWith('-')) {\n if (VALUE_FLAGS.has(tok)) i++;\n continue;\n }\n const parsed = parsePackageToken(tok, ecosystem);\n if (parsed) installs.push(parsed);\n }\n return installs;\n}\n\nconst ECO_TO_OSV: Record<string, string | null> = {\n npm: 'npm',\n pypi: 'PyPI', PyPI: 'PyPI',\n cargo: 'crates.io', 'crates.io': 'crates.io',\n go: 'Go', Go: 'Go',\n rubygems: 'RubyGems', RubyGems: 'RubyGems',\n packagist: 'Packagist', Packagist: 'Packagist',\n maven: 'Maven', Maven: 'Maven',\n nuget: 'NuGet', NuGet: 'NuGet',\n apt: null, brew: null, system: null, other: null,\n};\n\nfunction normalizeName(name: string, osvEco: string): string {\n const n = name.trim();\n if (osvEco === 'npm') return n.toLowerCase();\n if (osvEco === 'PyPI') return n.toLowerCase().replace(/[-_.]+/g, '-');\n return n;\n}\n\nfunction concretePin(spec: string | null): string | null {\n if (!spec) return null;\n const c = spec.trim().replace(/^[v=\\s]+/, '');\n if (c.toLowerCase() === 'latest' || c === '') return null;\n if (/[\\^~><|*\\sx]/i.test(c)) return null;\n return /^\\d[\\w.\\-+]*$/.test(c) ? c : null;\n}\n\n/**\n * Parse registry installs from a shell command without LLM/network.\n * Unpinned versions use '*' so OSV scans the full advisory history.\n */\nexport function extractDeterministicPkgRequests(command: string): DeterministicPkgRequest[] {\n const merged = new Map<string, DeterministicPkgRequest>();\n for (const r of segmentCommand(command).flatMap(extractSegment)) {\n if (r.source !== 'registry') continue;\n const osvEco = ECO_TO_OSV[r.ecosystem] ?? ECO_TO_OSV[r.ecosystem.toLowerCase()] ?? null;\n if (!osvEco) continue;\n const name = normalizeName(r.name, osvEco);\n if (!name) continue;\n const key = osvEco + '|' + name.toLowerCase();\n const version = concretePin(r.versionSpec) ?? '*';\n const prev = merged.get(key);\n if (!prev || (prev.version === '*' && version !== '*')) {\n merged.set(key, { name, version, ecosystem: osvEco });\n }\n }\n return [...merged.values()];\n}\n", "utf-8");
7457
+ writeFileSync7(installExtractCorePath, "/**\n * Deterministic install-command extraction \u2014 no LLM, no network.\n * Shared by the API pkg-scan route and the hook scripts (copied to ~/.synkro/hooks/).\n */\nimport { parse as shellParse } from 'shell-quote';\n\nexport interface DeterministicPkgRequest {\n name: string;\n version: string;\n ecosystem: string;\n}\n\ninterface RawInstall {\n ecosystem: string;\n name: string;\n versionSpec: string | null;\n source: 'registry' | 'git' | 'local' | 'url' | 'unknown';\n}\n\nconst SEPARATOR_OPS = new Set(['&&', '||', ';', '|', '&', '\\n']);\n\n/** Split a shell command into command segments (each an argv string array). */\nexport function segmentCommand(command: string): string[][] {\n let tokens: unknown[];\n try {\n tokens = shellParse(command) as unknown[];\n } catch {\n return [command.split(/\\s+/).filter(Boolean)];\n }\n const segments: string[][] = [];\n let current: string[] = [];\n for (const tok of tokens) {\n if (typeof tok === 'string') {\n current.push(tok);\n continue;\n }\n if (tok && typeof tok === 'object') {\n const op = (tok as { op?: string }).op;\n const pattern = (tok as { pattern?: string }).pattern;\n if (op && SEPARATOR_OPS.has(op)) {\n if (current.length) segments.push(current);\n current = [];\n } else if (typeof pattern === 'string') {\n current.push(pattern);\n }\n }\n }\n if (current.length) segments.push(current);\n return segments;\n}\n\nconst PM_TABLE: Record<string, { subs: Set<string>; ecosystem: string }> = {\n npm: { subs: new Set(['install', 'i', 'add', 'ci']), ecosystem: 'npm' },\n pnpm: { subs: new Set(['add', 'install', 'i', 'dlx']), ecosystem: 'npm' },\n yarn: { subs: new Set(['add', 'install']), ecosystem: 'npm' },\n bun: { subs: new Set(['add', 'install', 'i']), ecosystem: 'npm' },\n pip: { subs: new Set(['install']), ecosystem: 'PyPI' },\n pip3: { subs: new Set(['install']), ecosystem: 'PyPI' },\n cargo: { subs: new Set(['add', 'install']), ecosystem: 'crates.io' },\n go: { subs: new Set(['get', 'install']), ecosystem: 'Go' },\n gem: { subs: new Set(['install']), ecosystem: 'RubyGems' },\n composer: { subs: new Set(['require']), ecosystem: 'Packagist' },\n};\nconst SYSTEM_PMS = new Set(['apt', 'apt-get', 'apk', 'brew', 'dnf', 'yum', 'pacman']);\nconst SYSTEM_SUBS = new Set(['install', 'add']);\n\nconst WRAPPERS = new Set(['sudo', 'doas', 'command', 'env', 'xargs', 'nice', 'time']);\nconst VALUE_FLAGS = new Set([\n '--filter', '-F', '-C', '--dir', '--prefix', '--registry', '--tag', '--features',\n '-v', '--version', '--index-url', '--extra-index-url', '--target', '-t',\n]);\n\nfunction basename(p: string): string {\n const i = p.lastIndexOf('/');\n return i >= 0 ? p.slice(i + 1) : p;\n}\n\nfunction stripPrefixes(argv: string[]): string[] {\n let i = 0;\n while (i < argv.length) {\n const t = argv[i];\n if (WRAPPERS.has(basename(t).toLowerCase())) { i++; continue; }\n if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(t)) { i++; continue; }\n break;\n }\n return argv.slice(i);\n}\n\nfunction looksLikePath(tok: string): boolean {\n return tok === '.' || tok === '..' || /^\\.{0,2}\\//.test(tok) || tok.startsWith('~/') || tok.startsWith('file:');\n}\n\n/** Shell redirect fragments (e.g. `2>&1` \u2192 argv `2`, `1`) \u2014 not package names. */\nfunction isRedirectFragment(tok: string): boolean {\n if (/^\\d+$/.test(tok)) return true;\n if (/^[<>]|[<>]$/.test(tok)) return true;\n if (tok === '&' || tok === '|') return true;\n if (/^\\d*[<>]/.test(tok)) return true;\n return false;\n}\n\nfunction parsePackageToken(tok: string, ecosystem: string): RawInstall | null {\n if (/^(https?:)?\\/\\//.test(tok) || tok.startsWith('git+') || tok.startsWith('git:')) {\n return { ecosystem, name: tok, versionSpec: null, source: tok.includes('git') ? 'git' : 'url' };\n }\n if (looksLikePath(tok)) {\n return { ecosystem, name: basename(tok.replace(/\\/+$/, '')) || tok, versionSpec: null, source: 'local' };\n }\n if (ecosystem === 'PyPI') {\n const noExtras = tok.replace(/\\[[^\\]]*\\]/g, '');\n const m = noExtras.match(/^([A-Za-z0-9_.-]+)\\s*([=~!<>].*)?$/);\n if (!m) return null;\n return { ecosystem, name: m[1], versionSpec: m[2] ? m[2].trim() : null, source: 'registry' };\n }\n const at = tok.lastIndexOf('@');\n if (at > 0) {\n return { ecosystem, name: tok.slice(0, at), versionSpec: tok.slice(at + 1) || null, source: 'registry' };\n }\n return { ecosystem, name: tok, versionSpec: null, source: 'registry' };\n}\n\n/** Deterministic extraction for a single command segment. */\nexport function extractSegment(rawArgv: string[]): RawInstall[] {\n let argv = stripPrefixes(rawArgv);\n if (argv.length < 2) return [];\n let bin = basename(argv[0]).toLowerCase();\n\n if (bin === 'uv' && argv[1] === 'pip') { argv = argv.slice(1); bin = 'pip'; }\n if ((bin === 'python' || bin === 'python3') && argv.includes('-m')) {\n const mi = argv.indexOf('-m');\n if (argv[mi + 1] === 'pip') { argv = ['pip', ...argv.slice(mi + 2)]; bin = 'pip'; }\n }\n\n const isSystem = SYSTEM_PMS.has(bin);\n const entry = PM_TABLE[bin];\n if (!entry && !isSystem) return [];\n const ecosystem = entry ? entry.ecosystem : 'system';\n const subs = entry ? entry.subs : SYSTEM_SUBS;\n\n let subIdx = -1;\n for (let i = 1; i < argv.length; i++) {\n if (subs.has(argv[i].toLowerCase())) { subIdx = i; break; }\n }\n if (subIdx === -1) return [];\n\n const installs: RawInstall[] = [];\n for (let i = subIdx + 1; i < argv.length; i++) {\n const tok = argv[i];\n if (isRedirectFragment(tok)) break;\n if (tok.startsWith('-')) {\n if (VALUE_FLAGS.has(tok)) i++;\n continue;\n }\n const parsed = parsePackageToken(tok, ecosystem);\n if (parsed) installs.push(parsed);\n }\n return installs;\n}\n\nconst ECO_TO_OSV: Record<string, string | null> = {\n npm: 'npm',\n pypi: 'PyPI', PyPI: 'PyPI',\n cargo: 'crates.io', 'crates.io': 'crates.io',\n go: 'Go', Go: 'Go',\n rubygems: 'RubyGems', RubyGems: 'RubyGems',\n packagist: 'Packagist', Packagist: 'Packagist',\n maven: 'Maven', Maven: 'Maven',\n nuget: 'NuGet', NuGet: 'NuGet',\n apt: null, brew: null, system: null, other: null,\n};\n\nfunction normalizeName(name: string, osvEco: string): string {\n const n = name.trim();\n if (osvEco === 'npm') return n.toLowerCase();\n if (osvEco === 'PyPI') return n.toLowerCase().replace(/[-_.]+/g, '-');\n return n;\n}\n\nfunction concretePin(spec: string | null): string | null {\n if (!spec) return null;\n const c = spec.trim().replace(/^[v=\\s]+/, '');\n if (c.toLowerCase() === 'latest' || c === '') return null;\n if (/[\\^~><|*\\sx]/i.test(c)) return null;\n return /^\\d[\\w.\\-+]*$/.test(c) ? c : null;\n}\n\nconst PKG_JSON_DEP_FIELDS = ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'];\n\nfunction safeParseObject(text: string): Record<string, any> | null {\n try {\n const v = JSON.parse(text);\n return v && typeof v === 'object' && !Array.isArray(v) ? v : null;\n } catch {\n return null;\n }\n}\n\n/**\n * Diff two package.json contents and return the registry packages that are\n * newly added or whose version spec changed in the new content. The caller\n * scans these against the vuln DB before letting the edit land \u2014 so a bare\n * `pnpm install` afterwards has nothing left to vet. Non-registry sources\n * (file:, link:, workspace:, git, http, relative paths) are skipped.\n */\nexport function extractPackageJsonDelta(oldText: string, newText: string): DeterministicPkgRequest[] {\n const newJson = safeParseObject(newText);\n if (!newJson) return [];\n const oldJson = safeParseObject(oldText) || {};\n\n const out = new Map<string, DeterministicPkgRequest>();\n for (const field of PKG_JSON_DEP_FIELDS) {\n const oldDeps = (oldJson[field] && typeof oldJson[field] === 'object') ? oldJson[field] : {};\n const newDeps = (newJson[field] && typeof newJson[field] === 'object') ? newJson[field] : {};\n for (const [rawName, version] of Object.entries(newDeps)) {\n if (typeof version !== 'string') continue;\n if (oldDeps[rawName] === version) continue;\n const spec = version.trim();\n if (\n spec.startsWith('file:') || spec.startsWith('link:') ||\n spec.startsWith('http') || spec.startsWith('git') ||\n spec.startsWith('workspace:') || spec.startsWith('catalog:') ||\n spec.startsWith('npm:') ||\n spec.startsWith('./') || spec.startsWith('../') ||\n spec === '' || spec === '*'\n ) continue;\n const name = rawName.toLowerCase();\n out.set(name, {\n name,\n version: concretePin(spec) ?? '*',\n ecosystem: 'npm',\n });\n }\n }\n return [...out.values()];\n}\n\n/**\n * Parse registry installs from a shell command without LLM/network.\n * Unpinned versions use '*' so OSV scans the full advisory history.\n */\nexport function extractDeterministicPkgRequests(command: string): DeterministicPkgRequest[] {\n const merged = new Map<string, DeterministicPkgRequest>();\n for (const r of segmentCommand(command).flatMap(extractSegment)) {\n if (r.source !== 'registry') continue;\n const osvEco = ECO_TO_OSV[r.ecosystem] ?? ECO_TO_OSV[r.ecosystem.toLowerCase()] ?? null;\n if (!osvEco) continue;\n const name = normalizeName(r.name, osvEco);\n if (!name) continue;\n const key = osvEco + '|' + name.toLowerCase();\n const version = concretePin(r.versionSpec) ?? '*';\n const prev = merged.get(key);\n if (!prev || (prev.version === '*' && version !== '*')) {\n merged.set(key, { name, version, ecosystem: osvEco });\n }\n }\n return [...merged.values()];\n}\n", "utf-8");
6815
7458
  const hooksPkgPath = join8(HOOKS_DIR, "package.json");
6816
7459
  writeFileSync7(hooksPkgPath, JSON.stringify({
6817
7460
  name: "synkro-hooks",
@@ -6839,6 +7482,7 @@ function writeHookScripts() {
6839
7482
  chmodSync2(installScanScriptPath, 493);
6840
7483
  chmodSync2(cursorBashJudgePath, 493);
6841
7484
  chmodSync2(cursorEditCapturePath, 493);
7485
+ chmodSync2(cursorAgentCapturePath, 493);
6842
7486
  chmodSync2(mcpStdioProxyPath, 493);
6843
7487
  chmodSync2(installExtractCorePath, 493);
6844
7488
  return {
@@ -6855,7 +7499,8 @@ function writeHookScripts() {
6855
7499
  userPromptSubmitScript: userPromptSubmitScriptPath,
6856
7500
  installScanScript: installScanScriptPath,
6857
7501
  cursorBashJudgeScript: cursorBashJudgePath,
6858
- cursorEditCaptureScript: cursorEditCapturePath
7502
+ cursorEditCaptureScript: cursorEditCapturePath,
7503
+ cursorAgentCaptureScript: cursorAgentCapturePath
6859
7504
  };
6860
7505
  }
6861
7506
  function sanitizeConfigValue(raw, maxLen = 256) {
@@ -6887,7 +7532,7 @@ function writeConfigEnv(opts) {
6887
7532
  `SYNKRO_CREDENTIALS_PATH=${shellQuoteSingle(credsPath)}`,
6888
7533
  `SYNKRO_TIER=${shellQuoteSingle(safeTier)}`,
6889
7534
  `SYNKRO_INFERENCE=${shellQuoteSingle(safeInference)}`,
6890
- `SYNKRO_VERSION=${shellQuoteSingle("1.6.23")}`
7535
+ `SYNKRO_VERSION=${shellQuoteSingle("1.6.25")}`
6891
7536
  ];
6892
7537
  if (safeSynkroBin) lines.push(`SYNKRO_CLI_BIN=${shellQuoteSingle(safeSynkroBin)}`);
6893
7538
  if (safeUserId) lines.push(`SYNKRO_USER_ID=${shellQuoteSingle(safeUserId)}`);
@@ -7138,6 +7783,7 @@ async function installCommand(opts = {}) {
7138
7783
  installCursorHooks(agent.settingsPath, {
7139
7784
  bashJudgeScriptPath: scripts.cursorBashJudgeScript,
7140
7785
  editCaptureScriptPath: scripts.cursorEditCaptureScript,
7786
+ agentCaptureScriptPath: scripts.cursorAgentCaptureScript,
7141
7787
  bashFollowupScriptPath: scripts.bashFollowupScript,
7142
7788
  editPrecheckScriptPath: scripts.editPrecheckScript,
7143
7789
  cwePrecheckScriptPath: scripts.cwePrecheckScript,
@@ -9554,7 +10200,7 @@ var args = process.argv.slice(2);
9554
10200
  var cmd = args[0] || "";
9555
10201
  var subArgs = args.slice(1);
9556
10202
  function printVersion() {
9557
- console.log("1.6.23");
10203
+ console.log("1.6.25");
9558
10204
  }
9559
10205
  function printHelp2() {
9560
10206
  console.log(`Synkro CLI \u2014 runtime safety for AI coding agents