@synkro-sh/cli 1.6.45 → 1.6.47

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
@@ -804,7 +804,10 @@ synkro_post_with_retry() {
804
804
  });
805
805
 
806
806
  // cli/installer/hookScriptsTs.ts
807
- 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;
807
+ function stubHook(surface, optsLiteral) {
808
+ return "#!/usr/bin/env bun\nimport { runStub } from './_synkro-stub-common.ts';\nrunStub(" + JSON.stringify(surface) + ", " + optsLiteral + ");\n";
809
+ }
810
+ 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, STUB_COMMON_TS, STUB_EDIT_PRECHECK_TS, STUB_CWE_PRECHECK_TS, STUB_CVE_PRECHECK_TS, STUB_BASH_JUDGE_TS, STUB_INSTALL_SCAN_TS, STUB_AGENT_JUDGE_TS, STUB_PLAN_JUDGE_TS, STUB_STOP_SUMMARY_TS, STUB_SESSION_START_TS, STUB_TRANSCRIPT_SYNC_TS, STUB_USER_PROMPT_SUBMIT_TS, STUB_BASH_FOLLOWUP_TS, STUB_CURSOR_BASH_JUDGE_TS, STUB_CURSOR_EDIT_CAPTURE_TS, STUB_CURSOR_AGENT_CAPTURE_TS;
808
811
  var init_hookScriptsTs = __esm({
809
812
  "cli/installer/hookScriptsTs.ts"() {
810
813
  "use strict";
@@ -1280,6 +1283,39 @@ export function effectiveGraderPool(synkroFile: SynkroFileConfig, hookAgentKind:
1280
1283
  return 'cursor';
1281
1284
  }
1282
1285
 
1286
+ // \u2500\u2500\u2500 .synkro presence (per-repo onboarding) \u2500\u2500\u2500
1287
+ // A repo is "onboarded" to Synkro when it has a .synkro file at its git root.
1288
+ // The hooks are installed globally, so they fire in every repo \u2014 including ones
1289
+ // the user never set up. Without a .synkro there, grading is SKIPPED gracefully
1290
+ // rather than attempting a grade that surfaces a confusing "grader unavailable"
1291
+ // error: the tool call passes through and we show a one-time hint pointing at
1292
+ // "synkro install".
1293
+ export function synkroFilePresent(cwd?: string): boolean {
1294
+ try {
1295
+ const root = cwd ? findGitRoot(cwd) : '';
1296
+ if (!root) return false;
1297
+ return existsSync(root + '/.synkro');
1298
+ } catch { return false; }
1299
+ }
1300
+
1301
+ const NO_SYNKRO_HINT_DIR = join(HOME, '.synkro', '.no-synkro-hint');
1302
+
1303
+ // Returns the onboarding hint the FIRST time a session touches a repo with no
1304
+ // .synkro file, then null on subsequent calls so it doesn't repeat on every
1305
+ // tool call. Caller passes null straight through to outputEmpty().
1306
+ export function noSynkroSkipMessage(sessionId: string): string | null {
1307
+ try {
1308
+ mkdirSync(NO_SYNKRO_HINT_DIR, { recursive: true });
1309
+ const key = (sessionId || 'nosession').replace(/[^a-zA-Z0-9_-]/g, '_');
1310
+ const marker = join(NO_SYNKRO_HINT_DIR, key);
1311
+ if (existsSync(marker)) return null;
1312
+ writeFileSync(marker, '', { flag: 'w' });
1313
+ } catch {
1314
+ // best-effort dedup \u2014 if the marker can't be written, still show the hint
1315
+ }
1316
+ return '[synkro] No .synkro config in this repo \u2014 grading skipped. Run \`synkro install\` here to enable Synkro.';
1317
+ }
1318
+
1283
1319
  // \u2500\u2500\u2500 Channel Health \u2500\u2500\u2500
1284
1320
 
1285
1321
  export async function channelUp(port = 18929): Promise<boolean> {
@@ -3355,7 +3391,7 @@ import {
3355
3391
  outputJson, outputEmpty, setupCursorHookSignals, installHookWatchdog, isEditTool, hookSessionId, GATEWAY_URL,
3356
3392
  logGraderUnavailable, graderUnavailableMessage, filterRules, ruleFilterText, normalizeMode, countEditLineDelta,
3357
3393
  captureLineMetrics, cursorModelFromPayload, resolveTranscriptPath, isCursorInvokingCcHook,
3358
- loadSynkroFile, effectiveGraderPool,
3394
+ loadSynkroFile, effectiveGraderPool, synkroFilePresent, noSynkroSkipMessage,
3359
3395
  type HookConfig, type Rule,
3360
3396
  } from './_synkro-common.ts';
3361
3397
  import { existsSync, readFileSync } from 'node:fs';
@@ -3390,6 +3426,13 @@ async function main() {
3390
3426
 
3391
3427
  if (filePath.includes('/.synkro/hooks/')) { outputEmpty(); return; }
3392
3428
 
3429
+ if (!synkroFilePresent(cwd)) {
3430
+ const _skipMsg = noSynkroSkipMessage(sessionId);
3431
+ if (_skipMsg) outputJson({ systemMessage: _skipMsg, hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: _skipMsg } });
3432
+ else outputEmpty();
3433
+ return;
3434
+ }
3435
+
3393
3436
  const fileShort = basename(filePath);
3394
3437
  log('editGuard checking: ' + fileShort);
3395
3438
 
@@ -3613,7 +3656,7 @@ import {
3613
3656
  outputJson, outputEmpty, setupCursorHookSignals, installHookWatchdog, isEditTool, isShellTool, isCursorHookFormat,
3614
3657
  extractShellCodeWrites, hookSessionId, filePathFromToolInput, emitBlockScanFindings, dispatchFinding, dispatchCapture, GATEWAY_URL,
3615
3658
  logGraderUnavailable, graderUnavailableMessage, resolveTranscriptPath, isCursorInvokingCcHook,
3616
- loadSynkroFile, effectiveGraderPool,
3659
+ loadSynkroFile, effectiveGraderPool, synkroFilePresent, noSynkroSkipMessage,
3617
3660
  } from './_synkro-common.ts';
3618
3661
  import { basename, extname, resolve, join, dirname } from 'node:path';
3619
3662
  import { readFileSync, readdirSync, existsSync } from 'node:fs';
@@ -3767,6 +3810,13 @@ async function main() {
3767
3810
 
3768
3811
  if (isCursorInvokingCcHook(agentKind, ccModel)) { outputEmpty(); return; }
3769
3812
 
3813
+ if (!synkroFilePresent(cwd)) {
3814
+ const _skipMsg = noSynkroSkipMessage(sessionId);
3815
+ if (_skipMsg) outputJson({ systemMessage: _skipMsg, hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: _skipMsg } });
3816
+ else outputEmpty();
3817
+ return;
3818
+ }
3819
+
3770
3820
  const targets: CweScanTarget[] = [];
3771
3821
 
3772
3822
  if (isCursorHookFormat() && (shellCommand || isShellTool(toolName))) {
@@ -4173,7 +4223,7 @@ import {
4173
4223
  loadJwt, ensureFreshJwt, detectRepo, loadConfig, route, tag,
4174
4224
  reconstructContent, readStdin, findNearestDeps, filePathFromToolInput, log,
4175
4225
  outputJson, outputEmpty, setupCursorHookSignals, isEditTool, hookSessionId, dispatchFinding, dispatchCapture, dispatchScanResult, extractTranscript, emitBlockScanFindings, resolveTranscriptPath, GATEWAY_URL,
4176
- isCursorHookFormat,
4226
+ isCursorHookFormat, synkroFilePresent, noSynkroSkipMessage,
4177
4227
  } from './_synkro-common.ts';
4178
4228
  import { basename } from 'node:path';
4179
4229
  import { readFileSync } from 'node:fs';
@@ -4219,6 +4269,13 @@ async function main() {
4219
4269
 
4220
4270
  if (filePath.includes('/.synkro/hooks/')) { outputEmpty(); return; }
4221
4271
 
4272
+ if (!synkroFilePresent(cwd)) {
4273
+ const _skipMsg = noSynkroSkipMessage(sessionId);
4274
+ if (_skipMsg) outputJson({ systemMessage: _skipMsg, hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: _skipMsg } });
4275
+ else outputEmpty();
4276
+ return;
4277
+ }
4278
+
4222
4279
  const fileShort = basename(filePath);
4223
4280
 
4224
4281
  let jwt = loadJwt();
@@ -4455,7 +4512,7 @@ import {
4455
4512
  loadJwt, ensureFreshJwt, detectRepo, loadConfig, route, tag,
4456
4513
  readStdin, runInstallScan, emitBlockScanFindings, dispatchCapture, dispatchScanResult, hashCommand,
4457
4514
  outputJson, outputEmpty, setupCursorHookSignals, isShellTool, hookSessionId, log, GATEWAY_URL,
4458
- resolveTranscriptPath, isCursorHookFormat, loadSynkroFile, effectiveGraderPool,
4515
+ resolveTranscriptPath, isCursorHookFormat, loadSynkroFile, effectiveGraderPool, synkroFilePresent, noSynkroSkipMessage,
4459
4516
  } from './_synkro-common.ts';
4460
4517
  import { writeFileSync, mkdirSync } from 'node:fs';
4461
4518
  import { join } from 'node:path';
@@ -4480,6 +4537,14 @@ async function main() {
4480
4537
  // beforeShellExecution supplies command directly; preToolUse uses tool_name + tool_input.
4481
4538
  if (!isShellTool(toolName) && typeof payload.command !== 'string') { outputEmpty(); return; }
4482
4539
 
4540
+ const _cwd = payload.cwd || (Array.isArray(payload.workspace_roots) ? payload.workspace_roots[0] : '') || '';
4541
+ if (!synkroFilePresent(_cwd)) {
4542
+ const _skipMsg = noSynkroSkipMessage(hookSessionId(payload));
4543
+ if (_skipMsg) outputJson({ systemMessage: _skipMsg, hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: _skipMsg } });
4544
+ else outputEmpty();
4545
+ return;
4546
+ }
4547
+
4483
4548
  let jwt = loadJwt();
4484
4549
  if (!jwt) { outputEmpty(); return; }
4485
4550
  jwt = await ensureFreshJwt(jwt);
@@ -4576,7 +4641,7 @@ import {
4576
4641
  extractTranscript, readLastPrompt, appendSessionAction, readSessionLog, compressSessionLog, log,
4577
4642
  outputJson, outputEmpty, setupCursorHookSignals, installHookWatchdog, isShellTool, hookSessionId, GATEWAY_URL,
4578
4643
  logGraderUnavailable, graderUnavailableMessage, filterRules, ruleFilterText, normalizeMode, appendLocalTelemetry, isSafeInRepoRead,
4579
- loadSynkroFile, effectiveGraderPool,
4644
+ loadSynkroFile, effectiveGraderPool, synkroFilePresent, noSynkroSkipMessage,
4580
4645
  hashCommand, resolveTranscriptPath, isCursorHookFormat,
4581
4646
  type HookConfig, type Rule,
4582
4647
  } from './_synkro-common.ts';
@@ -4657,6 +4722,13 @@ async function main() {
4657
4722
 
4658
4723
  const gitRepo = detectRepo(cwd, transcriptPath, command, workspaceRoots);
4659
4724
 
4725
+ if (!synkroFilePresent(cwd)) {
4726
+ const _skipMsg = noSynkroSkipMessage(sessionId);
4727
+ if (_skipMsg) outputJson({ systemMessage: _skipMsg, hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: _skipMsg } });
4728
+ else outputEmpty();
4729
+ return;
4730
+ }
4731
+
4660
4732
  if (isDuplicate(command, sessionId)) {
4661
4733
  log('bashGuard skip (dedup): ' + command.slice(0, 80));
4662
4734
  outputEmpty();
@@ -4874,7 +4946,7 @@ import {
4874
4946
  extractTranscript, readLastPrompt, appendSessionAction, readSessionLog, compressSessionLog, log,
4875
4947
  outputJson, outputEmpty, setupCursorHookSignals, installHookWatchdog, isAgentTool, hookSessionId, GATEWAY_URL,
4876
4948
  logGraderUnavailable, graderUnavailableMessage, filterRules, normalizeMode, resolveTranscriptPath, isCursorInvokingCcHook,
4877
- loadSynkroFile, effectiveGraderPool,
4949
+ loadSynkroFile, effectiveGraderPool, synkroFilePresent, noSynkroSkipMessage,
4878
4950
  type HookConfig, type Rule,
4879
4951
  } from './_synkro-common.ts';
4880
4952
 
@@ -4905,6 +4977,13 @@ async function main() {
4905
4977
  const permissionMode = payload.permission_mode || '';
4906
4978
  const transcriptPath = resolveTranscriptPath(payload);
4907
4979
 
4980
+ if (!synkroFilePresent(cwd)) {
4981
+ const _skipMsg = noSynkroSkipMessage(sessionId);
4982
+ if (_skipMsg) outputJson({ systemMessage: _skipMsg, hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: _skipMsg } });
4983
+ else outputEmpty();
4984
+ return;
4985
+ }
4986
+
4908
4987
  const prompt = toolInput.prompt || '';
4909
4988
  const description = toolInput.description || '';
4910
4989
  const subagentType = toolInput.subagent_type || 'general-purpose';
@@ -5062,7 +5141,7 @@ import {
5062
5141
  parseVerdict, dispatchCapture, appendSessionAction, readSessionLog, compressSessionLog, postWithRetry, readStdin, log,
5063
5142
  outputJson, outputEmpty, setupCursorHookSignals, installHookWatchdog, isPlanTool, hookSessionId, GATEWAY_URL,
5064
5143
  filterRules, graderUnavailableMessage, resolveTranscriptPath, isCursorInvokingCcHook,
5065
- loadSynkroFile, effectiveGraderPool,
5144
+ loadSynkroFile, effectiveGraderPool, synkroFilePresent, noSynkroSkipMessage,
5066
5145
  } from './_synkro-common.ts';
5067
5146
  import { existsSync, readFileSync, writeFileSync, readdirSync, statSync } from 'node:fs';
5068
5147
  import { join } from 'node:path';
@@ -5135,6 +5214,13 @@ async function main() {
5135
5214
  const transcriptPath = resolveTranscriptPath(payload);
5136
5215
  const gitRepo = detectRepo(cwd, transcriptPath, plan, workspaceRoots);
5137
5216
 
5217
+ if (!synkroFilePresent(cwd)) {
5218
+ const _skipMsg = noSynkroSkipMessage(sessionId);
5219
+ if (_skipMsg) outputJson({ systemMessage: _skipMsg, hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: _skipMsg } });
5220
+ else outputEmpty();
5221
+ return;
5222
+ }
5223
+
5138
5224
  appendSessionAction(sessionId, { ts: new Date().toISOString(), tool: 'ExitPlanMode', summary: 'plan review: ' + plan.slice(0, 80) });
5139
5225
 
5140
5226
  const planShort = plan.slice(0, 80);
@@ -5249,7 +5335,7 @@ main();
5249
5335
  import {
5250
5336
  loadJwt, detectRepo, loadConfig, tag, readStdin, aggregateUsage, cleanupSessionLog,
5251
5337
  outputJson, outputEmpty, shipCloud, setupCursorHookSignals, hookSessionId, GATEWAY_URL,
5252
- resolveTranscriptPath, emitUsageTick, cursorModelFromPayload, log,
5338
+ resolveTranscriptPath, emitUsageTick, cursorModelFromPayload, log, synkroFilePresent,
5253
5339
  } from './_synkro-common.ts';
5254
5340
 
5255
5341
  async function main() {
@@ -5264,6 +5350,7 @@ async function main() {
5264
5350
 
5265
5351
  const workspaceRoots = Array.isArray(payload.workspace_roots) ? payload.workspace_roots as string[] : [];
5266
5352
  const cwd = payload.cwd || workspaceRoots[0] || '';
5353
+ if (!synkroFilePresent(cwd)) { outputEmpty(); return; }
5267
5354
  const transcriptPath = resolveTranscriptPath(payload);
5268
5355
  const gitRepo = detectRepo(cwd, transcriptPath, '', workspaceRoots);
5269
5356
  const modelFallback = cursorModelFromPayload(payload);
@@ -5317,7 +5404,7 @@ main();
5317
5404
  import {
5318
5405
  loadJwt, detectRepo, channelUp, tag, readStdin, writeCachedRepo,
5319
5406
  outputJson, outputEmpty, setupCursorHookSignals, hookSessionId, resolveTranscriptPath, GATEWAY_URL,
5320
- isLocalStorageMode, loadSynkroFile, log, type HookConfig,
5407
+ isLocalStorageMode, loadSynkroFile, log, synkroFilePresent, type HookConfig,
5321
5408
  } from './_synkro-common.ts';
5322
5409
 
5323
5410
  async function main() {
@@ -5329,6 +5416,7 @@ async function main() {
5329
5416
  const payload = JSON.parse(input);
5330
5417
  const workspaceRoots = Array.isArray(payload.workspace_roots) ? payload.workspace_roots as string[] : [];
5331
5418
  const cwd = payload.cwd || workspaceRoots[0] || '';
5419
+ if (!synkroFilePresent(cwd)) { outputEmpty(); return; }
5332
5420
  const transcriptPath = resolveTranscriptPath(payload);
5333
5421
  const sessionId = hookSessionId(payload);
5334
5422
  const gitRepo = detectRepo(cwd, transcriptPath, '', workspaceRoots);
@@ -5408,7 +5496,7 @@ main();
5408
5496
  import {
5409
5497
  loadJwt, loadConfig, readStdin, hashCommand, consentGrant, consentHasActive, consentConsume,
5410
5498
  appendSessionAction,
5411
- outputEmpty, appendLocalTelemetry, shipCloud, setupCursorHookSignals, isShellTool, hookSessionId, GATEWAY_URL,
5499
+ outputEmpty, appendLocalTelemetry, shipCloud, setupCursorHookSignals, isShellTool, hookSessionId, GATEWAY_URL, synkroFilePresent,
5412
5500
  } from './_synkro-common.ts';
5413
5501
 
5414
5502
  async function main() {
@@ -5418,6 +5506,8 @@ async function main() {
5418
5506
  if (!input.trim()) { outputEmpty(); return; }
5419
5507
 
5420
5508
  const payload = JSON.parse(input);
5509
+ const _cwd = payload.cwd || (Array.isArray(payload.workspace_roots) ? payload.workspace_roots[0] : '') || '';
5510
+ if (!synkroFilePresent(_cwd)) { outputEmpty(); return; }
5421
5511
  const toolName = payload.tool_name || '';
5422
5512
  const shellCmd = typeof payload.command === 'string' ? payload.command : (payload.tool_input?.command || '');
5423
5513
  if (!isShellTool(toolName) && !shellCmd) { outputEmpty(); return; }
@@ -5472,7 +5562,7 @@ main();
5472
5562
  import {
5473
5563
  loadJwt, detectRepo, readStdin, aggregateUsage, appendLocalTelemetry,
5474
5564
  outputEmpty, setupCursorHookSignals, hookSessionId, GATEWAY_URL, readSessionLog, shipCloud,
5475
- resolveTranscriptPath, syncConversationTranscript, emitUsageTick, cursorModelFromPayload,
5565
+ resolveTranscriptPath, syncConversationTranscript, emitUsageTick, cursorModelFromPayload, synkroFilePresent,
5476
5566
  } from './_synkro-common.ts';
5477
5567
  import { readFileSync } from 'node:fs';
5478
5568
 
@@ -5486,6 +5576,7 @@ async function main() {
5486
5576
  const sessionId = hookSessionId(payload);
5487
5577
  const workspaceRoots = Array.isArray(payload.workspace_roots) ? payload.workspace_roots as string[] : [];
5488
5578
  const cwd = payload.cwd || workspaceRoots[0] || '';
5579
+ if (!synkroFilePresent(cwd)) { outputEmpty(); return; }
5489
5580
  const transcriptPath = resolveTranscriptPath(payload);
5490
5581
 
5491
5582
  if (!sessionId || !transcriptPath) {
@@ -5531,7 +5622,7 @@ async function main() {
5531
5622
  main();
5532
5623
  `;
5533
5624
  USER_PROMPT_SUBMIT_TS = `#!/usr/bin/env bun
5534
- import { readStdin, appendLocalTelemetry, aggregateUsage, outputEmpty, setupCursorHookSignals, hookSessionId, detectRepo, resolveTranscriptPath, syncConversationTranscript, emitUsageTick, cursorModelFromPayload } from './_synkro-common.ts';
5625
+ import { readStdin, appendLocalTelemetry, aggregateUsage, outputEmpty, setupCursorHookSignals, hookSessionId, detectRepo, resolveTranscriptPath, syncConversationTranscript, emitUsageTick, cursorModelFromPayload, synkroFilePresent } from './_synkro-common.ts';
5535
5626
  import { writeFileSync, mkdirSync } from 'node:fs';
5536
5627
  import { join, dirname } from 'node:path';
5537
5628
  import { homedir } from 'node:os';
@@ -5542,7 +5633,12 @@ async function main() {
5542
5633
  const input = await readStdin();
5543
5634
  if (!input.trim()) { outputEmpty(); return; }
5544
5635
  const payload = JSON.parse(input);
5545
- const msg = payload.message || payload.prompt || payload.content || '';
5636
+ const _cwd = typeof payload.cwd === 'string' ? payload.cwd
5637
+ : (Array.isArray(payload.workspace_roots) && typeof payload.workspace_roots[0] === 'string' ? payload.workspace_roots[0] : '');
5638
+ if (!synkroFilePresent(_cwd)) { outputEmpty(); return; }
5639
+ const msg = typeof payload.message === 'string' ? payload.message
5640
+ : typeof payload.prompt === 'string' ? payload.prompt
5641
+ : typeof payload.content === 'string' ? payload.content : '';
5546
5642
  if (msg) {
5547
5643
  const promptFile = join(homedir(), '.synkro', '.last-prompt');
5548
5644
  mkdirSync(dirname(promptFile), { recursive: true, mode: 0o700 });
@@ -5581,7 +5677,7 @@ import {
5581
5677
  isSafeInRepoRead, resolveTranscriptPath, postWithRetry, readStdin, hashCommand,
5582
5678
  extractTranscript, readLastPrompt, readSessionLog, compressSessionLog,
5583
5679
  appendLocalTelemetry, logGraderUnavailable, graderUnavailableMessage, log, GATEWAY_URL,
5584
- loadSynkroFile, effectiveGraderPool,
5680
+ loadSynkroFile, effectiveGraderPool, synkroFilePresent,
5585
5681
  type Rule,
5586
5682
  } from './_synkro-common.ts';
5587
5683
  import { createHash } from 'node:crypto';
@@ -5699,6 +5795,10 @@ async function main() {
5699
5795
  const model = rawModel ? (rawModel.startsWith('cursor/') ? rawModel : 'cursor/' + rawModel) : 'cursor';
5700
5796
  const repo = detectRepo(cwd, transcriptPath, command, workspaceRoots);
5701
5797
 
5798
+ // No .synkro at the resolved repo root \u2192 Synkro is dormant here; allow
5799
+ // without grading. Keyed off the validated detectRepo() root, not raw cwd.
5800
+ if (!repo || !synkroFilePresent(repo)) finishAllow();
5801
+
5702
5802
  const cmdShort = command.slice(0, 80);
5703
5803
  log('bashGuard checking: ' + cmdShort);
5704
5804
 
@@ -5882,7 +5982,7 @@ import {
5882
5982
  loadJwt, ensureFreshJwt, detectRepo, readStdin, resolveTranscriptPath,
5883
5983
  appendSessionAction, appendLocalTelemetry, shipCloud, log, GATEWAY_URL,
5884
5984
  countEditLineDelta, dispatchCapture, hookSessionId, cursorModelFromPayload,
5885
- isLocalStorageMode,
5985
+ isLocalStorageMode, synkroFilePresent, isPathUnder,
5886
5986
  } from './_synkro-common.ts';
5887
5987
  import { existsSync, readFileSync } from 'node:fs';
5888
5988
  import { basename, dirname, join } from 'node:path';
@@ -5908,6 +6008,10 @@ async function main() {
5908
6008
  const filePath = String(payload.file_path || payload.path || payload.target_file || '');
5909
6009
  if (!filePath) finish();
5910
6010
 
6011
+ // No .synkro at the resolved repo root \u2192 Synkro is dormant here; skip capture.
6012
+ const _root = detectRepo((typeof payload.cwd === 'string' && payload.cwd) || dirname(filePath), '', filePath, []);
6013
+ if (!_root || !isPathUnder(filePath, _root) || !synkroFilePresent(_root)) finish();
6014
+
5911
6015
  const workspaceRoots = Array.isArray(payload.workspace_roots)
5912
6016
  ? (payload.workspace_roots as unknown[]).filter((r): r is string => typeof r === 'string')
5913
6017
  : [];
@@ -6010,7 +6114,7 @@ main().catch((e) => {
6010
6114
  /** Capture Cursor agent thinking/response text before transcript JSONL redacts it. */
6011
6115
  import {
6012
6116
  readStdin, outputEmpty, setupCursorHookSignals, hookSessionId, detectRepo,
6013
- appendThoughtOverlay, pushConversationMessage,
6117
+ appendThoughtOverlay, pushConversationMessage, synkroFilePresent,
6014
6118
  } from './_synkro-common.ts';
6015
6119
 
6016
6120
  async function main() {
@@ -6030,6 +6134,7 @@ async function main() {
6030
6134
  ? payload.workspace_roots.filter((r): r is string => typeof r === 'string')
6031
6135
  : [];
6032
6136
  const cwd = (typeof payload.cwd === 'string' && payload.cwd) || workspaceRoots[0] || '';
6137
+ if (!synkroFilePresent(cwd)) { outputEmpty(); return; }
6033
6138
  const gitRepo = detectRepo(cwd, '', '', workspaceRoots);
6034
6139
 
6035
6140
  if (event === 'afterAgentThought') {
@@ -6047,6 +6152,149 @@ async function main() {
6047
6152
 
6048
6153
  main();
6049
6154
  `;
6155
+ STUB_COMMON_TS = String.raw`import { existsSync, readFileSync, mkdirSync, writeFileSync } from 'node:fs';
6156
+ import { execSync } from 'node:child_process';
6157
+ import { homedir } from 'node:os';
6158
+ import { join } from 'node:path';
6159
+
6160
+ const HOME = homedir();
6161
+ const PORT = process.env.SYNKRO_MCP_PORT || '18931';
6162
+
6163
+ function loadMcpJwt(): string {
6164
+ try { return readFileSync(join(HOME, '.synkro', '.mcp-jwt'), 'utf-8').trim(); } catch { return ''; }
6165
+ }
6166
+
6167
+ async function readStdin(): Promise<string> {
6168
+ const chunks: Buffer[] = [];
6169
+ for await (const chunk of process.stdin) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
6170
+ return Buffer.concat(chunks).toString('utf-8');
6171
+ }
6172
+
6173
+ function isCursor(harnessOpt?: string): boolean {
6174
+ return harnessOpt === 'cursor' || process.argv.includes('--cursor') || process.env.SYNKRO_HOOK_FORMAT === 'cursor';
6175
+ }
6176
+
6177
+ function failOpen(harness: string): string {
6178
+ return harness === 'cursor' ? '{"permission":"allow"}' : '{}';
6179
+ }
6180
+
6181
+ function out(s: string): void { try { process.stdout.write(s + '\n'); } catch {} }
6182
+
6183
+ function gitRoot(cwd: string): string {
6184
+ if (!cwd) return '';
6185
+ try {
6186
+ return execSync('git rev-parse --show-toplevel', { cwd, timeout: 2000, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
6187
+ } catch { return ''; }
6188
+ }
6189
+
6190
+ // Once-per-session onboarding hint for repos with no .synkro file.
6191
+ function noSynkroHint(sessionId: string): string | null {
6192
+ const dir = join(HOME, '.synkro', '.no-synkro-hint');
6193
+ try {
6194
+ mkdirSync(dir, { recursive: true });
6195
+ const key = (sessionId || 'nosession').replace(/[^a-zA-Z0-9_-]/g, '_');
6196
+ const marker = join(dir, key);
6197
+ if (existsSync(marker)) return null;
6198
+ writeFileSync(marker, '', { flag: 'w' });
6199
+ } catch {}
6200
+ return '[synkro] No .synkro config in this repo — grading skipped. Run synkro install here to enable Synkro.';
6201
+ }
6202
+
6203
+ function filePathFromToolInput(ti: any): string {
6204
+ if (!ti || typeof ti !== 'object') return '';
6205
+ return ti.file_path || ti.notebook_path || ti.path || ti.target_file || '';
6206
+ }
6207
+
6208
+ interface StubOpts {
6209
+ needsFile?: boolean;
6210
+ needsTranscript?: boolean;
6211
+ // Ship the WHOLE transcript (not just the 200KB tail). Sync surfaces need it
6212
+ // so the server can compute stable absolute message indices + total usage.
6213
+ fullTranscript?: boolean;
6214
+ needsPlan?: boolean;
6215
+ telemetry?: boolean;
6216
+ harness?: string;
6217
+ }
6218
+
6219
+ export async function runStub(surface: string, opts: StubOpts = {}): Promise<void> {
6220
+ const harness = isCursor(opts.harness) ? 'cursor' : 'cc';
6221
+ try {
6222
+ const input = await readStdin();
6223
+ if (!input.trim()) { out(failOpen(harness)); return; }
6224
+ const payload = JSON.parse(input);
6225
+
6226
+ const workspaceRoots = Array.isArray(payload.workspace_roots) ? payload.workspace_roots : [];
6227
+ const cwd = (typeof payload.cwd === 'string' && payload.cwd) || workspaceRoots[0] || '';
6228
+ const sessionId = String(payload.session_id || payload.conversation_id || '');
6229
+ const root = gitRoot(cwd);
6230
+
6231
+ // Dormancy: a repo is onboarded only if it has a .synkro at its git root.
6232
+ let synkroFileText = '';
6233
+ if (root && existsSync(join(root, '.synkro'))) {
6234
+ try { synkroFileText = readFileSync(join(root, '.synkro'), 'utf-8'); } catch {}
6235
+ }
6236
+ if (!synkroFileText) {
6237
+ if (opts.telemetry) { out(failOpen(harness)); return; }
6238
+ const hint = noSynkroHint(sessionId);
6239
+ if (!hint) { out(failOpen(harness)); return; }
6240
+ if (harness === 'cursor') out(JSON.stringify({ permission: 'allow', user_message: hint }));
6241
+ else out(JSON.stringify({ systemMessage: hint, hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: hint } }));
6242
+ return;
6243
+ }
6244
+
6245
+ // Gather host-only inputs the container can't read.
6246
+ const envelope: any = { payload, harness, cwd: root || cwd, sessionId, synkroFileText };
6247
+
6248
+ if (opts.needsFile) {
6249
+ const fp = filePathFromToolInput(payload.tool_input || {});
6250
+ if (fp && existsSync(fp)) {
6251
+ try { envelope.baseContent = readFileSync(fp, 'utf-8').slice(0, 65536); } catch {}
6252
+ }
6253
+ }
6254
+ if (opts.needsTranscript) {
6255
+ const tp = typeof payload.transcript_path === 'string' ? payload.transcript_path : '';
6256
+ if (tp && existsSync(tp)) {
6257
+ try {
6258
+ const t = readFileSync(tp, 'utf-8');
6259
+ envelope.transcript = (opts.fullTranscript || t.length <= 200000) ? t : t.slice(t.length - 200000);
6260
+ } catch {}
6261
+ }
6262
+ }
6263
+ if (opts.needsPlan) {
6264
+ const ti = payload.tool_input || {};
6265
+ envelope.planText = String(ti.plan || ti.content || payload.plan || '');
6266
+ }
6267
+
6268
+ const timeoutMs = opts.telemetry ? 6000 : 28000;
6269
+ const url = 'http://127.0.0.1:' + PORT + '/api/scan/' + surface + (harness === 'cursor' ? '?harness=cursor' : '');
6270
+ const resp = await fetch(url, {
6271
+ method: 'POST',
6272
+ headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + loadMcpJwt() },
6273
+ body: JSON.stringify(envelope),
6274
+ signal: AbortSignal.timeout(timeoutMs),
6275
+ });
6276
+ const text = await resp.text();
6277
+ out(resp.ok ? (text.trim() || failOpen(harness)) : failOpen(harness));
6278
+ } catch {
6279
+ out(failOpen(harness));
6280
+ }
6281
+ }
6282
+ `;
6283
+ STUB_EDIT_PRECHECK_TS = stubHook("edit-precheck", "{ needsFile: true, needsTranscript: true }");
6284
+ STUB_CWE_PRECHECK_TS = stubHook("cwe-precheck", "{ needsFile: true, needsTranscript: true }");
6285
+ STUB_CVE_PRECHECK_TS = stubHook("cve-precheck", "{ needsFile: true, needsTranscript: true }");
6286
+ STUB_BASH_JUDGE_TS = stubHook("bash-judge", "{ needsTranscript: true }");
6287
+ STUB_INSTALL_SCAN_TS = stubHook("install-scan", "{ needsTranscript: true }");
6288
+ STUB_AGENT_JUDGE_TS = stubHook("agent-judge", "{ needsTranscript: true }");
6289
+ STUB_PLAN_JUDGE_TS = stubHook("plan-judge", "{ needsPlan: true }");
6290
+ STUB_STOP_SUMMARY_TS = stubHook("stop-summary", "{ needsTranscript: true, fullTranscript: true, telemetry: true }");
6291
+ STUB_SESSION_START_TS = stubHook("session-start", "{ telemetry: true }");
6292
+ STUB_TRANSCRIPT_SYNC_TS = stubHook("transcript-sync", "{ needsTranscript: true, fullTranscript: true, telemetry: true }");
6293
+ STUB_USER_PROMPT_SUBMIT_TS = stubHook("prompt-submit", "{ telemetry: true }");
6294
+ STUB_BASH_FOLLOWUP_TS = stubHook("bash-followup", "{ telemetry: true }");
6295
+ STUB_CURSOR_BASH_JUDGE_TS = stubHook("bash-judge", "{ needsTranscript: true, harness: 'cursor' }");
6296
+ STUB_CURSOR_EDIT_CAPTURE_TS = stubHook("edit-precheck", "{ needsFile: true, needsTranscript: true, harness: 'cursor' }");
6297
+ STUB_CURSOR_AGENT_CAPTURE_TS = stubHook("agent-judge", "{ needsTranscript: true, harness: 'cursor' }");
6050
6298
  }
6051
6299
  });
6052
6300
 
@@ -7392,10 +7640,20 @@ async function dockerInstall(opts = {}) {
7392
7640
  } else {
7393
7641
  mkdirSync7(join6(homedir6(), ".claude"), { recursive: true });
7394
7642
  }
7395
- console.log(` Pulling ${image}...`);
7396
- const pull = spawnSync2("docker", ["pull", image], { encoding: "utf-8", stdio: "inherit", timeout: 6e5 });
7397
- if (pull.status !== 0) {
7398
- throw new DockerInstallError(`docker pull ${image} failed`);
7643
+ const imageExistsLocally = () => spawnSync2("docker", ["image", "inspect", image], { stdio: "ignore", timeout: 3e4 }).status === 0;
7644
+ const skipPull = process.env.SYNKRO_SKIP_PULL === "1" || process.env.SYNKRO_SKIP_PULL === "true";
7645
+ if (skipPull && imageExistsLocally()) {
7646
+ console.log(` Using local image ${image} (SYNKRO_SKIP_PULL set \u2014 pull skipped).`);
7647
+ } else {
7648
+ console.log(` Pulling ${image}...`);
7649
+ const pull = spawnSync2("docker", ["pull", image], { encoding: "utf-8", stdio: "inherit", timeout: 6e5 });
7650
+ if (pull.status !== 0) {
7651
+ if (imageExistsLocally()) {
7652
+ console.warn(` \u26A0 docker pull ${image} failed \u2014 falling back to the existing local image.`);
7653
+ } else {
7654
+ throw new DockerInstallError(`docker pull ${image} failed`);
7655
+ }
7656
+ }
7399
7657
  }
7400
7658
  const existing = dockerStatus();
7401
7659
  if (existing.running) {
@@ -8066,11 +8324,21 @@ __export(install_exports, {
8066
8324
  syncSkillFiles: () => syncSkillFiles,
8067
8325
  writeHookScripts: () => writeHookScripts
8068
8326
  });
8069
- import { existsSync as existsSync10, mkdirSync as mkdirSync8, writeFileSync as writeFileSync7, chmodSync as chmodSync2, readFileSync as readFileSync8, readdirSync as readdirSync3 } from "fs";
8327
+ import { existsSync as existsSync10, mkdirSync as mkdirSync8, writeFileSync as writeFileSync7, chmodSync as chmodSync2, readFileSync as readFileSync8, readdirSync as readdirSync3, unlinkSync as unlinkSync4 } from "fs";
8070
8328
  import { homedir as homedir8 } from "os";
8071
8329
  import { join as join8 } from "path";
8072
8330
  import { execSync as execSync6 } from "child_process";
8073
8331
  import { createInterface as createInterface3 } from "readline";
8332
+ function resolvePersistedHookMode() {
8333
+ if (process.env.SYNKRO_HOOK_MODE === "stub") return "stub";
8334
+ if (process.env.SYNKRO_HOOK_MODE === "full") return "full";
8335
+ try {
8336
+ const env = readFileSync8(CONFIG_PATH2, "utf-8");
8337
+ if (/^SYNKRO_HOOK_MODE=['"]?stub['"]?\s*$/m.test(env)) return "stub";
8338
+ } catch {
8339
+ }
8340
+ return "full";
8341
+ }
8074
8342
  function sanitizeGatewayCandidate(raw) {
8075
8343
  if (!raw) return void 0;
8076
8344
  return /^https?:\/\//.test(raw) ? raw : void 0;
@@ -8085,7 +8353,13 @@ function parseArgs(argv) {
8085
8353
  else if (a === "--no-mcp") opts.noMcp = true;
8086
8354
  else if (a === "--force" || a === "-f") opts.force = true;
8087
8355
  else if (a === "--link-repo") opts.linkRepo = true;
8356
+ else if (a === "--stub" || a === "--mode=stub") opts.hookMode = "stub";
8357
+ else if (a === "--mode=full") opts.hookMode = "full";
8088
8358
  }
8359
+ const modeIdx = argv.indexOf("--mode");
8360
+ if (modeIdx >= 0 && argv[modeIdx + 1] === "stub") opts.hookMode = "stub";
8361
+ if (modeIdx >= 0 && argv[modeIdx + 1] === "full") opts.hookMode = "full";
8362
+ if (!opts.hookMode && process.env.SYNKRO_HOOK_MODE === "stub") opts.hookMode = "stub";
8089
8363
  if (!opts.gatewayUrl) {
8090
8364
  const fromEnv = sanitizeGatewayCandidate(process.env.SYNKRO_GATEWAY_URL);
8091
8365
  if (fromEnv) opts.gatewayUrl = fromEnv;
@@ -8196,7 +8470,7 @@ function ensureSynkroDir() {
8196
8470
  mkdirSync8(OFFSETS_DIR, { recursive: true });
8197
8471
  mkdirSync8(join8(SYNKRO_DIR4, "sessions"), { recursive: true });
8198
8472
  }
8199
- function writeHookScripts() {
8473
+ function writeHookScripts(mode = "full") {
8200
8474
  const installExtractCorePath = join8(HOOKS_DIR, "installExtractCore.ts");
8201
8475
  const bashScriptPath = join8(HOOKS_DIR, "cc-bash-judge.ts");
8202
8476
  const bashFollowupScriptPath = join8(HOOKS_DIR, "cc-bash-followup.ts");
@@ -8216,6 +8490,56 @@ function writeHookScripts() {
8216
8490
  const cursorEditCapturePath = join8(HOOKS_DIR, "cursor-edit-capture.ts");
8217
8491
  const cursorAgentCapturePath = join8(HOOKS_DIR, "cursor-agent-capture.ts");
8218
8492
  const mcpStdioProxyPath = join8(HOOKS_DIR, "mcp-stdio-proxy.ts");
8493
+ if (mode === "stub") {
8494
+ const stubCommonPath = join8(HOOKS_DIR, "_synkro-stub-common.ts");
8495
+ const stubFiles = [
8496
+ [stubCommonPath, STUB_COMMON_TS],
8497
+ [bashScriptPath, STUB_BASH_JUDGE_TS],
8498
+ [bashFollowupScriptPath, STUB_BASH_FOLLOWUP_TS],
8499
+ [editPrecheckScriptPath, STUB_EDIT_PRECHECK_TS],
8500
+ [cwePrecheckScriptPath, STUB_CWE_PRECHECK_TS],
8501
+ [cvePrecheckScriptPath, STUB_CVE_PRECHECK_TS],
8502
+ [planJudgeScriptPath, STUB_PLAN_JUDGE_TS],
8503
+ [agentJudgeScriptPath, STUB_AGENT_JUDGE_TS],
8504
+ [stopSummaryScriptPath, STUB_STOP_SUMMARY_TS],
8505
+ [sessionStartScriptPath, STUB_SESSION_START_TS],
8506
+ [transcriptSyncScriptPath, STUB_TRANSCRIPT_SYNC_TS],
8507
+ [userPromptSubmitScriptPath, STUB_USER_PROMPT_SUBMIT_TS],
8508
+ [installScanScriptPath, STUB_INSTALL_SCAN_TS],
8509
+ [cursorBashJudgePath, STUB_CURSOR_BASH_JUDGE_TS],
8510
+ [cursorEditCapturePath, STUB_CURSOR_EDIT_CAPTURE_TS],
8511
+ [cursorAgentCapturePath, STUB_CURSOR_AGENT_CAPTURE_TS]
8512
+ ];
8513
+ for (const [p, content] of stubFiles) {
8514
+ writeFileSync7(p, content, "utf-8");
8515
+ chmodSync2(p, 493);
8516
+ }
8517
+ writeFileSync7(mcpStdioProxyPath, MCP_STDIO_PROXY_SRC, "utf-8");
8518
+ chmodSync2(mcpStdioProxyPath, 493);
8519
+ for (const stale of ["_synkro-common.ts", "_synkro-common.sh", "installExtractCore.ts"]) {
8520
+ try {
8521
+ unlinkSync4(join8(HOOKS_DIR, stale));
8522
+ } catch {
8523
+ }
8524
+ }
8525
+ return {
8526
+ bashScript: bashScriptPath,
8527
+ bashFollowupScript: bashFollowupScriptPath,
8528
+ editPrecheckScript: editPrecheckScriptPath,
8529
+ cwePrecheckScript: cwePrecheckScriptPath,
8530
+ cvePrecheckScript: cvePrecheckScriptPath,
8531
+ planJudgeScript: planJudgeScriptPath,
8532
+ agentJudgeScript: agentJudgeScriptPath,
8533
+ stopSummaryScript: stopSummaryScriptPath,
8534
+ sessionStartScript: sessionStartScriptPath,
8535
+ transcriptSyncScript: transcriptSyncScriptPath,
8536
+ userPromptSubmitScript: userPromptSubmitScriptPath,
8537
+ installScanScript: installScanScriptPath,
8538
+ cursorBashJudgeScript: cursorBashJudgePath,
8539
+ cursorEditCaptureScript: cursorEditCapturePath,
8540
+ cursorAgentCaptureScript: cursorAgentCapturePath
8541
+ };
8542
+ }
8219
8543
  writeFileSync7(bashScriptPath, BASH_JUDGE_TS, "utf-8");
8220
8544
  writeFileSync7(bashFollowupScriptPath, BASH_FOLLOWUP_TS, "utf-8");
8221
8545
  writeFileSync7(editPrecheckScriptPath, EDIT_PRECHECK_TS, "utf-8");
@@ -8234,7 +8558,7 @@ function writeHookScripts() {
8234
8558
  writeFileSync7(cursorEditCapturePath, CURSOR_EDIT_CAPTURE_TS, "utf-8");
8235
8559
  writeFileSync7(cursorAgentCapturePath, CURSOR_AGENT_CAPTURE_TS, "utf-8");
8236
8560
  writeFileSync7(mcpStdioProxyPath, MCP_STDIO_PROXY_SRC, "utf-8");
8237
- 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");
8561
+ 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 if (ecosystem === 'Packagist') {\n // composer uses vendor/package:version-constraint (e.g. monolog/monolog:1.0.0).\n // Split on the first ':' after the vendor/ slash; never on the '/'.\n const ci = tok.indexOf(':');\n if (ci > 0) return { ecosystem, name: tok.slice(0, ci), versionSpec: tok.slice(ci + 1) || null, source: 'registry' };\n return { ecosystem, name: tok, versionSpec: 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 // gem pins the version with a separate `-v`/`--version` flag rather than an\n // inline spec; capture it and apply to the package(s) in this segment.\n let flagVersion: string | null = null;\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 const lower = tok.toLowerCase();\n if (ecosystem === 'RubyGems' && (lower === '-v' || lower === '--version')) {\n flagVersion = (argv[i + 1] || '').replace(/^[v=\\s]+/, '') || null;\n i++;\n continue;\n }\n if (VALUE_FLAGS.has(tok)) i++;\n continue;\n }\n const parsed = parsePackageToken(tok, ecosystem);\n if (parsed) installs.push(parsed);\n }\n if (flagVersion) {\n for (const ins of installs) {\n if (ins.source === 'registry' && !ins.versionSpec) ins.versionSpec = flagVersion;\n }\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");
8238
8562
  chmodSync2(bashScriptPath, 493);
8239
8563
  chmodSync2(bashFollowupScriptPath, 493);
8240
8564
  chmodSync2(editPrecheckScriptPath, 493);
@@ -8254,6 +8578,10 @@ function writeHookScripts() {
8254
8578
  chmodSync2(cursorAgentCapturePath, 493);
8255
8579
  chmodSync2(mcpStdioProxyPath, 493);
8256
8580
  chmodSync2(installExtractCorePath, 493);
8581
+ try {
8582
+ unlinkSync4(join8(HOOKS_DIR, "_synkro-stub-common.ts"));
8583
+ } catch {
8584
+ }
8257
8585
  return {
8258
8586
  bashScript: bashScriptPath,
8259
8587
  bashFollowupScript: bashFollowupScriptPath,
@@ -8301,7 +8629,7 @@ function writeConfigEnv(opts) {
8301
8629
  `SYNKRO_CREDENTIALS_PATH=${shellQuoteSingle(credsPath)}`,
8302
8630
  `SYNKRO_TIER=${shellQuoteSingle(safeTier)}`,
8303
8631
  `SYNKRO_INFERENCE=${shellQuoteSingle(safeInference)}`,
8304
- `SYNKRO_VERSION=${shellQuoteSingle("1.6.45")}`
8632
+ `SYNKRO_VERSION=${shellQuoteSingle("1.6.47")}`
8305
8633
  ];
8306
8634
  if (safeSynkroBin) lines.push(`SYNKRO_CLI_BIN=${shellQuoteSingle(safeSynkroBin)}`);
8307
8635
  if (safeUserId) lines.push(`SYNKRO_USER_ID=${shellQuoteSingle(safeUserId)}`);
@@ -8315,6 +8643,7 @@ function writeConfigEnv(opts) {
8315
8643
  lines.push(`SYNKRO_DEPLOYMENT_MODE=${shellQuoteSingle(safeMode)}`);
8316
8644
  lines.push(`SYNKRO_GRADING_MODE=${shellQuoteSingle(sanitizeConfigValue(opts.gradingMode ?? "local", 16))}`);
8317
8645
  lines.push(`SYNKRO_STORAGE_MODE=${shellQuoteSingle(sanitizeConfigValue(opts.storageMode ?? "local", 16))}`);
8646
+ lines.push(`SYNKRO_HOOK_MODE=${shellQuoteSingle(sanitizeConfigValue(opts.hookMode ?? "full", 8))}`);
8318
8647
  lines.push("");
8319
8648
  writeFileSync7(CONFIG_PATH2, lines.join("\n"), "utf-8");
8320
8649
  chmodSync2(CONFIG_PATH2, 384);
@@ -8533,7 +8862,8 @@ async function installCommand(opts = {}) {
8533
8862
  }
8534
8863
  }
8535
8864
  ensureSynkroDir();
8536
- const scripts = writeHookScripts();
8865
+ const hookMode = opts.hookMode || resolvePersistedHookMode();
8866
+ const scripts = writeHookScripts(hookMode);
8537
8867
  console.log("Wrote hook scripts to ~/.synkro/hooks/\n");
8538
8868
  for (const mode of ["edit", "bash"]) {
8539
8869
  const pidFile = join8(SYNKRO_DIR4, "daemon", mode, "daemon.pid");
@@ -8704,7 +9034,7 @@ async function installCommand(opts = {}) {
8704
9034
  }
8705
9035
  const synkroBundle = resolveSynkroBundle();
8706
9036
  const persistedMode = resolveDeploymentMode();
8707
- writeConfigEnv({ gatewayUrl, userId, orgId, email, tier: profile.tier, inference: profile.inference, synkroBin: synkroBundle, transcriptConsent, localInference: profile.localInference, deploymentMode: persistedMode, gradingMode, storageMode });
9037
+ writeConfigEnv({ gatewayUrl, userId, orgId, email, tier: profile.tier, inference: profile.inference, synkroBin: synkroBundle, transcriptConsent, localInference: profile.localInference, deploymentMode: persistedMode, gradingMode, storageMode, hookMode });
8708
9038
  console.log(`Wrote config to ${CONFIG_PATH2}`);
8709
9039
  console.log(` inference: ${profile.inference} (server-side grading)`);
8710
9040
  if (profile.localInference) console.log(` local inference: enabled (gradingProvider=claude-code)`);
@@ -9008,7 +9338,7 @@ function reconcileHarness() {
9008
9338
  const wantCC = sf.harness.includes("claude-code");
9009
9339
  const wantCursor = sf.harness.includes("cursor");
9010
9340
  console.log(`.synkro: harness=[${sf.harness.join(", ")}] pool=${sf.grader.pool} mode=${sf.grader.mode}`);
9011
- const scripts = writeHookScripts();
9341
+ const scripts = writeHookScripts(resolvePersistedHookMode());
9012
9342
  console.log("Wrote hook scripts to ~/.synkro/hooks/");
9013
9343
  const ccSettings = join8(homedir8(), ".claude", "settings.json");
9014
9344
  if (wantCC) {
@@ -9366,6 +9696,7 @@ var init_install = __esm({
9366
9696
  init_skillParser();
9367
9697
  init_hookScripts();
9368
9698
  init_hookScriptsTs();
9699
+ init_hookScriptsTs();
9369
9700
  init_stub();
9370
9701
  init_repoConnect();
9371
9702
  init_projects();
@@ -9426,7 +9757,7 @@ rl.on('line', async (line) => {
9426
9757
  });
9427
9758
 
9428
9759
  // cli/local-cc/install.ts
9429
- import { existsSync as existsSync11, mkdirSync as mkdirSync9, writeFileSync as writeFileSync8, readFileSync as readFileSync9, chmodSync as chmodSync3, copyFileSync as copyFileSync2, renameSync as renameSync4, unlinkSync as unlinkSync4, openSync, fsyncSync, closeSync } from "fs";
9760
+ import { existsSync as existsSync11, mkdirSync as mkdirSync9, writeFileSync as writeFileSync8, readFileSync as readFileSync9, chmodSync as chmodSync3, copyFileSync as copyFileSync2, renameSync as renameSync4, unlinkSync as unlinkSync5, openSync, fsyncSync, closeSync } from "fs";
9430
9761
  import { join as join9 } from "path";
9431
9762
  import { homedir as homedir9 } from "os";
9432
9763
  import { spawnSync as spawnSync4 } from "child_process";
@@ -9507,7 +9838,7 @@ function safelyMutateClaudeJson(mutator) {
9507
9838
  renameSync4(tmpPath, CLAUDE_JSON_PATH);
9508
9839
  } catch (err) {
9509
9840
  try {
9510
- unlinkSync4(tmpPath);
9841
+ unlinkSync5(tmpPath);
9511
9842
  } catch {
9512
9843
  }
9513
9844
  try {
@@ -11386,7 +11717,7 @@ var args = process.argv.slice(2);
11386
11717
  var cmd = args[0] || "";
11387
11718
  var subArgs = args.slice(1);
11388
11719
  function printVersion() {
11389
- console.log("1.6.45");
11720
+ console.log("1.6.47");
11390
11721
  }
11391
11722
  function printHelp2() {
11392
11723
  console.log(`Synkro CLI \u2014 runtime safety for AI coding agents