@synkro-sh/cli 1.6.22 → 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";
@@ -1297,8 +1313,7 @@ export function ruleFilterText(action: string, userMessage?: string | null): str
1297
1313
  const act = (action || '').trim();
1298
1314
  if (!user) return act.slice(0, 4000);
1299
1315
  if (!act) return user.slice(0, 4000);
1300
- return (user + '
1301
- ' + act).slice(0, 4000);
1316
+ return (user + '\\n' + act).slice(0, 4000);
1302
1317
  }
1303
1318
 
1304
1319
  export async function filterRules(commandText: string, allRules: Rule[]): Promise<Rule[]> {
@@ -1475,15 +1490,21 @@ export async function runInstallScan(command: string, jwt: string): Promise<Inst
1475
1490
  .flatMap((p: any) => (p.signals || []))
1476
1491
  .filter((s: any) => s.type === 'cve').length;
1477
1492
  const findings: InstallScanResult['findings'] = [];
1493
+ const seenFindingKeys = new Set<string>();
1478
1494
  for (const p of pkgResults) {
1479
1495
  for (const s of (p.signals || [])) {
1480
- if (s.severity === 'critical' || s.severity === 'high') {
1481
- 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);
1482
- findings.push({
1483
- advisoryId: advisoryMatch ? advisoryMatch[1] : s.type,
1484
- name: p.name, version: p.version, severity: s.severity, detail: s.detail,
1485
- });
1486
- }
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
+ });
1487
1508
  }
1488
1509
  }
1489
1510
  // Preview the top 5 detail lines; the headline carries the true total.
@@ -1742,11 +1763,13 @@ export function dispatchCapture(
1742
1763
  violatedRules?: string[];
1743
1764
  recentUserMessages?: string[];
1744
1765
  ccModel?: string;
1766
+ linesAdded?: number;
1767
+ linesRemoved?: number;
1745
1768
  },
1746
1769
  ): void {
1747
1770
  // Fire-and-forget
1748
1771
  const eventId = 'evt_' + Date.now() + '_' + process.pid;
1749
- const model = opts?.ccModel || 'unknown';
1772
+ const model = normalizeCaptureModel(opts?.ccModel || 'unknown');
1750
1773
 
1751
1774
  const body: Record<string, any> = {
1752
1775
  capture_type: 'local_verdict',
@@ -1775,6 +1798,8 @@ export function dispatchCapture(
1775
1798
  if (opts.rulesChecked) localBody.rules_checked = opts.rulesChecked;
1776
1799
  if (opts.violatedRules) localBody.violated_rules = opts.violatedRules;
1777
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;
1778
1803
  }
1779
1804
  appendLocalTelemetry(localBody);
1780
1805
 
@@ -1935,7 +1960,7 @@ export function reconstructContent(toolName: string, toolInput: any, filePath: s
1935
1960
  case 'apply_patch':
1936
1961
  return contentFromPatch(patchTextFromToolInput(toolInput));
1937
1962
  case 'Write':
1938
- return toolInput.content || '';
1963
+ return toolInput.content || toolInput.contents || '';
1939
1964
  case 'edit_file':
1940
1965
  case 'reapply':
1941
1966
  return toolInput.content || toolInput.new_string || toolInput.code_edit || '';
@@ -2029,7 +2054,303 @@ export async function readStdin(): Promise<string> {
2029
2054
  return Buffer.concat(chunks).toString('utf-8');
2030
2055
  }
2031
2056
 
2032
- // \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
+ }
2033
2354
 
2034
2355
  export interface TranscriptContext {
2035
2356
  userIntent: string;
@@ -2068,11 +2389,8 @@ export function extractTranscript(transcriptPath: string | undefined): Transcrip
2068
2389
  // Recent user messages (last 5)
2069
2390
  const userMsgs: string[] = [];
2070
2391
  for (const entry of parsed) {
2071
- if (entry.type !== 'user') continue;
2072
- const content = entry.message?.content;
2073
- let text = '';
2074
- if (typeof content === 'string') text = content;
2075
- else if (Array.isArray(content)) text = content.map((c: any) => c.text || '').join(' ');
2392
+ if (transcriptEntryType(entry) !== 'user') continue;
2393
+ const text = extractTranscriptEntryText(entry);
2076
2394
  if (text) userMsgs.push(text);
2077
2395
  }
2078
2396
  ctx.recentUserMessages = userMsgs.slice(-5);
@@ -2081,26 +2399,25 @@ export function extractTranscript(transcriptPath: string | undefined): Transcrip
2081
2399
  // Recent messages (last 10, user + assistant)
2082
2400
  const msgs: Array<{ type: string; text: string }> = [];
2083
2401
  for (const entry of parsed) {
2084
- if (entry.type !== 'user' && entry.type !== 'assistant') continue;
2085
- const content = entry.message?.content;
2086
- let text = '';
2087
- if (typeof content === 'string') text = content.slice(0, 500);
2088
- else if (Array.isArray(content)) text = content.map((c: any) => (c.text || '').slice(0, 300)).join(' ');
2089
- 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 });
2090
2406
  }
2091
2407
  ctx.recentMessages = msgs.slice(-10);
2092
2408
 
2093
2409
  // Recent tool calls (last 5)
2094
2410
  const actions: Array<{ tool: string; input: string }> = [];
2095
2411
  for (const entry of parsed) {
2096
- if (entry.type !== 'assistant') continue;
2097
- const content = entry.message?.content;
2412
+ if (transcriptEntryType(entry) !== 'assistant') continue;
2413
+ const msg = entry.message;
2414
+ const content = msg?.content ?? entry.content;
2098
2415
  if (!Array.isArray(content)) continue;
2099
2416
  for (const block of content) {
2100
- if (block.type !== 'tool_use') continue;
2417
+ if (block.type !== 'tool_use' && block.type !== 'tool_call') continue;
2101
2418
  actions.push({
2102
2419
  tool: block.name || '',
2103
- input: JSON.stringify(block.input || {}).slice(0, 200),
2420
+ input: JSON.stringify(block.input || block.arguments || {}).slice(0, 200),
2104
2421
  });
2105
2422
  }
2106
2423
  }
@@ -2114,7 +2431,7 @@ export function extractTranscript(transcriptPath: string | undefined): Transcrip
2114
2431
  }
2115
2432
 
2116
2433
  // CC model
2117
- const assistantEntries = parsed.filter(e => e.type === 'assistant');
2434
+ const assistantEntries = parsed.filter(e => transcriptEntryType(e) === 'assistant');
2118
2435
  if (assistantEntries.length > 0) {
2119
2436
  const last = assistantEntries[assistantEntries.length - 1];
2120
2437
  ctx.ccModel = last.message?.model || '';
@@ -2209,39 +2526,254 @@ export function hashCommand(cmd: string): string {
2209
2526
  return createHash('sha256').update(cmd).digest('hex').slice(0, 16);
2210
2527
  }
2211
2528
 
2212
- // \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
+ }
2213
2586
 
2214
- export function aggregateUsage(transcriptPath: string): { model: string; totals: Record<string, number> } {
2215
- 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 } };
2216
2649
  if (!transcriptPath || !existsSync(transcriptPath)) return result;
2217
2650
  try {
2218
2651
  const raw = readFileSync(transcriptPath, 'utf-8');
2219
- const lines = raw.split('\\n').filter(l => l.trim());
2220
- for (const line of lines) {
2221
- try {
2222
- const entry = JSON.parse(line);
2223
- if (entry.type !== 'assistant') continue;
2224
- result.model = entry.message?.model || result.model;
2225
- const u = entry.message?.usage;
2226
- if (u) {
2227
- result.totals.in += u.input_tokens || 0;
2228
- result.totals.out += u.output_tokens || 0;
2229
- result.totals.cw += u.cache_creation_input_tokens || 0;
2230
- result.totals.cr += u.cache_read_input_tokens || 0;
2231
- }
2232
- } 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;
2233
2689
  }
2234
2690
  } catch {}
2235
2691
  return result;
2236
2692
  }
2237
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
+
2238
2734
  // \u2500\u2500\u2500 Scan Finding Dispatch \u2500\u2500\u2500
2239
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
+
2240
2771
  export function dispatchFinding(
2241
2772
  jwt: string,
2242
2773
  finding: {
2243
2774
  session_id: string;
2244
2775
  file_path: string;
2776
+ repo?: string;
2245
2777
  finding_type: 'cwe' | 'cve';
2246
2778
  finding_id: string;
2247
2779
  severity?: string;
@@ -2271,6 +2803,7 @@ export function dispatchFinding(
2271
2803
  status: finding.status,
2272
2804
  session_id: finding.session_id,
2273
2805
  file_path: finding.file_path,
2806
+ repo: finding.repo,
2274
2807
  package_name: finding.package_name,
2275
2808
  package_version: finding.package_version,
2276
2809
  fixed_version: finding.fixed_version,
@@ -2412,11 +2945,12 @@ import {
2412
2945
  readStdin, extractTranscript, readLastPrompt, findNearestDeps, filePathFromToolInput,
2413
2946
  appendSessionAction, readSessionLog, compressSessionLog, log,
2414
2947
  outputJson, outputEmpty, setupCursorHookSignals, isEditTool, hookSessionId, GATEWAY_URL,
2415
- logGraderUnavailable, filterRules, ruleFilterText, normalizeMode,
2948
+ logGraderUnavailable, filterRules, ruleFilterText, normalizeMode, countEditLineDelta,
2949
+ captureLineMetrics, cursorModelFromPayload,
2416
2950
  type HookConfig, type Rule,
2417
2951
  } from './_synkro-common.ts';
2418
2952
  import { existsSync, readFileSync } from 'node:fs';
2419
- import { basename, dirname, join } from 'node:path';
2953
+ import { basename, join } from 'node:path';
2420
2954
 
2421
2955
  const agentKind = process.env.SYNKRO_HOOK_FORMAT === 'cursor' ? 'cursor' : 'claude_code';
2422
2956
 
@@ -2439,7 +2973,7 @@ async function main() {
2439
2973
  const workspaceRoots = Array.isArray(payload.workspace_roots) ? payload.workspace_roots as string[] : [];
2440
2974
  const cwd = payload.cwd || workspaceRoots[0] || '';
2441
2975
  const permissionMode = payload.permission_mode || '';
2442
- const transcriptPath = payload.transcript_path || '';
2976
+ const transcriptPath = resolveTranscriptPath(payload);
2443
2977
 
2444
2978
  const filePath = filePathFromToolInput(toolInput);
2445
2979
  if (!filePath) { outputEmpty(); return; }
@@ -2470,21 +3004,44 @@ async function main() {
2470
3004
  if (toolInput.edits != null) diffField.edits = toolInput.edits;
2471
3005
  }
2472
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
+
2473
3028
  // Read file before edit for cloud payload
2474
3029
  let fileBefore = '';
2475
- if (toolName !== 'Write' && filePath && isPathUnder(filePath, cwd || '.') && existsSync(filePath)) {
2476
- 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 {}
2477
3032
  }
2478
3033
 
2479
3034
  // Extract transcript context
2480
3035
  const transcript = extractTranscript(transcriptPath);
2481
3036
  const lastPrompt = readLastPrompt(sessionId);
2482
3037
 
3038
+ const captureModel = agentKind === 'cursor'
3039
+ ? cursorModelFromPayload(payload)
3040
+ : (transcript.ccModel || String(payload.model ?? payload.model_id ?? ''));
3041
+
2483
3042
  // Model detection: prefer transcript (CC), fall back to payload (Cursor)
2484
3043
  if (!transcript.ccModel) {
2485
- const rawModel = String(payload.model ?? payload.model_id ?? '');
2486
- 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']);
2487
- 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;
2488
3045
  }
2489
3046
 
2490
3047
  // Load config and decide route
@@ -2543,8 +3100,8 @@ async function main() {
2543
3100
  dispatchCapture(jwt, 'edit', 'block', verdict.severity || 'critical', verdict.category || 'security',
2544
3101
  toolName, gitRepo, sessionId, config.captureDepth, {
2545
3102
  command: editContent, reasoning: guardReason,
2546
- rulesChecked: config.rules, violatedRules,
2547
- ccModel: transcript.ccModel,
3103
+ rulesChecked: relevantRules, violatedRules,
3104
+ ccModel: captureModel, ...lineMetrics,
2548
3105
  });
2549
3106
  outputJson({
2550
3107
  systemMessage: tagStr + ' editGuard ' + fileShort + ' \u2192 blocked: ' + guardReason,
@@ -2557,8 +3114,8 @@ async function main() {
2557
3114
  dispatchCapture(jwt, 'edit', 'pass', 'clean', verdict.category || 'trivial_edit',
2558
3115
  toolName, gitRepo, sessionId, config.captureDepth, {
2559
3116
  command: editContent, reasoning: verdict.reason || 'no policy violations detected',
2560
- rulesChecked: config.rules, violatedRules: [],
2561
- ccModel: transcript.ccModel,
3117
+ rulesChecked: relevantRules, violatedRules: [],
3118
+ ccModel: captureModel, ...lineMetrics,
2562
3119
  });
2563
3120
  const passLine = tagStr + ' editGuard ' + fileShort + ' \u2192 pass: ' + (verdict.reason || 'no policy violations detected');
2564
3121
  outputJson({ systemMessage: passLine, hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: 'Synkro local edit judge. ' + (verdict.reason || 'no policy violations detected') } });
@@ -2637,7 +3194,7 @@ main();
2637
3194
  import {
2638
3195
  loadJwt, ensureFreshJwt, detectRepo, loadConfig, cweRoute, tag,
2639
3196
  localGradeCwe, parseVerdict, reconstructContent, readStdin, log,
2640
- outputJson, outputEmpty, setupCursorHookSignals, isEditTool, hookSessionId, filePathFromToolInput, dispatchFinding, dispatchCapture, GATEWAY_URL,
3197
+ outputJson, outputEmpty, setupCursorHookSignals, isEditTool, hookSessionId, filePathFromToolInput, emitBlockScanFindings, dispatchFinding, dispatchCapture, GATEWAY_URL,
2641
3198
  logGraderUnavailable,
2642
3199
  } from './_synkro-common.ts';
2643
3200
  import { basename, extname, resolve, join, dirname } from 'node:path';
@@ -2645,6 +3202,12 @@ import { readFileSync, readdirSync, existsSync } from 'node:fs';
2645
3202
 
2646
3203
  const agentKind = process.env.SYNKRO_HOOK_FORMAT === 'cursor' ? 'cursor' : 'claude_code';
2647
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
+
2648
3211
  interface PackageCapability {
2649
3212
  name: string;
2650
3213
  description: string;
@@ -2768,11 +3331,12 @@ async function main() {
2768
3331
  const sessionId = hookSessionId(payload);
2769
3332
  const workspaceRoots = Array.isArray(payload.workspace_roots) ? payload.workspace_roots as string[] : [];
2770
3333
  const cwd = payload.cwd || workspaceRoots[0] || '';
2771
- const transcriptPath = payload.transcript_path || '';
3334
+ const transcriptPath = resolveTranscriptPath(payload);
2772
3335
 
2773
3336
  const filePath = filePathFromToolInput(toolInput);
2774
3337
  if (!filePath) { outputEmpty(); return; }
2775
3338
  const gitRepo = detectRepo(cwd, transcriptPath, filePath, workspaceRoots);
3339
+ const ccModel = detectModel(payload);
2776
3340
 
2777
3341
  if (filePath.includes('/.synkro/hooks/')) { outputEmpty(); return; }
2778
3342
 
@@ -2982,24 +3546,31 @@ async function main() {
2982
3546
  const fixHint = fixLines.length > 0 ? '\n' + fixLines.join('\n') : '';
2983
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.';
2984
3548
 
2985
- for (const cweId of activeCweIds) {
2986
- dispatchFinding(jwt, {
2987
- session_id: sessionId,
2988
- file_path: filePath,
2989
- 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,
2990
3555
  finding_id: cweId,
2991
3556
  severity: verdict.severity || 'high',
2992
- status: 'open',
2993
3557
  detail: verdict.reason || 'code weakness detected',
2994
3558
  cwe_name: cweNameMap.get(cweId.toUpperCase()) || undefined,
2995
- }, config.captureDepth);
2996
- }
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
+ );
2997
3567
 
2998
3568
  dispatchCapture(jwt, 'cwe', 'block', verdict.severity || 'high', verdict.category || 'security',
2999
3569
  toolName, gitRepo, sessionId, config.captureDepth, {
3000
3570
  command: 'edit ' + filePath,
3001
3571
  reasoning: denyDetail,
3002
3572
  violatedRules: activeCweIds,
3573
+ ccModel,
3003
3574
  });
3004
3575
 
3005
3576
  outputJson({
@@ -3077,25 +3648,34 @@ async function main() {
3077
3648
  const denyDetail = '[' + displayIds + '] ' + (findings[0]?.reason || 'code weakness detected');
3078
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.';
3079
3650
 
3080
- for (const cweId of activeCweIds) {
3081
- const f = findings.find((x: any) => x.cwe === cweId);
3082
- dispatchFinding(jwt, {
3083
- session_id: sessionId,
3084
- 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
+ {
3085
3666
  finding_type: 'cwe',
3086
- finding_id: cweId,
3087
- severity: f?.severity || 'high',
3088
- status: 'open',
3089
- detail: f?.reason || 'code weakness detected',
3090
- cwe_name: f?.name || undefined,
3091
- }, config.captureDepth);
3092
- }
3667
+ finding_id: activeCweIds[0] || 'CWE-UNKNOWN',
3668
+ severity: findings[0]?.severity || 'high',
3669
+ detail: denyDetail,
3670
+ },
3671
+ );
3093
3672
 
3094
3673
  dispatchCapture(jwt, 'cwe', 'block', findings[0]?.severity || 'high', 'security',
3095
3674
  toolName, gitRepo, sessionId, config.captureDepth, {
3096
3675
  command: 'edit ' + filePath,
3097
3676
  reasoning: denyDetail,
3098
3677
  violatedRules: activeCweIds,
3678
+ ccModel,
3099
3679
  });
3100
3680
 
3101
3681
  outputJson({
@@ -3127,9 +3707,10 @@ main();
3127
3707
  import {
3128
3708
  loadJwt, ensureFreshJwt, detectRepo, loadConfig, route, tag,
3129
3709
  reconstructContent, readStdin, findNearestDeps, filePathFromToolInput, log,
3130
- outputJson, outputEmpty, setupCursorHookSignals, isEditTool, hookSessionId, dispatchFinding, dispatchCapture, GATEWAY_URL,
3710
+ outputJson, outputEmpty, setupCursorHookSignals, isEditTool, hookSessionId, dispatchFinding, dispatchCapture, extractTranscript, emitBlockScanFindings, GATEWAY_URL,
3131
3711
  } from './_synkro-common.ts';
3132
3712
  import { basename } from 'node:path';
3713
+ import { readFileSync } from 'node:fs';
3133
3714
 
3134
3715
  const MANIFEST_NAMES = new Set([
3135
3716
  'package.json', 'requirements.txt', 'requirements-dev.txt', 'requirements-test.txt',
@@ -3161,7 +3742,7 @@ async function main() {
3161
3742
  const sessionId = hookSessionId(payload);
3162
3743
  const workspaceRoots = Array.isArray(payload.workspace_roots) ? payload.workspace_roots as string[] : [];
3163
3744
  const cwd = payload.cwd || workspaceRoots[0] || '';
3164
- const transcriptPath = payload.transcript_path || '';
3745
+ const transcriptPath = resolveTranscriptPath(payload);
3165
3746
 
3166
3747
  const filePath = filePathFromToolInput(toolInput);
3167
3748
  if (!filePath) { outputEmpty(); return; }
@@ -3200,6 +3781,97 @@ async function main() {
3200
3781
  deps = findNearestDeps(filePath);
3201
3782
  }
3202
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
+
3203
3875
  // CVE scan via OSV API
3204
3876
  const cveBody = {
3205
3877
  file_path: filePath,
@@ -3223,15 +3895,12 @@ async function main() {
3223
3895
 
3224
3896
  const findings = Array.isArray(cveResp?.findings) ? cveResp.findings : [];
3225
3897
  if (findings.length > 0) {
3226
- for (const f of findings.slice(0, 10)) {
3898
+ const cveRows = findings.slice(0, 10).map((f: any) => {
3227
3899
  const cveId = (f.aliases || []).find((a: string) => a.startsWith('CVE-')) || f.id || 'unknown';
3228
- dispatchFinding(jwt, {
3229
- session_id: sessionId,
3230
- file_path: filePath,
3231
- finding_type: 'cve',
3900
+ return {
3901
+ finding_type: 'cve' as const,
3232
3902
  finding_id: cveId,
3233
3903
  severity: f.severity || 'high',
3234
- status: 'open',
3235
3904
  detail: f.summary || f.title || 'vulnerable dependency',
3236
3905
  description: f.details || undefined,
3237
3906
  package_name: f.package || undefined,
@@ -3239,8 +3908,15 @@ async function main() {
3239
3908
  fixed_version: f.fixed || undefined,
3240
3909
  aliases: f.aliases || undefined,
3241
3910
  references: f.references || undefined,
3242
- }, config.captureDepth);
3243
- }
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
+ );
3244
3920
 
3245
3921
  const formatFinding = (f: any): string => {
3246
3922
  const id = (f.aliases || []).find((a: string) => a.startsWith('CVE-')) || f.id || '?';
@@ -3286,8 +3962,9 @@ main();
3286
3962
  INSTALL_SCAN_TS = `#!/usr/bin/env bun
3287
3963
  import {
3288
3964
  loadJwt, ensureFreshJwt, detectRepo, loadConfig, route, tag,
3289
- readStdin, runInstallScan, dispatchFinding, dispatchCapture, hashCommand,
3965
+ readStdin, runInstallScan, emitBlockScanFindings, dispatchCapture, hashCommand,
3290
3966
  outputJson, outputEmpty, setupCursorHookSignals, isShellTool, hookSessionId, log, GATEWAY_URL,
3967
+ resolveTranscriptPath, isCursorHookFormat,
3291
3968
  } from './_synkro-common.ts';
3292
3969
  import { writeFileSync, mkdirSync } from 'node:fs';
3293
3970
  import { join } from 'node:path';
@@ -3327,30 +4004,41 @@ async function main() {
3327
4004
  const sessionId = hookSessionId(payload);
3328
4005
  const workspaceRoots = Array.isArray(payload.workspace_roots) ? payload.workspace_roots as string[] : [];
3329
4006
  const cwd = payload.cwd || workspaceRoots[0] || '';
3330
- const transcriptPath = payload.transcript_path || '';
4007
+ const transcriptPath = resolveTranscriptPath(payload);
3331
4008
  const repo = detectRepo(cwd, transcriptPath, command, workspaceRoots);
3332
4009
  const config = await loadConfig(jwt);
3333
4010
  const rt = await route(config);
3334
4011
  const tagStr = tag(rt, config);
3335
4012
 
3336
4013
  const rawModel = String(payload.model ?? payload.model_id ?? '');
3337
- 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']);
3338
- 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 || '');
3339
4016
 
3340
4017
  if (scan.action === 'block') {
3341
- for (const f of scan.findings) {
3342
- dispatchFinding(jwt, {
3343
- session_id: sessionId,
3344
- 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) => ({
3345
4026
  finding_type: 'cve' as const,
3346
4027
  finding_id: f.advisoryId + ':' + f.name,
3347
4028
  severity: f.severity,
3348
- status: 'open',
3349
4029
  detail: f.detail,
3350
4030
  package_name: f.name,
3351
4031
  package_version: f.version,
3352
- }, config.captureDepth);
3353
- }
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
+ );
3354
4042
  dispatchCapture(jwt, 'pkg', 'block', 'critical', 'security',
3355
4043
  'Bash', repo, sessionId, config.captureDepth, {
3356
4044
  command, reasoning: scan.blockContext.slice(0, 200),
@@ -3443,7 +4131,7 @@ async function main() {
3443
4131
  const workspaceRoots = Array.isArray(payload.workspace_roots) ? payload.workspace_roots as string[] : [];
3444
4132
  const cwd = payload.cwd || workspaceRoots[0] || '';
3445
4133
  const permissionMode = payload.permission_mode || '';
3446
- const transcriptPath = payload.transcript_path || '';
4134
+ const transcriptPath = resolveTranscriptPath(payload);
3447
4135
  const transcript = extractTranscript(transcriptPath);
3448
4136
 
3449
4137
  let command = '';
@@ -3513,20 +4201,20 @@ async function main() {
3513
4201
  return;
3514
4202
  }
3515
4203
 
3516
- // ─── Install protection: read cached scan from the install-scan hook ───
3517
- // The install-scan hook (INSTALL_SCAN_TS) runs before this hook, calls
3518
- // runInstallScan(), outputs its own system message, and caches the result.
3519
- // We just read the cache to feed scanConcern into the grader prompt so
3520
- // the consent-carryover flow works.
4204
+ // ─── Install protection: install-scan runs first and owns block traces ───
3521
4205
  let scanConcern = '';
3522
4206
  let scanBlockContext = '';
3523
4207
  if (toolName === 'Bash') {
3524
4208
  const scan = readCachedScan(command);
3525
- if (scan && scan.action === 'block') {
3526
- scanBlockContext = scan.blockContext || '';
3527
- scanConcern = 'PACKAGE SCANNER FLAG (authoritative — do NOT re-evaluate whether the vulnerability is real): '
3528
- + scanBlockContext
3529
- + ' 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.';
3530
4218
  }
3531
4219
  }
3532
4220
 
@@ -3591,7 +4279,7 @@ async function main() {
3591
4279
  });
3592
4280
  dispatchCapture(jwt, 'bash', 'block', verdict.severity || 'critical', verdict.category || 'security',
3593
4281
  toolName, gitRepo, sessionId, config.captureDepth, {
3594
- command, reasoning: guardReason, rulesChecked: config.rules, violatedRules,
4282
+ command, reasoning: guardReason, rulesChecked: relevantRules, violatedRules,
3595
4283
  recentUserMessages: transcript.recentUserMessages, ccModel: transcript.ccModel,
3596
4284
  });
3597
4285
  } else {
@@ -3603,7 +4291,7 @@ async function main() {
3603
4291
  dispatchCapture(jwt, 'bash', 'pass', 'clean', verdict.category || 'trivial_utility',
3604
4292
  toolName, gitRepo, sessionId, config.captureDepth, {
3605
4293
  command, reasoning: verdict.reason || 'no policy violations detected',
3606
- rulesChecked: config.rules, violatedRules: [],
4294
+ rulesChecked: relevantRules, violatedRules: [],
3607
4295
  recentUserMessages: transcript.recentUserMessages, ccModel: transcript.ccModel,
3608
4296
  });
3609
4297
  }
@@ -3701,7 +4389,7 @@ async function main() {
3701
4389
  const workspaceRoots = Array.isArray(payload.workspace_roots) ? payload.workspace_roots as string[] : [];
3702
4390
  const cwd = payload.cwd || workspaceRoots[0] || '';
3703
4391
  const permissionMode = payload.permission_mode || '';
3704
- const transcriptPath = payload.transcript_path || '';
4392
+ const transcriptPath = resolveTranscriptPath(payload);
3705
4393
 
3706
4394
  const prompt = toolInput.prompt || '';
3707
4395
  const description = toolInput.description || '';
@@ -3724,8 +4412,11 @@ async function main() {
3724
4412
 
3725
4413
  if (!transcript.ccModel) {
3726
4414
  const rawModel = String(payload.model ?? payload.model_id ?? '');
3727
- 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']);
3728
- 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
+ }
3729
4420
  }
3730
4421
 
3731
4422
  const config = await loadConfig(jwt);
@@ -3783,7 +4474,7 @@ async function main() {
3783
4474
  });
3784
4475
  dispatchCapture(jwt, 'agent', 'block', verdict.severity || 'critical', verdict.category || 'security',
3785
4476
  toolName, gitRepo, sessionId, config.captureDepth, {
3786
- command: agentContent, reasoning: guardReason, rulesChecked: config.rules, violatedRules,
4477
+ command: agentContent, reasoning: guardReason, rulesChecked: relevantRules, violatedRules,
3787
4478
  recentUserMessages: transcript.recentUserMessages, ccModel: transcript.ccModel,
3788
4479
  });
3789
4480
  } else {
@@ -3792,7 +4483,7 @@ async function main() {
3792
4483
  dispatchCapture(jwt, 'agent', 'pass', 'clean', verdict.category || 'subagent_spawn',
3793
4484
  toolName, gitRepo, sessionId, config.captureDepth, {
3794
4485
  command: agentContent, reasoning: verdict.reason || 'no policy violations detected',
3795
- rulesChecked: config.rules, violatedRules: [],
4486
+ rulesChecked: relevantRules, violatedRules: [],
3796
4487
  recentUserMessages: transcript.recentUserMessages, ccModel: transcript.ccModel,
3797
4488
  });
3798
4489
  }
@@ -3918,7 +4609,7 @@ async function main() {
3918
4609
  const sessionId = hookSessionId(payload);
3919
4610
  const workspaceRoots = Array.isArray(payload.workspace_roots) ? payload.workspace_roots as string[] : [];
3920
4611
  const cwd = payload.cwd || workspaceRoots[0] || '';
3921
- const transcriptPath = payload.transcript_path || '';
4612
+ const transcriptPath = resolveTranscriptPath(payload);
3922
4613
  const gitRepo = detectRepo(cwd, transcriptPath, plan, workspaceRoots);
3923
4614
 
3924
4615
  appendSessionAction(sessionId, { ts: new Date().toISOString(), tool: 'ExitPlanMode', summary: 'plan review: ' + plan.slice(0, 80) });
@@ -3931,8 +4622,7 @@ async function main() {
3931
4622
  jwt = await ensureFreshJwt(jwt);
3932
4623
 
3933
4624
  const rawModel = String(payload.model ?? payload.model_id ?? '');
3934
- 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']);
3935
- 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 || '');
3936
4626
 
3937
4627
  const config = await loadConfig(jwt);
3938
4628
  const rt = await route(config);
@@ -3975,7 +4665,7 @@ async function main() {
3975
4665
  dispatchCapture(jwt, 'plan_review', 'advisory', verdict.severity || 'medium', verdict.category || 'general',
3976
4666
  'ExitPlanMode', gitRepo, sessionId, config.captureDepth, {
3977
4667
  command: planContent, reasoning: verdict.reason || 'check org rules',
3978
- rulesChecked: config.rules, violatedRules, ccModel: ccModel || undefined,
4668
+ rulesChecked: relevantRules, violatedRules, ccModel: ccModel || undefined,
3979
4669
  });
3980
4670
  } else {
3981
4671
  const reviewMsg = verdict.reason || 'no relevant org rules for this plan';
@@ -3985,7 +4675,7 @@ async function main() {
3985
4675
  dispatchCapture(jwt, 'plan_review', 'clean', 'clean', verdict.category || 'general',
3986
4676
  'ExitPlanMode', gitRepo, sessionId, config.captureDepth, {
3987
4677
  command: planContent, reasoning: reviewMsg,
3988
- rulesChecked: config.rules, violatedRules: [], ccModel: ccModel || undefined,
4678
+ rulesChecked: relevantRules, violatedRules: [], ccModel: ccModel || undefined,
3989
4679
  });
3990
4680
  }
3991
4681
  return;
@@ -4033,7 +4723,8 @@ main();
4033
4723
  STOP_SUMMARY_TS = `#!/usr/bin/env bun
4034
4724
  import {
4035
4725
  loadJwt, detectRepo, loadConfig, tag, readStdin, aggregateUsage, cleanupSessionLog,
4036
- outputJson, outputEmpty, appendLocalTelemetry, shipCloud, setupCursorHookSignals, hookSessionId, GATEWAY_URL,
4726
+ outputJson, outputEmpty, shipCloud, setupCursorHookSignals, hookSessionId, GATEWAY_URL,
4727
+ resolveTranscriptPath, emitUsageTick, cursorModelFromPayload, log,
4037
4728
  } from './_synkro-common.ts';
4038
4729
 
4039
4730
  async function main() {
@@ -4048,35 +4739,16 @@ async function main() {
4048
4739
 
4049
4740
  const workspaceRoots = Array.isArray(payload.workspace_roots) ? payload.workspace_roots as string[] : [];
4050
4741
  const cwd = payload.cwd || workspaceRoots[0] || '';
4051
- const transcriptPath = payload.transcript_path || '';
4742
+ const transcriptPath = resolveTranscriptPath(payload);
4052
4743
  const gitRepo = detectRepo(cwd, transcriptPath, '', workspaceRoots);
4744
+ const modelFallback = cursorModelFromPayload(payload);
4053
4745
 
4054
4746
  let jwt = loadJwt();
4055
4747
  if (!jwt) { outputEmpty(); return; }
4056
4748
 
4057
4749
  if (transcriptPath) {
4058
- const usage = aggregateUsage(transcriptPath);
4059
- if (usage.totals.in + usage.totals.out > 0) {
4060
- const usageBody = {
4061
- capture_type: 'usage_tick',
4062
- event_id: 'usage_' + Date.now() + '_' + process.pid,
4063
- hook_type: 'stop',
4064
- verdict: 'allow',
4065
- severity: 'none',
4066
- model: usage.model || 'unknown',
4067
- cc_model: usage.model || '',
4068
- cc_usage: {
4069
- input_tokens: usage.totals.in,
4070
- output_tokens: usage.totals.out,
4071
- cache_creation_input_tokens: usage.totals.cw,
4072
- cache_read_input_tokens: usage.totals.cr,
4073
- },
4074
- ...(gitRepo ? { repo: gitRepo } : {}),
4075
- ...(sessionId ? { session_id: sessionId } : {}),
4076
- };
4077
- appendLocalTelemetry(usageBody);
4078
- shipCloud(jwt, '/api/v1/hook/capture', usageBody);
4079
- }
4750
+ const usage = aggregateUsage(transcriptPath, { modelFallback });
4751
+ emitUsageTick({ sessionId, usage, hookType: 'session_end', gitRepo, modelFallback });
4080
4752
  }
4081
4753
 
4082
4754
  let resp: any;
@@ -4132,7 +4804,7 @@ async function main() {
4132
4804
  const payload = JSON.parse(input);
4133
4805
  const workspaceRoots = Array.isArray(payload.workspace_roots) ? payload.workspace_roots as string[] : [];
4134
4806
  const cwd = payload.cwd || workspaceRoots[0] || '';
4135
- const transcriptPath = payload.transcript_path || '';
4807
+ const transcriptPath = resolveTranscriptPath(payload);
4136
4808
  const sessionId = hookSessionId(payload);
4137
4809
  const gitRepo = detectRepo(cwd, transcriptPath, '', workspaceRoots);
4138
4810
  if (gitRepo) writeCachedRepo(gitRepo);
@@ -4264,10 +4936,9 @@ main();
4264
4936
  import {
4265
4937
  loadJwt, detectRepo, readStdin, aggregateUsage, appendLocalTelemetry,
4266
4938
  outputEmpty, setupCursorHookSignals, hookSessionId, GATEWAY_URL, readSessionLog, shipCloud,
4939
+ resolveTranscriptPath, syncConversationTranscript, emitUsageTick, cursorModelFromPayload,
4267
4940
  } from './_synkro-common.ts';
4268
- import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
4269
- import { join, dirname } from 'node:path';
4270
- import { homedir } from 'node:os';
4941
+ import { readFileSync } from 'node:fs';
4271
4942
 
4272
4943
  async function main() {
4273
4944
  setupCursorHookSignals();
@@ -4277,11 +4948,11 @@ async function main() {
4277
4948
 
4278
4949
  const payload = JSON.parse(input);
4279
4950
  const sessionId = hookSessionId(payload);
4280
- const transcriptPath = payload.transcript_path || '';
4281
4951
  const workspaceRoots = Array.isArray(payload.workspace_roots) ? payload.workspace_roots as string[] : [];
4282
4952
  const cwd = payload.cwd || workspaceRoots[0] || '';
4953
+ const transcriptPath = resolveTranscriptPath(payload);
4283
4954
 
4284
- if (!sessionId || !transcriptPath || !existsSync(transcriptPath)) {
4955
+ if (!sessionId || !transcriptPath) {
4285
4956
  outputEmpty();
4286
4957
  return;
4287
4958
  }
@@ -4289,104 +4960,21 @@ async function main() {
4289
4960
  const jwt = loadJwt();
4290
4961
  if (!jwt) { outputEmpty(); return; }
4291
4962
 
4292
- const usage = aggregateUsage(transcriptPath);
4293
- if (usage.totals.in + usage.totals.out > 0) {
4294
- const usageBody = {
4295
- capture_type: 'usage_tick',
4296
- event_id: 'usage_' + Date.now() + '_' + process.pid,
4297
- hook_type: 'stop',
4298
- verdict: 'allow',
4299
- severity: 'none',
4300
- model: usage.model || 'unknown',
4301
- cc_model: usage.model || '',
4302
- cc_usage: {
4303
- input_tokens: usage.totals.in,
4304
- output_tokens: usage.totals.out,
4305
- cache_creation_input_tokens: usage.totals.cw,
4306
- cache_read_input_tokens: usage.totals.cr,
4307
- },
4308
- session_id: sessionId,
4309
- };
4310
- appendLocalTelemetry(usageBody);
4311
- shipCloud(jwt, '/api/v1/hook/capture', usageBody);
4312
- }
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
+ });
4313
4971
 
4314
- // Transcript consent gates only CLOUD transmission. Local persistence \u2014
4315
- // this machine's own PGLite \u2014 is the same category as the local telemetry
4316
- // already captured for every command, so it always runs.
4317
4972
  const cloudConsent = process.env.SYNKRO_TRANSCRIPT_CONSENT !== 'no';
4318
4973
  const gitRepo = detectRepo(cwd, transcriptPath, '', workspaceRoots);
4319
4974
 
4320
- // Offset-tracked extraction of new user/assistant turns from the transcript.
4321
- const offsetDir = join(homedir(), '.synkro', '.transcript-offsets');
4322
- mkdirSync(offsetDir, { recursive: true });
4323
- const offsetFile = join(offsetDir, sessionId);
4324
- let offset = 0;
4325
- if (existsSync(offsetFile)) {
4326
- try { offset = parseInt(readFileSync(offsetFile, 'utf-8').trim(), 10) || 0; } catch {}
4327
- }
4328
-
4329
- const raw = readFileSync(transcriptPath, 'utf-8');
4330
- const allLines = raw.split('\\n').filter(l => l.trim());
4331
- const totalLines = allLines.length;
4332
-
4333
- if (totalLines <= offset) { outputEmpty(); return; }
4334
-
4335
- let startIdx = offset;
4336
- const delta = totalLines - offset;
4337
- if (delta > 200) startIdx = totalLines - 200;
4338
-
4339
- const messages: any[] = [];
4340
- for (let i = startIdx; i < totalLines; i++) {
4341
- try {
4342
- const entry = JSON.parse(allLines[i]);
4343
- if (entry.type !== 'user' && entry.type !== 'assistant') continue;
4344
- const content = entry.message?.content;
4345
- let text = '';
4346
- if (typeof content === 'string') text = content.slice(0, 8000);
4347
- else if (Array.isArray(content)) {
4348
- text = content.map((c: any) => {
4349
- if (typeof c === 'string') return c;
4350
- if (c?.type === 'text') return c.text || '';
4351
- return '';
4352
- }).join(' ').slice(0, 8000);
4353
- }
4354
-
4355
- const msg: any = { message_index: i, type: entry.type, content: text, ts: entry.timestamp || null };
4356
- if (entry.type === 'assistant') {
4357
- const toolCalls = (Array.isArray(content) ? content : [])
4358
- .filter((c: any) => c?.type === 'tool_use')
4359
- .map((c: any) => ({ name: c.name, input: JSON.stringify(c.input || {}).slice(0, 500), id: c.id }));
4360
- if (toolCalls.length > 0) msg.tool_calls = toolCalls;
4361
- msg.model = entry.message?.model || null;
4362
- const u = entry.message?.usage;
4363
- 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 };
4364
- }
4365
- messages.push(msg);
4366
- } catch {}
4367
- }
4368
-
4369
- writeFileSync(offsetFile, String(totalLines), 'utf-8');
4370
-
4371
- if (messages.length === 0) { outputEmpty(); return; }
4975
+ const { messages } = await syncConversationTranscript(sessionId, transcriptPath, gitRepo || '');
4372
4976
 
4373
- // Local persist \u2014 always. The conversation timeline lives in PGLite.
4374
- const mcpPort = process.env.SYNKRO_MCP_PORT || '18931';
4375
- let mcpToken = '';
4376
- try { mcpToken = readFileSync(join(homedir(), '.synkro', '.mcp-jwt'), 'utf-8').trim(); } catch {}
4377
- if (mcpToken) {
4378
- fetch('http://127.0.0.1:' + mcpPort + '/api/conversation-sync', {
4379
- method: 'POST',
4380
- headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + mcpToken },
4381
- body: JSON.stringify({ session_id: sessionId, repo: gitRepo || '', messages }),
4382
- signal: AbortSignal.timeout(5000),
4383
- }).catch(() => {});
4384
- }
4385
-
4386
- // Cloud sync \u2014 only when storage mode is cloud (and transcript consent
4387
- // wasn't declined). Carries the transcript + the session step-log so cloud
4388
- // storage matches the local PGLite shape.
4389
- if (cloudConsent && gitRepo && (process.env.SYNKRO_STORAGE_MODE || 'local') === 'cloud') {
4977
+ if (cloudConsent && gitRepo && messages.length > 0 && (process.env.SYNKRO_STORAGE_MODE || 'local') === 'cloud') {
4390
4978
  fetch(GATEWAY_URL + '/api/v1/cli/sync-transcripts', {
4391
4979
  method: 'POST',
4392
4980
  headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + jwt },
@@ -4407,7 +4995,7 @@ async function main() {
4407
4995
  main();
4408
4996
  `;
4409
4997
  USER_PROMPT_SUBMIT_TS = `#!/usr/bin/env bun
4410
- 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';
4411
4999
  import { writeFileSync, mkdirSync } from 'node:fs';
4412
5000
  import { join, dirname } from 'node:path';
4413
5001
  import { homedir } from 'node:os';
@@ -4432,25 +5020,15 @@ async function main() {
4432
5020
  }
4433
5021
 
4434
5022
  const sessionId = hookSessionId(payload);
4435
- 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);
4436
5026
  if (sessionId && transcriptPath) {
4437
- const usage = aggregateUsage(transcriptPath);
4438
- if (usage.totals.in + usage.totals.out > 0) {
4439
- appendLocalTelemetry({
4440
- capture_type: 'usage_tick',
4441
- event_id: 'usage_' + Date.now() + '_' + process.pid,
4442
- hook_type: 'prompt_submit',
4443
- session_id: sessionId,
4444
- model: usage.model || 'unknown',
4445
- cc_model: usage.model || '',
4446
- cc_usage: {
4447
- input_tokens: usage.totals.in,
4448
- output_tokens: usage.totals.out,
4449
- cache_creation_input_tokens: usage.totals.cw,
4450
- cache_read_input_tokens: usage.totals.cr,
4451
- },
4452
- });
4453
- }
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 });
4454
5032
  }
4455
5033
  outputEmpty();
4456
5034
  } catch {
@@ -4464,7 +5042,7 @@ main();
4464
5042
  import {
4465
5043
  loadJwt, ensureFreshJwt, detectRepo, loadConfig, route, tag, localGrade,
4466
5044
  parseVerdict, dispatchCapture, dispatchFinding, ruleMode, normalizeMode, filterRules, ruleFilterText,
4467
- isSafeInRepoRead, postWithRetry, readStdin, hashCommand,
5045
+ isSafeInRepoRead, resolveTranscriptPath, postWithRetry, readStdin, hashCommand,
4468
5046
  extractTranscript, readLastPrompt, readSessionLog, compressSessionLog,
4469
5047
  appendLocalTelemetry, logGraderUnavailable, log, GATEWAY_URL,
4470
5048
  type Rule,
@@ -4577,10 +5155,9 @@ async function main() {
4577
5155
  finishAllow();
4578
5156
  }
4579
5157
 
4580
- const transcriptPath = typeof payload.transcript_path === 'string' ? payload.transcript_path : '';
5158
+ const transcriptPath = resolveTranscriptPath(payload);
4581
5159
  const rawModel = String(payload.model ?? payload.model_id ?? '');
4582
- 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']);
4583
- 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';
4584
5161
  const repo = detectRepo(cwd, transcriptPath, command, workspaceRoots);
4585
5162
 
4586
5163
  const cmdShort = command.slice(0, 80);
@@ -4613,16 +5190,19 @@ async function main() {
4613
5190
  const rt = await route(config);
4614
5191
  const tagStr = tag(rt, config);
4615
5192
 
4616
- // Install protection \u2014 read cached scan from the install-scan hook.
5193
+ // Install protection \u2014 install-scan runs first and owns block traces.
4617
5194
  let scanConcern = '';
4618
5195
  let scanBlockContext = '';
4619
5196
  if (SHELL_TOOL_NAMES.has(toolName)) {
4620
5197
  const scan = readCachedScan(command);
4621
- if (scan && scan.action === 'block') {
4622
- scanBlockContext = scan.blockContext || '';
4623
- scanConcern = 'PACKAGE SCANNER FLAG (authoritative \u2014 do NOT re-evaluate whether the vulnerability is real): '
4624
- + scanBlockContext
4625
- + ' 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.';
4626
5206
  }
4627
5207
  }
4628
5208
 
@@ -4677,7 +5257,7 @@ async function main() {
4677
5257
  dispatchCapture(jwt, 'bash', 'block', verdict.severity || 'critical', verdict.category || 'security',
4678
5258
  'Bash', repo, sessionId, config.captureDepth, {
4679
5259
  command, reasoning: guardReason,
4680
- rulesChecked: config.rules, violatedRules: verdict.ruleId ? [verdict.ruleId] : [],
5260
+ rulesChecked: relevantRules, violatedRules: verdict.ruleId ? [verdict.ruleId] : [],
4681
5261
  ccModel: model,
4682
5262
  });
4683
5263
  finishWith({
@@ -4689,7 +5269,7 @@ async function main() {
4689
5269
  dispatchCapture(jwt, 'bash', 'pass', 'clean', verdict.category || 'clean',
4690
5270
  'Bash', repo, sessionId, config.captureDepth, {
4691
5271
  command, reasoning: verdict.reason || 'no policy violations detected',
4692
- rulesChecked: config.rules, violatedRules: [],
5272
+ rulesChecked: relevantRules, violatedRules: [],
4693
5273
  ccModel: model,
4694
5274
  });
4695
5275
  }
@@ -4756,8 +5336,9 @@ main().catch((e) => {
4756
5336
  });`;
4757
5337
  CURSOR_EDIT_CAPTURE_TS = `#!/usr/bin/env bun
4758
5338
  import {
4759
- loadJwt, ensureFreshJwt, detectRepo, readStdin,
5339
+ loadJwt, ensureFreshJwt, detectRepo, readStdin, resolveTranscriptPath,
4760
5340
  appendSessionAction, appendLocalTelemetry, shipCloud, log, GATEWAY_URL,
5341
+ countEditLineDelta, dispatchCapture, hookSessionId, cursorModelFromPayload,
4761
5342
  } from './_synkro-common.ts';
4762
5343
  import { existsSync, readFileSync } from 'node:fs';
4763
5344
  import { basename, dirname, join } from 'node:path';
@@ -4780,23 +5361,31 @@ async function main() {
4780
5361
  const input = await readStdin();
4781
5362
  if (!input.trim()) finish();
4782
5363
 
4783
- const payload = JSON.parse(input);
4784
- 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 || '');
4785
5366
  if (!filePath) finish();
4786
5367
 
4787
- const workspaceRoots = Array.isArray(payload.workspace_roots) ? (payload.workspace_roots as unknown[]).filter((r): r is string => typeof r === 'string') : [];
4788
- const cwd = payload.cwd || workspaceRoots[0] || '';
4789
- const transcriptPath = typeof payload.transcript_path === 'string' ? payload.transcript_path : '';
4790
- 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);
4791
5374
  const repo = detectRepo(cwd, transcriptPath, filePath, workspaceRoots);
5375
+ const model = cursorModelFromPayload(payload);
4792
5376
 
4793
- const rawModel = String(payload.model ?? payload.model_id ?? '');
4794
- 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']);
4795
- 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 });
4796
5379
 
4797
- log('editScan ' + basename(filePath));
5380
+ log('editScan ' + basename(filePath) + ' +' + linesAdded + '/-' + linesRemoved);
4798
5381
 
4799
- 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
+ });
4800
5389
 
4801
5390
  let jwt = loadJwt();
4802
5391
  if (!jwt) finish();
@@ -4842,6 +5431,16 @@ async function main() {
4842
5431
  appendLocalTelemetry(captureBody);
4843
5432
  shipCloud(jwt, '/api/v1/hook/capture', captureBody, 10000);
4844
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
+
4845
5444
  finish();
4846
5445
  } catch (e) {
4847
5446
  log('editScan error: ' + String(e));
@@ -4853,6 +5452,47 @@ main().catch((e) => {
4853
5452
  log('editScan fatal: ' + String(e));
4854
5453
  finish();
4855
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
+ `;
4856
5496
  }
4857
5497
  });
4858
5498
 
@@ -6794,6 +7434,7 @@ function writeHookScripts() {
6794
7434
  const installScanScriptPath = join8(HOOKS_DIR, "cc-install-scan.ts");
6795
7435
  const cursorBashJudgePath = join8(HOOKS_DIR, "cursor-bash-judge.ts");
6796
7436
  const cursorEditCapturePath = join8(HOOKS_DIR, "cursor-edit-capture.ts");
7437
+ const cursorAgentCapturePath = join8(HOOKS_DIR, "cursor-agent-capture.ts");
6797
7438
  const mcpStdioProxyPath = join8(HOOKS_DIR, "mcp-stdio-proxy.ts");
6798
7439
  writeFileSync7(bashScriptPath, BASH_JUDGE_TS, "utf-8");
6799
7440
  writeFileSync7(bashFollowupScriptPath, BASH_FOLLOWUP_TS, "utf-8");
@@ -6811,8 +7452,9 @@ function writeHookScripts() {
6811
7452
  writeFileSync7(installScanScriptPath, INSTALL_SCAN_TS, "utf-8");
6812
7453
  writeFileSync7(cursorBashJudgePath, CURSOR_BASH_JUDGE_TS, "utf-8");
6813
7454
  writeFileSync7(cursorEditCapturePath, CURSOR_EDIT_CAPTURE_TS, "utf-8");
7455
+ writeFileSync7(cursorAgentCapturePath, CURSOR_AGENT_CAPTURE_TS, "utf-8");
6814
7456
  writeFileSync7(mcpStdioProxyPath, MCP_STDIO_PROXY_SRC, "utf-8");
6815
- 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");
6816
7458
  const hooksPkgPath = join8(HOOKS_DIR, "package.json");
6817
7459
  writeFileSync7(hooksPkgPath, JSON.stringify({
6818
7460
  name: "synkro-hooks",
@@ -6840,6 +7482,7 @@ function writeHookScripts() {
6840
7482
  chmodSync2(installScanScriptPath, 493);
6841
7483
  chmodSync2(cursorBashJudgePath, 493);
6842
7484
  chmodSync2(cursorEditCapturePath, 493);
7485
+ chmodSync2(cursorAgentCapturePath, 493);
6843
7486
  chmodSync2(mcpStdioProxyPath, 493);
6844
7487
  chmodSync2(installExtractCorePath, 493);
6845
7488
  return {
@@ -6856,7 +7499,8 @@ function writeHookScripts() {
6856
7499
  userPromptSubmitScript: userPromptSubmitScriptPath,
6857
7500
  installScanScript: installScanScriptPath,
6858
7501
  cursorBashJudgeScript: cursorBashJudgePath,
6859
- cursorEditCaptureScript: cursorEditCapturePath
7502
+ cursorEditCaptureScript: cursorEditCapturePath,
7503
+ cursorAgentCaptureScript: cursorAgentCapturePath
6860
7504
  };
6861
7505
  }
6862
7506
  function sanitizeConfigValue(raw, maxLen = 256) {
@@ -6888,7 +7532,7 @@ function writeConfigEnv(opts) {
6888
7532
  `SYNKRO_CREDENTIALS_PATH=${shellQuoteSingle(credsPath)}`,
6889
7533
  `SYNKRO_TIER=${shellQuoteSingle(safeTier)}`,
6890
7534
  `SYNKRO_INFERENCE=${shellQuoteSingle(safeInference)}`,
6891
- `SYNKRO_VERSION=${shellQuoteSingle("1.6.22")}`
7535
+ `SYNKRO_VERSION=${shellQuoteSingle("1.6.25")}`
6892
7536
  ];
6893
7537
  if (safeSynkroBin) lines.push(`SYNKRO_CLI_BIN=${shellQuoteSingle(safeSynkroBin)}`);
6894
7538
  if (safeUserId) lines.push(`SYNKRO_USER_ID=${shellQuoteSingle(safeUserId)}`);
@@ -7139,6 +7783,7 @@ async function installCommand(opts = {}) {
7139
7783
  installCursorHooks(agent.settingsPath, {
7140
7784
  bashJudgeScriptPath: scripts.cursorBashJudgeScript,
7141
7785
  editCaptureScriptPath: scripts.cursorEditCaptureScript,
7786
+ agentCaptureScriptPath: scripts.cursorAgentCaptureScript,
7142
7787
  bashFollowupScriptPath: scripts.bashFollowupScript,
7143
7788
  editPrecheckScriptPath: scripts.editPrecheckScript,
7144
7789
  cwePrecheckScriptPath: scripts.cwePrecheckScript,
@@ -9555,7 +10200,7 @@ var args = process.argv.slice(2);
9555
10200
  var cmd = args[0] || "";
9556
10201
  var subArgs = args.slice(1);
9557
10202
  function printVersion() {
9558
- console.log("1.6.22");
10203
+ console.log("1.6.25");
9559
10204
  }
9560
10205
  function printHelp2() {
9561
10206
  console.log(`Synkro CLI \u2014 runtime safety for AI coding agents