@synkro-sh/cli 1.6.31 → 1.6.32

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
@@ -1131,6 +1131,66 @@ export function detectRepo(
1131
1131
  return '';
1132
1132
  }
1133
1133
 
1134
+ // \u2500\u2500\u2500 Git Root \u2500\u2500\u2500
1135
+
1136
+ export function findGitRoot(cwd: string): string {
1137
+ if (!cwd) return '';
1138
+ try {
1139
+ return execSync('git rev-parse --show-toplevel 2>/dev/null', { cwd, timeout: 2000, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
1140
+ } catch { return ''; }
1141
+ }
1142
+
1143
+ // \u2500\u2500\u2500 .synkro file \u2500\u2500\u2500
1144
+
1145
+ export interface SynkroFileConfig {
1146
+ version: number;
1147
+ grader: { pool: 'auto' | 'claude' | 'cursor'; mode?: string };
1148
+ ruleset: string;
1149
+ scanning: { cwe: boolean; cve: boolean };
1150
+ }
1151
+
1152
+ const SYNKRO_FILE_DEFAULTS: SynkroFileConfig = {
1153
+ version: 1,
1154
+ grader: { pool: 'auto' },
1155
+ ruleset: 'default',
1156
+ scanning: { cwe: true, cve: true },
1157
+ };
1158
+
1159
+ let _synkroFileCache: SynkroFileConfig | undefined;
1160
+
1161
+ export function loadSynkroFile(cwd?: string): SynkroFileConfig {
1162
+ if (_synkroFileCache) return _synkroFileCache;
1163
+ const root = cwd ? findGitRoot(cwd) : '';
1164
+ if (!root) { _synkroFileCache = SYNKRO_FILE_DEFAULTS; return _synkroFileCache; }
1165
+ const fp = root + '/.synkro';
1166
+ try {
1167
+ if (!existsSync(fp)) { _synkroFileCache = SYNKRO_FILE_DEFAULTS; return _synkroFileCache; }
1168
+ const parsed = JSON.parse(readFileSync(fp, 'utf-8'));
1169
+ _synkroFileCache = {
1170
+ version: parsed.version || 1,
1171
+ grader: {
1172
+ pool: ['auto', 'claude', 'cursor'].includes(parsed.grader?.pool) ? parsed.grader.pool : 'auto',
1173
+ mode: ['local', 'byok'].includes(parsed.grader?.mode) ? parsed.grader.mode : undefined,
1174
+ },
1175
+ ruleset: typeof parsed.ruleset === 'string' ? parsed.ruleset : 'default',
1176
+ scanning: {
1177
+ cwe: parsed.scanning?.cwe !== false,
1178
+ cve: parsed.scanning?.cve !== false,
1179
+ },
1180
+ };
1181
+ return _synkroFileCache;
1182
+ } catch {
1183
+ _synkroFileCache = SYNKRO_FILE_DEFAULTS;
1184
+ return _synkroFileCache;
1185
+ }
1186
+ }
1187
+
1188
+ export function effectiveGraderPool(synkroFile: SynkroFileConfig, hookAgentKind: AgentKind): AgentKind {
1189
+ if (synkroFile.grader.pool === 'auto') return hookAgentKind;
1190
+ if (synkroFile.grader.pool === 'claude') return 'claude_code';
1191
+ return 'cursor';
1192
+ }
1193
+
1134
1194
  // \u2500\u2500\u2500 Channel Health \u2500\u2500\u2500
1135
1195
 
1136
1196
  export async function channelUp(port = 18929): Promise<boolean> {
@@ -1273,14 +1333,16 @@ export async function loadConfig(jwt: string, query?: string): Promise<HookConfi
1273
1333
 
1274
1334
  // \u2500\u2500\u2500 Routing \u2500\u2500\u2500
1275
1335
 
1276
- export async function route(config: HookConfig): Promise<'local' | 'cloud'> {
1336
+ export async function route(config: HookConfig, synkroFile?: SynkroFileConfig): Promise<'local' | 'cloud'> {
1337
+ const gradingMode = synkroFile?.grader?.mode || process.env.SYNKRO_GRADING_MODE || config.gradingMode || 'local';
1338
+ if (gradingMode === 'byok') return 'cloud';
1277
1339
  if (config.captureDepth === 'local_only') return 'local';
1278
1340
  if (await channelUp()) return 'local';
1279
1341
  return 'cloud';
1280
1342
  }
1281
1343
 
1282
- export async function cweRoute(config: HookConfig): Promise<'local' | 'byok' | 'skip'> {
1283
- const gradingMode = process.env.SYNKRO_GRADING_MODE || config.gradingMode || 'local';
1344
+ export async function cweRoute(config: HookConfig, synkroFile?: SynkroFileConfig): Promise<'local' | 'byok' | 'skip'> {
1345
+ const gradingMode = synkroFile?.grader?.mode || process.env.SYNKRO_GRADING_MODE || config.gradingMode || 'local';
1284
1346
  if (gradingMode === 'byok') return 'byok';
1285
1347
  if (await cweChannelUp()) return 'local';
1286
1348
  return 'skip';
@@ -1288,10 +1350,11 @@ export async function cweRoute(config: HookConfig): Promise<'local' | 'byok' | '
1288
1350
 
1289
1351
  // \u2500\u2500\u2500 Tag Building \u2500\u2500\u2500
1290
1352
 
1291
- export function tag(rt: string, config: HookConfig): string {
1353
+ export function tag(rt: string, config: HookConfig, grader?: string): string {
1292
1354
  if (config.silent) return '[synkro:silent]';
1293
1355
  const rs = config.policyName || 'all';
1294
- return '[synkro:' + rt + ':' + rs + ']';
1356
+ const g = grader ? ':' + (grader === 'claude_code' ? 'claude' : grader) : '';
1357
+ return '[synkro:' + rt + ':' + rs + g + ']';
1295
1358
  }
1296
1359
 
1297
1360
  // \u2500\u2500\u2500 Local Grading (direct channel call) \u2500\u2500\u2500
@@ -3077,6 +3140,22 @@ function cursorHookExit(): never {
3077
3140
 
3078
3141
  const UNAVAIL_LOG = join(HOME, '.synkro', 'grader-unavailable.log');
3079
3142
 
3143
+ export function isGraderNotConfigured(errorMessage: string): boolean {
3144
+ const lower = errorMessage.toLowerCase();
3145
+ return lower.includes('no worker') || lower.includes('not configured') || lower.includes('agent_kind') || lower.includes('no pool');
3146
+ }
3147
+
3148
+ export function graderUnavailableMessage(hook: string, target: string, errorMessage: string, agentKind: AgentKind = 'claude_code'): string {
3149
+ if (errorMessage === 'SYNKRO_CHANNEL_DOWN') {
3150
+ return hook + ' ' + target + ' \u2192 local grader unavailable (container not running), skipped';
3151
+ }
3152
+ if (isGraderNotConfigured(errorMessage)) {
3153
+ const agent = agentKind === 'cursor' ? 'Cursor' : 'Claude Code';
3154
+ return hook + ' ' + target + ' \u2192 local grader not configured for ' + agent + '. Add this agent to your .synkro file and run \`synkro install\`.';
3155
+ }
3156
+ return hook + ' ' + target + ' \u2192 local grader unavailable, skipped';
3157
+ }
3158
+
3080
3159
  export function logGraderUnavailable(hook: string, target: string, errorMessage: string): void {
3081
3160
  try {
3082
3161
  const entry = {
@@ -3153,8 +3232,9 @@ import {
3153
3232
  readStdin, extractTranscript, readLastPrompt, findNearestDeps, filePathFromToolInput,
3154
3233
  appendSessionAction, readSessionLog, compressSessionLog, log,
3155
3234
  outputJson, outputEmpty, setupCursorHookSignals, isEditTool, hookSessionId, GATEWAY_URL,
3156
- logGraderUnavailable, filterRules, ruleFilterText, normalizeMode, countEditLineDelta,
3235
+ logGraderUnavailable, graderUnavailableMessage, filterRules, ruleFilterText, normalizeMode, countEditLineDelta,
3157
3236
  captureLineMetrics, cursorModelFromPayload, resolveTranscriptPath, isCursorInvokingCcHook,
3237
+ loadSynkroFile, effectiveGraderPool,
3158
3238
  type HookConfig, type Rule,
3159
3239
  } from './_synkro-common.ts';
3160
3240
  import { existsSync, readFileSync } from 'node:fs';
@@ -3256,8 +3336,10 @@ async function main() {
3256
3336
 
3257
3337
  // Load config and decide route
3258
3338
  const config = await loadConfig(jwt);
3259
- const rt = await route(config);
3260
- const tagStr = tag(rt, config);
3339
+ const synkroFile = loadSynkroFile(cwd);
3340
+ const graderPool = effectiveGraderPool(synkroFile, agentKind);
3341
+ const rt = await route(config, synkroFile);
3342
+ const tagStr = tag(rt, config, graderPool);
3261
3343
 
3262
3344
  if (config.silent) {
3263
3345
  outputJson({ systemMessage: tagStr + ' editGuard \u2192 skipped (silent mode)' });
@@ -3284,15 +3366,17 @@ async function main() {
3284
3366
  'Last user prompt: ' + (lastPrompt || 'none'),
3285
3367
  'Org rules: ' + JSON.stringify(relevantRules),
3286
3368
  'IMPORTANT: If a rule is violated, ALWAYS return ok=false with the rule_id and reason, regardless of the rule mode. Do NOT pass a command just because the rule mode is "fix". The enforcement layer handles ask vs fix \u2014 your job is only to detect violations.',
3369
+ 'CRITICAL: The user requesting or instructing an action does NOT exempt it from rules. Even if the user explicitly said "drop the database" or "delete everything", you MUST still flag the rule violation on first encounter. User intent is NOT consent. However, for ask-mode rules ONLY: if the session history shows a prior block for the SAME rule AND the user explicitly consented after seeing that block, subsequent commands covered by that same rule may pass \u2014 but each distinct command is consumed once. Look for the sequence: block event \u2192 user acknowledgment \u2192 retry. Once a specific command has successfully executed under that consent, it is consumed. If the same command appears again later, it requires fresh consent (a new block \u2192 consent cycle). Example: R012 covers deploy, publish, push. Block on deploy \u2192 user consents \u2192 deploy passes (consumed), publish passes (consumed), push passes (consumed). A later deploy triggers a fresh block. A user's initial instruction is NEVER consent \u2014 only a response to a shown block counts.',
3287
3370
  'The rules shown were pre-selected as the ones relevant to this edit \u2014 every rule here IS relevant, do not label any "not relevant". When passing (ok=true), give a terse, specific reason each rule passes. Format: "R003: no hardcoded secrets in file. R005: in-repo path only." Cover every rule shown.',
3288
3371
  ].join('\\n');
3289
3372
 
3290
3373
  let gradeResp: string;
3291
3374
  try {
3292
- gradeResp = await localGrade('edit', graderPrompt, undefined, agentKind);
3375
+ gradeResp = await localGrade('edit', graderPrompt, undefined, graderPool);
3293
3376
  } catch (err) {
3294
- logGraderUnavailable('editGuard', fileShort, (err as Error).message || String(err));
3295
- outputJson({ systemMessage: tagStr + ' editGuard ' + fileShort + ' \u2192 local grader unavailable, skipped' });
3377
+ const errMsg = (err as Error).message || String(err);
3378
+ logGraderUnavailable('editGuard', fileShort, errMsg);
3379
+ outputJson({ systemMessage: tagStr + ' ' + graderUnavailableMessage('editGuard', fileShort, errMsg, graderPool) });
3296
3380
  return;
3297
3381
  }
3298
3382
 
@@ -3406,7 +3490,8 @@ import {
3406
3490
  localGradeCwe, parseVerdict, reconstructContent, readStdin, log,
3407
3491
  outputJson, outputEmpty, setupCursorHookSignals, isEditTool, isShellTool, isCursorHookFormat,
3408
3492
  extractShellCodeWrites, hookSessionId, filePathFromToolInput, emitBlockScanFindings, dispatchFinding, dispatchCapture, GATEWAY_URL,
3409
- logGraderUnavailable, resolveTranscriptPath, isCursorInvokingCcHook,
3493
+ logGraderUnavailable, graderUnavailableMessage, resolveTranscriptPath, isCursorInvokingCcHook,
3494
+ loadSynkroFile, effectiveGraderPool,
3410
3495
  } from './_synkro-common.ts';
3411
3496
  import { basename, extname, resolve, join, dirname } from 'node:path';
3412
3497
  import { readFileSync, readdirSync, existsSync } from 'node:fs';
@@ -3619,10 +3704,12 @@ async function main() {
3619
3704
  jwt = await ensureFreshJwt(jwt);
3620
3705
 
3621
3706
  const config = await loadConfig(jwt);
3622
- const rt = await cweRoute(config);
3707
+ const synkroFile = loadSynkroFile(cwd);
3708
+ const graderPool = effectiveGraderPool(synkroFile, agentKind);
3709
+ const rt = await cweRoute(config, synkroFile);
3623
3710
 
3624
3711
  if (config.silent) {
3625
- outputJson({ systemMessage: '[synkro:' + rt + ':cweScan] skipped (silent mode)' });
3712
+ outputJson({ systemMessage: '[synkro:' + rt + ':cweScan:' + (graderPool === 'claude_code' ? 'claude' : graderPool) + '] skipped (silent mode)' });
3626
3713
  return;
3627
3714
  }
3628
3715
 
@@ -3643,10 +3730,11 @@ async function main() {
3643
3730
  }
3644
3731
  }
3645
3732
 
3646
- const cweTag = '[synkro:' + rt + ':cweScan]';
3733
+ const graderLabel = graderPool === 'claude_code' ? 'claude' : graderPool;
3734
+ const cweTag = '[synkro:' + rt + ':cweScan:' + graderLabel + ']';
3647
3735
 
3648
3736
  if (rt === 'skip') {
3649
- outputJson({ systemMessage: cweTag + ' ' + fileShort + ' \u2192 local CWE grader unavailable, skipped' });
3737
+ outputJson({ systemMessage: cweTag + ' ' + graderUnavailableMessage('cweGuard', fileShort, 'channel down', graderPool) });
3650
3738
  return;
3651
3739
  }
3652
3740
 
@@ -3828,23 +3916,23 @@ async function main() {
3828
3916
  const chunk2 = cweContent.slice(mid - OVERLAP);
3829
3917
  try {
3830
3918
  const [resp1, resp2] = await Promise.all([
3831
- localGradeCwe(buildCwePrompt(chunk1), agentKind),
3832
- localGradeCwe(buildCwePrompt(chunk2), agentKind),
3919
+ localGradeCwe(buildCwePrompt(chunk1), graderPool),
3920
+ localGradeCwe(buildCwePrompt(chunk2), graderPool),
3833
3921
  ]);
3834
3922
  gradeResponses = [resp1, resp2];
3835
3923
  } catch (gradeErr: any) {
3836
3924
  const reason = gradeErr?.message || String(gradeErr);
3837
3925
  logGraderUnavailable('cweGuard', fileShort, reason);
3838
- outputJson({ systemMessage: cweTag + ' ' + fileShort + ' \u2192 grader unavailable (' + reason + '), skipped' });
3926
+ outputJson({ systemMessage: cweTag + ' ' + graderUnavailableMessage('cweGuard', fileShort, reason, graderPool) });
3839
3927
  return;
3840
3928
  }
3841
3929
  } else {
3842
3930
  try {
3843
- gradeResponses = [await localGradeCwe(buildCwePrompt(cweContent), agentKind)];
3931
+ gradeResponses = [await localGradeCwe(buildCwePrompt(cweContent), graderPool)];
3844
3932
  } catch (gradeErr: any) {
3845
3933
  const reason = gradeErr?.message || String(gradeErr);
3846
3934
  logGraderUnavailable('cweGuard', fileShort, reason);
3847
- outputJson({ systemMessage: cweTag + ' ' + fileShort + ' \u2192 grader unavailable (' + reason + '), skipped' });
3935
+ outputJson({ systemMessage: cweTag + ' ' + graderUnavailableMessage('cweGuard', fileShort, reason, graderPool) });
3848
3936
  return;
3849
3937
  }
3850
3938
  }
@@ -4244,7 +4332,7 @@ import {
4244
4332
  loadJwt, ensureFreshJwt, detectRepo, loadConfig, route, tag,
4245
4333
  readStdin, runInstallScan, emitBlockScanFindings, dispatchCapture, dispatchScanResult, hashCommand,
4246
4334
  outputJson, outputEmpty, setupCursorHookSignals, isShellTool, hookSessionId, log, GATEWAY_URL,
4247
- resolveTranscriptPath, isCursorHookFormat,
4335
+ resolveTranscriptPath, isCursorHookFormat, loadSynkroFile, effectiveGraderPool,
4248
4336
  } from './_synkro-common.ts';
4249
4337
  import { writeFileSync, mkdirSync } from 'node:fs';
4250
4338
  import { join } from 'node:path';
@@ -4290,11 +4378,13 @@ async function main() {
4290
4378
  const transcriptPath = resolveTranscriptPath(payload);
4291
4379
  const repo = detectRepo(cwd, transcriptPath, command, workspaceRoots);
4292
4380
  const config = await loadConfig(jwt);
4293
- const rt = await route(config);
4294
- const tagStr = tag(rt, config);
4381
+ const isCursor = isCursorHookFormat();
4382
+ const synkroFile = loadSynkroFile(cwd);
4383
+ const graderPool = effectiveGraderPool(synkroFile, isCursor ? 'cursor' : 'claude_code');
4384
+ const rt = await route(config, synkroFile);
4385
+ const tagStr = tag(rt, config, graderPool);
4295
4386
 
4296
4387
  const rawModel = String(payload.model ?? payload.model_id ?? '');
4297
- const isCursor = isCursorHookFormat();
4298
4388
  const model = isCursor ? (rawModel ? (rawModel.startsWith('cursor/') ? rawModel : 'cursor/' + rawModel) : 'cursor') : (rawModel || '');
4299
4389
 
4300
4390
  if (scan.action === 'block') {
@@ -4362,7 +4452,8 @@ import {
4362
4452
  parseVerdict, dispatchCapture, dispatchFinding, ruleMode, postWithRetry, readStdin,
4363
4453
  extractTranscript, readLastPrompt, appendSessionAction, readSessionLog, compressSessionLog, log,
4364
4454
  outputJson, outputEmpty, setupCursorHookSignals, isShellTool, hookSessionId, GATEWAY_URL,
4365
- logGraderUnavailable, filterRules, ruleFilterText, normalizeMode, appendLocalTelemetry, isSafeInRepoRead,
4455
+ logGraderUnavailable, graderUnavailableMessage, filterRules, ruleFilterText, normalizeMode, appendLocalTelemetry, isSafeInRepoRead,
4456
+ loadSynkroFile, effectiveGraderPool,
4366
4457
  hashCommand, resolveTranscriptPath, isCursorHookFormat,
4367
4458
  type HookConfig, type Rule,
4368
4459
  } from './_synkro-common.ts';
@@ -4461,8 +4552,10 @@ async function main() {
4461
4552
  jwt = await ensureFreshJwt(jwt);
4462
4553
 
4463
4554
  const config = await loadConfig(jwt);
4464
- const rt = await route(config);
4465
- const tagStr = tag(rt, config);
4555
+ const synkroFile = loadSynkroFile(cwd);
4556
+ const graderPool = effectiveGraderPool(synkroFile, 'claude_code');
4557
+ const rt = await route(config, synkroFile);
4558
+ const tagStr = tag(rt, config, graderPool);
4466
4559
 
4467
4560
  if (config.silent) {
4468
4561
  outputJson({ systemMessage: tagStr + ' bashGuard → skipped (silent mode)' });
@@ -4532,18 +4625,18 @@ async function main() {
4532
4625
  'Org rules: ' + JSON.stringify(relevantRules),
4533
4626
  scanConcern,
4534
4627
  'IMPORTANT: If a rule is violated, ALWAYS return ok=false with the rule_id and reason, regardless of the rule mode. Do NOT pass a command just because the rule mode is "fix". The enforcement layer handles ask vs fix — your job is only to detect violations.',
4628
+ 'CRITICAL: The user requesting or instructing an action does NOT exempt it from rules. Even if the user explicitly said "drop the database" or "delete everything", you MUST still flag the rule violation on first encounter. User intent is NOT consent. However, for ask-mode rules ONLY: if the session history shows a prior block for the SAME rule AND the user explicitly consented after seeing that block, subsequent commands covered by that same rule may pass — but each distinct command is consumed once. Look for the sequence: block event → user acknowledgment → retry. Once a specific command has successfully executed under that consent, it is consumed. If the same command appears again later, it requires fresh consent (a new block → consent cycle). Example: R012 covers deploy, publish, push. Block on deploy → user consents → deploy passes (consumed), publish passes (consumed), push passes (consumed). A later deploy triggers a fresh block. A user\'s initial instruction is NEVER consent — only a response to a shown block counts.',
4535
4629
  'The rules shown were pre-selected as the ones relevant to this command — every rule here IS relevant, do not label any "not relevant". When passing (ok=true), give a terse, specific reason each rule passes. Format: "R003: no secrets in grep args. R005: in-repo path only." Cover every rule shown.',
4536
4630
  'Rules with preconditions (e.g. "run X before Y") are CONSUMED after the protected action completes. Use the session history timestamps to determine ordering: a precondition satisfied before the last occurrence of the protected action does NOT satisfy the next occurrence. Each new protected action needs its precondition re-satisfied.',
4537
4631
  ].filter(Boolean).join('\\n');
4538
4632
 
4539
4633
  let gradeResp: string;
4540
4634
  try {
4541
- gradeResp = await localGrade('bash', graderPrompt);
4635
+ gradeResp = await localGrade('bash', graderPrompt, undefined, graderPool);
4542
4636
  } catch (err) {
4543
- logGraderUnavailable('bashGuard', toolInput.command?.slice(0, 200) || '', (err as Error).message || String(err));
4637
+ const errMsg = (err as Error).message || String(err);
4638
+ logGraderUnavailable('bashGuard', toolInput.command?.slice(0, 200) || '', errMsg);
4544
4639
  if (scanConcern) {
4545
- // Grader unavailable to run the consent check — fail closed on a
4546
- // scanner-flagged install (ask-mode so the user can still consent).
4547
4640
  const ctx = scanBlockContext + ' Synkro flagged this install but the grader is unavailable to check consent — ask the user for explicit consent, then retry.';
4548
4641
  outputJson({
4549
4642
  systemMessage: tagStr + ' bashGuard → install blocked (scan flagged; grader unavailable)',
@@ -4551,7 +4644,7 @@ async function main() {
4551
4644
  });
4552
4645
  return;
4553
4646
  }
4554
- outputJson({ systemMessage: tagStr + ' bashGuard local grader unavailable, skipped' });
4647
+ outputJson({ systemMessage: tagStr + ' ' + graderUnavailableMessage('bashGuard', cmdShort, errMsg) });
4555
4648
  return;
4556
4649
  }
4557
4650
 
@@ -4656,7 +4749,8 @@ import {
4656
4749
  parseVerdict, dispatchCapture, ruleMode, postWithRetry, readStdin,
4657
4750
  extractTranscript, readLastPrompt, appendSessionAction, readSessionLog, compressSessionLog, log,
4658
4751
  outputJson, outputEmpty, setupCursorHookSignals, isAgentTool, hookSessionId, GATEWAY_URL,
4659
- logGraderUnavailable, filterRules, normalizeMode, resolveTranscriptPath, isCursorInvokingCcHook,
4752
+ logGraderUnavailable, graderUnavailableMessage, filterRules, normalizeMode, resolveTranscriptPath, isCursorInvokingCcHook,
4753
+ loadSynkroFile, effectiveGraderPool,
4660
4754
  type HookConfig, type Rule,
4661
4755
  } from './_synkro-common.ts';
4662
4756
 
@@ -4715,8 +4809,10 @@ async function main() {
4715
4809
  }
4716
4810
 
4717
4811
  const config = await loadConfig(jwt);
4718
- const rt = await route(config);
4719
- const tagStr = tag(rt, config);
4812
+ const synkroFile = loadSynkroFile(cwd);
4813
+ const graderPool = effectiveGraderPool(synkroFile, agentKind);
4814
+ const rt = await route(config, synkroFile);
4815
+ const tagStr = tag(rt, config, graderPool);
4720
4816
 
4721
4817
  if (config.silent) {
4722
4818
  const msg = tagStr + ' agentGuard \u2192 skipped (silent mode)';
@@ -4741,14 +4837,16 @@ async function main() {
4741
4837
  'Last user prompt: ' + (lastPrompt || 'none'),
4742
4838
  'Org rules: ' + JSON.stringify(relevantRules),
4743
4839
  'IMPORTANT: If a rule is violated, ALWAYS return ok=false with the rule_id and reason, regardless of the rule mode. Do NOT pass a command just because the rule mode is "fix". The enforcement layer handles ask vs fix \u2014 your job is only to detect violations.',
4840
+ 'CRITICAL: The user requesting or instructing an action does NOT exempt it from rules. Even if the user explicitly said "drop the database" or "delete everything", you MUST still flag the rule violation on first encounter. User intent is NOT consent. However, for ask-mode rules ONLY: if the session history shows a prior block for the SAME rule AND the user explicitly consented after seeing that block, subsequent commands covered by that same rule may pass \u2014 but each distinct command is consumed once. Look for the sequence: block event \u2192 user acknowledgment \u2192 retry. Once a specific command has successfully executed under that consent, it is consumed. If the same command appears again later, it requires fresh consent (a new block \u2192 consent cycle). Example: R012 covers deploy, publish, push. Block on deploy \u2192 user consents \u2192 deploy passes (consumed), publish passes (consumed), push passes (consumed). A later deploy triggers a fresh block. A user's initial instruction is NEVER consent \u2014 only a response to a shown block counts.',
4744
4841
  ].filter(Boolean).join('\\n');
4745
4842
 
4746
4843
  let gradeResp: string;
4747
4844
  try {
4748
- gradeResp = await localGrade('bash', graderPrompt, undefined, agentKind);
4845
+ gradeResp = await localGrade('bash', graderPrompt, undefined, graderPool);
4749
4846
  } catch (err) {
4750
- logGraderUnavailable('agentGuard', subagentType || (description || '').slice(0, 100), (err as Error).message || String(err));
4751
- outputJson({ systemMessage: tagStr + ' agentGuard \u2192 local grader unavailable, skipped' });
4847
+ const errMsg = (err as Error).message || String(err);
4848
+ logGraderUnavailable('agentGuard', subagentType || (description || '').slice(0, 100), errMsg);
4849
+ outputJson({ systemMessage: tagStr + ' ' + graderUnavailableMessage('agentGuard', subagentType || 'agent', errMsg, graderPool) });
4752
4850
  return;
4753
4851
  }
4754
4852
 
@@ -4838,7 +4936,8 @@ import {
4838
4936
  loadJwt, ensureFreshJwt, detectRepo, loadConfig, route, tag, localGrade,
4839
4937
  parseVerdict, dispatchCapture, appendSessionAction, readSessionLog, compressSessionLog, postWithRetry, readStdin, log,
4840
4938
  outputJson, outputEmpty, setupCursorHookSignals, isPlanTool, hookSessionId, GATEWAY_URL,
4841
- filterRules, resolveTranscriptPath, isCursorInvokingCcHook,
4939
+ filterRules, graderUnavailableMessage, resolveTranscriptPath, isCursorInvokingCcHook,
4940
+ loadSynkroFile, effectiveGraderPool,
4842
4941
  } from './_synkro-common.ts';
4843
4942
  import { existsSync, readFileSync, writeFileSync, readdirSync, statSync } from 'node:fs';
4844
4943
  import { join } from 'node:path';
@@ -4923,8 +5022,10 @@ async function main() {
4923
5022
  const ccModel = agentKind === 'cursor' ? (rawModel ? (rawModel.startsWith('cursor/') ? rawModel : 'cursor/' + rawModel) : 'cursor') : (rawModel || '');
4924
5023
 
4925
5024
  const config = await loadConfig(jwt);
4926
- const rt = await route(config);
4927
- const tagStr = tag(rt, config);
5025
+ const synkroFile = loadSynkroFile(cwd);
5026
+ const graderPool = effectiveGraderPool(synkroFile, agentKind);
5027
+ const rt = await route(config, synkroFile);
5028
+ const tagStr = tag(rt, config, graderPool);
4928
5029
 
4929
5030
  if (config.silent) {
4930
5031
  outputJson({ systemMessage: tagStr + ' planReview \u2192 skipped (silent mode)' });
@@ -4945,9 +5046,9 @@ async function main() {
4945
5046
 
4946
5047
  let gradeResp: string;
4947
5048
  try {
4948
- gradeResp = await localGrade('plan', graderPrompt, undefined, agentKind);
4949
- } catch {
4950
- outputJson({ systemMessage: tagStr + ' planReview \u2192 local grader unavailable, skipped' });
5049
+ gradeResp = await localGrade('plan', graderPrompt, undefined, graderPool);
5050
+ } catch (err) {
5051
+ outputJson({ systemMessage: tagStr + ' ' + graderUnavailableMessage('planReview', 'plan', (err as Error).message || String(err), graderPool) });
4951
5052
  return;
4952
5053
  }
4953
5054
 
@@ -5090,7 +5191,7 @@ main();
5090
5191
  import {
5091
5192
  loadJwt, detectRepo, channelUp, tag, readStdin, writeCachedRepo,
5092
5193
  outputJson, outputEmpty, setupCursorHookSignals, hookSessionId, resolveTranscriptPath, GATEWAY_URL,
5093
- isLocalStorageMode, log, type HookConfig,
5194
+ isLocalStorageMode, loadSynkroFile, log, type HookConfig,
5094
5195
  } from './_synkro-common.ts';
5095
5196
 
5096
5197
  async function main() {
@@ -5110,7 +5211,9 @@ async function main() {
5110
5211
  let jwt = loadJwt();
5111
5212
 
5112
5213
  const isChannelUp = await channelUp();
5113
- const rt = isChannelUp ? 'local' : 'cloud';
5214
+ const synkroFile = loadSynkroFile(cwd);
5215
+ const gradingMode = synkroFile.grader.mode || process.env.SYNKRO_GRADING_MODE || 'local';
5216
+ const rt = gradingMode === 'byok' ? 'cloud' : (isChannelUp ? 'local' : 'cloud');
5114
5217
 
5115
5218
  let policyName = '';
5116
5219
  let silent = false;
@@ -5142,15 +5245,24 @@ async function main() {
5142
5245
  } catch {}
5143
5246
  }
5144
5247
 
5145
- const fakeConfig: HookConfig = { captureDepth: 'local_only', tier: 'standard', silent, policyName, rules: [] };
5248
+ const fakeConfig: HookConfig = { captureDepth: 'local_only', tier: 'standard', silent, policyName, rules: [], scanExemptions: [], gradingMode, storageMode: process.env.SYNKRO_STORAGE_MODE || 'local' };
5146
5249
  const tagStr = tag(rt, fakeConfig);
5147
- const routeLine = tagStr + ' inference: ' + (isChannelUp ? 'local-cc (channel reachable on 127.0.0.1:18929)' : 'cloud (local-cc channel not reachable)');
5250
+ const routeLine = tagStr + ' inference: ' + (gradingMode === 'byok' ? 'cloud (BYOK)' : isChannelUp ? 'local-cc (channel reachable on 127.0.0.1:18929)' : 'cloud (local-cc channel not reachable)');
5148
5251
 
5149
5252
  if (!jwt) {
5150
5253
  outputJson({ systemMessage: routeLine });
5151
5254
  return;
5152
5255
  }
5153
5256
 
5257
+ // Sync grading mode to API profile so the dashboard reflects the actual config
5258
+ const fastInference = gradingMode === 'byok';
5259
+ fetch(GATEWAY_URL + '/api/v1/cli/me', {
5260
+ method: 'PATCH',
5261
+ headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + jwt },
5262
+ body: JSON.stringify({ fast_inference: fastInference }),
5263
+ signal: AbortSignal.timeout(3000),
5264
+ }).catch(() => {});
5265
+
5154
5266
  if (!openFindings) {
5155
5267
  outputJson({ systemMessage: routeLine });
5156
5268
  } else if (openFindings === 1) {
@@ -5342,7 +5454,8 @@ import {
5342
5454
  parseVerdict, dispatchCapture, dispatchFinding, ruleMode, normalizeMode, filterRules, ruleFilterText,
5343
5455
  isSafeInRepoRead, resolveTranscriptPath, postWithRetry, readStdin, hashCommand,
5344
5456
  extractTranscript, readLastPrompt, readSessionLog, compressSessionLog,
5345
- appendLocalTelemetry, logGraderUnavailable, log, GATEWAY_URL,
5457
+ appendLocalTelemetry, logGraderUnavailable, graderUnavailableMessage, log, GATEWAY_URL,
5458
+ loadSynkroFile, effectiveGraderPool,
5346
5459
  type Rule,
5347
5460
  } from './_synkro-common.ts';
5348
5461
  import { createHash } from 'node:crypto';
@@ -5485,8 +5598,10 @@ async function main() {
5485
5598
  const config = await loadConfig(jwt);
5486
5599
  if (config.silent) finishAllow();
5487
5600
 
5488
- const rt = await route(config);
5489
- const tagStr = tag(rt, config);
5601
+ const synkroFile = loadSynkroFile(cwd);
5602
+ const graderPool = effectiveGraderPool(synkroFile, 'cursor');
5603
+ const rt = await route(config, synkroFile);
5604
+ const tagStr = tag(rt, config, graderPool);
5490
5605
 
5491
5606
  // Install protection \u2014 install-scan runs first and owns block traces.
5492
5607
  let scanConcern = '';
@@ -5521,13 +5636,14 @@ async function main() {
5521
5636
  'Org rules: ' + JSON.stringify(relevantRules),
5522
5637
  scanConcern,
5523
5638
  'IMPORTANT: If a rule is violated, ALWAYS return ok=false with the rule_id and reason, regardless of the rule mode. Do NOT pass a command just because the rule mode is "fix". The enforcement layer handles ask vs fix \u2014 your job is only to detect violations.',
5639
+ 'CRITICAL: The user requesting or instructing an action does NOT exempt it from rules. Even if the user explicitly said "drop the database" or "delete everything", you MUST still flag the rule violation on first encounter. User intent is NOT consent. However, for ask-mode rules ONLY: if the session history shows a prior block for the SAME rule AND the user explicitly consented after seeing that block, subsequent commands covered by that same rule may pass \u2014 but each distinct command is consumed once. Look for the sequence: block event \u2192 user acknowledgment \u2192 retry. Once a specific command has successfully executed under that consent, it is consumed. If the same command appears again later, it requires fresh consent (a new block \u2192 consent cycle). Example: R012 covers deploy, publish, push. Block on deploy \u2192 user consents \u2192 deploy passes (consumed), publish passes (consumed), push passes (consumed). A later deploy triggers a fresh block. A user's initial instruction is NEVER consent \u2014 only a response to a shown block counts.',
5524
5640
  'The rules shown were pre-selected as the ones relevant to this command \u2014 every rule here IS relevant, do not label any "not relevant". When passing (ok=true), give a terse, specific reason each rule passes. Format: "R003: no secrets in grep args. R005: in-repo path only." Cover every rule shown.',
5525
5641
  'Rules with preconditions (e.g. "run X before Y") are CONSUMED after the protected action completes. Use the session history timestamps to determine ordering: a precondition satisfied before the last occurrence of the protected action does NOT satisfy the next occurrence. Each new protected action needs its precondition re-satisfied.',
5526
5642
  ].filter(Boolean).join('\\n');
5527
5643
 
5528
5644
  let gradeResp: string;
5529
5645
  try {
5530
- gradeResp = await localGrade('bash', graderPrompt, CURSOR_GRADE_TIMEOUT_MS, 'cursor');
5646
+ gradeResp = await localGrade('bash', graderPrompt, CURSOR_GRADE_TIMEOUT_MS, graderPool);
5531
5647
  } catch (e) {
5532
5648
  logGraderUnavailable('bashGuard', command.slice(0, 200), (e as Error).message || String(e));
5533
5649
  if (scanConcern) {
@@ -5539,8 +5655,9 @@ async function main() {
5539
5655
  agent_message: 'Synkro flagged this install: ' + scanBlockContext + ' The grader is unavailable to check consent \u2014 ask the user for explicit consent, then retry.',
5540
5656
  });
5541
5657
  }
5542
- log('bashGuard ' + cmdShort + ' \u2192 pass (grade unavailable): ' + String(e));
5543
- finishWith({ permission: 'allow' });
5658
+ const errMsg = (e as Error).message || String(e);
5659
+ log('bashGuard ' + cmdShort + ' \u2192 pass (grade unavailable): ' + errMsg);
5660
+ finishWith({ permission: 'allow', user_message: tagStr + ' ' + graderUnavailableMessage('bashGuard', cmdShort, errMsg, graderPool) });
5544
5661
  }
5545
5662
 
5546
5663
  const verdict = parseVerdict(gradeResp);
@@ -6908,10 +7025,10 @@ __export(dockerInstall_exports, {
6908
7025
  splitWorkers: () => splitWorkers,
6909
7026
  waitForContainerReady: () => waitForContainerReady
6910
7027
  });
6911
- import { copyFileSync, existsSync as existsSync7, mkdirSync as mkdirSync7, readdirSync as readdirSync2 } from "fs";
7028
+ import { copyFileSync, existsSync as existsSync7, mkdirSync as mkdirSync7, readFileSync as readFileSync6, readdirSync as readdirSync2 } from "fs";
6912
7029
  import { homedir as homedir6 } from "os";
6913
7030
  import { join as join6 } from "path";
6914
- import { spawnSync as spawnSync2 } from "child_process";
7031
+ import { execSync as execSync4, spawnSync as spawnSync2 } from "child_process";
6915
7032
  function splitWorkers(total, providers) {
6916
7033
  const t = Math.max(0, Math.floor(total));
6917
7034
  const hasClaude = providers.includes("claude_code");
@@ -6929,6 +7046,19 @@ function normalizeProvider(p) {
6929
7046
  if (v === "cursor") return "cursor";
6930
7047
  return null;
6931
7048
  }
7049
+ function readSynkroFilePool() {
7050
+ try {
7051
+ const root = execSync4("git rev-parse --show-toplevel 2>/dev/null", { encoding: "utf-8", timeout: 3e3, stdio: ["pipe", "pipe", "pipe"] }).trim();
7052
+ if (!root) return "auto";
7053
+ const fp = join6(root, ".synkro");
7054
+ if (!existsSync7(fp)) return "auto";
7055
+ const parsed = JSON.parse(readFileSync6(fp, "utf-8"));
7056
+ const pool = parsed?.grader?.pool;
7057
+ if (pool === "cursor" || pool === "claude") return pool;
7058
+ } catch {
7059
+ }
7060
+ return "auto";
7061
+ }
6932
7062
  function resolveWorkerConfig(rest) {
6933
7063
  let workers = 8;
6934
7064
  let explicit = false;
@@ -6962,8 +7092,15 @@ function resolveWorkerConfig(rest) {
6962
7092
  workers = Math.min(workers, 64);
6963
7093
  let provs = providers;
6964
7094
  if (provs.length === 0) {
6965
- provs = detectAgents().map((a) => a.kind);
6966
- if (provs.length === 0) provs = ["claude_code"];
7095
+ const synkroPool = readSynkroFilePool();
7096
+ if (synkroPool === "cursor") {
7097
+ provs = ["cursor"];
7098
+ } else if (synkroPool === "claude") {
7099
+ provs = ["claude_code"];
7100
+ } else {
7101
+ provs = detectAgents().map((a) => a.kind);
7102
+ if (provs.length === 0) provs = ["claude_code"];
7103
+ }
6967
7104
  }
6968
7105
  return { ...splitWorkers(workers, provs), explicit };
6969
7106
  }
@@ -7368,15 +7505,15 @@ __export(setupGithub_exports, {
7368
7505
  });
7369
7506
  import { createInterface as createInterface2 } from "readline/promises";
7370
7507
  import { stdin as input, stdout as output } from "process";
7371
- import { execSync as execSync4, spawn as nodeSpawn } from "child_process";
7372
- import { existsSync as existsSync8, readFileSync as readFileSync6, unlinkSync as unlinkSync3 } from "fs";
7508
+ import { execSync as execSync5, spawn as nodeSpawn } from "child_process";
7509
+ import { existsSync as existsSync8, readFileSync as readFileSync7, unlinkSync as unlinkSync3 } from "fs";
7373
7510
  import { homedir as homedir7, platform as platform3 } from "os";
7374
7511
  import { join as join7 } from "path";
7375
7512
  import { execFile as execFile2 } from "child_process";
7376
7513
  function readConfig() {
7377
7514
  if (!existsSync8(CONFIG_PATH)) return {};
7378
7515
  const out = {};
7379
- for (const line of readFileSync6(CONFIG_PATH, "utf-8").split("\n")) {
7516
+ for (const line of readFileSync7(CONFIG_PATH, "utf-8").split("\n")) {
7380
7517
  const t = line.trim();
7381
7518
  if (!t || t.startsWith("#")) continue;
7382
7519
  const eq = t.indexOf("=");
@@ -7446,7 +7583,7 @@ function captureClaudeSetupToken() {
7446
7583
  proc.on("close", (code) => {
7447
7584
  let raw = "";
7448
7585
  try {
7449
- raw = readFileSync6(tmpFile, "utf-8");
7586
+ raw = readFileSync7(tmpFile, "utf-8");
7450
7587
  } catch (e) {
7451
7588
  reject(new Error(`Could not read script output file: ${e.message}`));
7452
7589
  return;
@@ -7578,7 +7715,7 @@ async function setupGithubCommand(opts = {}) {
7578
7715
  }
7579
7716
  } catch {
7580
7717
  try {
7581
- ghToken = execSync4("gh auth token", { encoding: "utf-8", timeout: 5e3 }).trim();
7718
+ ghToken = execSync5("gh auth token", { encoding: "utf-8", timeout: 5e3 }).trim();
7582
7719
  } catch {
7583
7720
  console.error("GitHub not connected. Run `synkro-cli setup-github` interactively to connect.");
7584
7721
  return;
@@ -7612,7 +7749,7 @@ async function setupGithubCommand(opts = {}) {
7612
7749
  }
7613
7750
  console.log(" Validating token...");
7614
7751
  try {
7615
- const validateResult = execSync4(
7752
+ const validateResult = execSync5(
7616
7753
  'claude --print --output-format json "say ok"',
7617
7754
  { env: { ...process.env, CLAUDE_CODE_OAUTH_TOKEN: claudeToken }, encoding: "utf-8", timeout: 3e4, stdio: ["ignore", "pipe", "pipe"] }
7618
7755
  );
@@ -7629,7 +7766,7 @@ async function setupGithubCommand(opts = {}) {
7629
7766
  if (opts.nonInteractive) {
7630
7767
  let currentFullName = null;
7631
7768
  try {
7632
- const remoteUrl = execSync4("git remote get-url origin", { encoding: "utf-8", timeout: 5e3 }).trim();
7769
+ const remoteUrl = execSync5("git remote get-url origin", { encoding: "utf-8", timeout: 5e3 }).trim();
7633
7770
  const m = remoteUrl.match(/(?:github\.com)[:/](.+?)(?:\.git)?$/);
7634
7771
  if (m) currentFullName = m[1];
7635
7772
  } catch {
@@ -7730,10 +7867,10 @@ __export(install_exports, {
7730
7867
  installCommand: () => installCommand,
7731
7868
  parseArgs: () => parseArgs
7732
7869
  });
7733
- import { existsSync as existsSync9, mkdirSync as mkdirSync8, writeFileSync as writeFileSync7, chmodSync as chmodSync2, readFileSync as readFileSync7, readdirSync as readdirSync3 } from "fs";
7870
+ import { existsSync as existsSync9, mkdirSync as mkdirSync8, writeFileSync as writeFileSync7, chmodSync as chmodSync2, readFileSync as readFileSync8, readdirSync as readdirSync3 } from "fs";
7734
7871
  import { homedir as homedir8 } from "os";
7735
7872
  import { join as join8 } from "path";
7736
- import { execSync as execSync5, spawnSync as spawnSync3 } from "child_process";
7873
+ import { execSync as execSync6, spawnSync as spawnSync3 } from "child_process";
7737
7874
  import { createInterface as createInterface3 } from "readline";
7738
7875
  function sanitizeGatewayCandidate(raw) {
7739
7876
  if (!raw) return void 0;
@@ -7976,7 +8113,7 @@ function writeConfigEnv(opts) {
7976
8113
  `SYNKRO_CREDENTIALS_PATH=${shellQuoteSingle(credsPath)}`,
7977
8114
  `SYNKRO_TIER=${shellQuoteSingle(safeTier)}`,
7978
8115
  `SYNKRO_INFERENCE=${shellQuoteSingle(safeInference)}`,
7979
- `SYNKRO_VERSION=${shellQuoteSingle("1.6.31")}`
8116
+ `SYNKRO_VERSION=${shellQuoteSingle("1.6.32")}`
7980
8117
  ];
7981
8118
  if (safeSynkroBin) lines.push(`SYNKRO_CLI_BIN=${shellQuoteSingle(safeSynkroBin)}`);
7982
8119
  if (safeUserId) lines.push(`SYNKRO_USER_ID=${shellQuoteSingle(safeUserId)}`);
@@ -7999,7 +8136,7 @@ function resolveDeploymentMode() {
7999
8136
  if (envOverride === "bare-host" || envOverride === "docker") return envOverride;
8000
8137
  try {
8001
8138
  if (existsSync9(CONFIG_PATH2)) {
8002
- const m = readFileSync7(CONFIG_PATH2, "utf-8").match(/^SYNKRO_DEPLOYMENT_MODE='([^']*)'/m);
8139
+ const m = readFileSync8(CONFIG_PATH2, "utf-8").match(/^SYNKRO_DEPLOYMENT_MODE='([^']*)'/m);
8003
8140
  const val = m?.[1]?.toLowerCase();
8004
8141
  if (val === "bare-host" || val === "docker") return val;
8005
8142
  }
@@ -8007,40 +8144,41 @@ function resolveDeploymentMode() {
8007
8144
  }
8008
8145
  return "docker";
8009
8146
  }
8010
- function collectLocalMetadata() {
8147
+ function collectLocalMetadata(includeClaudeCode = true) {
8011
8148
  const meta = { platform: process.platform };
8012
8149
  try {
8013
- meta.display_name = execSync5("git config user.name", { encoding: "utf-8", timeout: 3e3 }).trim();
8150
+ meta.display_name = execSync6("git config user.name", { encoding: "utf-8", timeout: 3e3 }).trim();
8014
8151
  } catch {
8015
8152
  }
8016
8153
  try {
8017
- const remote = execSync5("git remote get-url origin", { encoding: "utf-8", timeout: 3e3, stdio: ["pipe", "pipe", "pipe"] }).trim();
8154
+ const remote = execSync6("git remote get-url origin", { encoding: "utf-8", timeout: 3e3, stdio: ["pipe", "pipe", "pipe"] }).trim();
8018
8155
  const sshMatch = remote.match(/^git@[^:]+:(.+?)(?:\.git)?$/);
8019
8156
  const httpMatch = remote.match(/^https?:\/\/[^/]+\/(.+?)(?:\.git)?$/);
8020
8157
  const m = sshMatch || httpMatch;
8021
8158
  if (m) meta.active_repo = m[1];
8022
8159
  } catch {
8023
8160
  }
8161
+ if (!includeClaudeCode) return meta;
8024
8162
  try {
8025
- meta.cc_version = execSync5("claude --version", { encoding: "utf-8", timeout: 5e3 }).trim().split("\n")[0];
8163
+ meta.cc_version = execSync6("claude --version", { encoding: "utf-8", timeout: 5e3 }).trim().split("\n")[0];
8026
8164
  } catch {
8027
8165
  }
8028
8166
  const claudeDir = join8(homedir8(), ".claude");
8029
8167
  try {
8030
- const settings = JSON.parse(readFileSync7(join8(claudeDir, "settings.json"), "utf-8"));
8168
+ const settings = JSON.parse(readFileSync8(join8(claudeDir, "settings.json"), "utf-8"));
8031
8169
  const plugins = Object.keys(settings.enabledPlugins ?? {}).filter((k) => settings.enabledPlugins[k]);
8032
8170
  if (plugins.length) meta.enabled_plugins = plugins;
8033
8171
  if (settings.permissions?.defaultMode) meta.permissions_mode = settings.permissions.defaultMode;
8034
8172
  } catch {
8035
8173
  }
8036
8174
  try {
8037
- const mcpCache = JSON.parse(readFileSync7(join8(claudeDir, "mcp-needs-auth-cache.json"), "utf-8"));
8175
+ const mcpCache = JSON.parse(readFileSync8(join8(claudeDir, "mcp-needs-auth-cache.json"), "utf-8"));
8038
8176
  const mcpNames = Object.keys(mcpCache);
8039
8177
  if (mcpNames.length) meta.mcp_servers = mcpNames;
8040
8178
  } catch {
8041
8179
  }
8042
8180
  try {
8043
- const mcpList = execSync5("claude mcp list 2>/dev/null", { encoding: "utf-8", timeout: 1e4 });
8181
+ const mcpList = execSync6("claude mcp list 2>/dev/null", { encoding: "utf-8", timeout: 1e4 });
8044
8182
  const connected = mcpList.split("\n").filter((l) => l.includes("Connected")).map((l) => l.split(":")[0].trim()).filter(Boolean);
8045
8183
  if (connected.length) meta.mcp_servers_connected = connected;
8046
8184
  } catch {
@@ -8049,7 +8187,7 @@ function collectLocalMetadata() {
8049
8187
  const sessionsDir = join8(claudeDir, "sessions");
8050
8188
  const files = readdirSync3(sessionsDir).filter((f) => f.endsWith(".json")).slice(-5);
8051
8189
  for (const f of files) {
8052
- const s = JSON.parse(readFileSync7(join8(sessionsDir, f), "utf-8"));
8190
+ const s = JSON.parse(readFileSync8(join8(sessionsDir, f), "utf-8"));
8053
8191
  if (s.version) {
8054
8192
  meta.cc_version = meta.cc_version || s.version;
8055
8193
  break;
@@ -8059,14 +8197,14 @@ function collectLocalMetadata() {
8059
8197
  }
8060
8198
  return meta;
8061
8199
  }
8062
- async function fetchUserProfile(gatewayUrl, token) {
8200
+ async function fetchUserProfile(gatewayUrl, token, hasClaudeCode = true) {
8063
8201
  try {
8064
8202
  const resp = await fetch(`${gatewayUrl}/api/v1/cli/me`, {
8065
8203
  headers: { "Authorization": `Bearer ${token}` }
8066
8204
  });
8067
8205
  if (!resp.ok) return { tier: "pro", inference: "fast", localInference: false, captureDepth: "full" };
8068
8206
  const data = await resp.json();
8069
- const meta = collectLocalMetadata();
8207
+ const meta = collectLocalMetadata(hasClaudeCode);
8070
8208
  fetch(`${gatewayUrl}/api/v1/cli/me`, {
8071
8209
  method: "PATCH",
8072
8210
  headers: { "Authorization": `Bearer ${token}`, "Content-Type": "application/json" },
@@ -8177,22 +8315,11 @@ async function installCommand(opts = {}) {
8177
8315
  }
8178
8316
  ensureSynkroDir();
8179
8317
  const scripts = writeHookScripts();
8180
- console.log("Wrote hook scripts:");
8181
- console.log(` ${scripts.bashScript}`);
8182
- console.log(` ${scripts.bashFollowupScript}`);
8183
- console.log(` ${scripts.editPrecheckScript}`);
8184
- console.log(` ${scripts.cwePrecheckScript}`);
8185
- console.log(` ${scripts.cvePrecheckScript}`);
8186
- console.log(` ${scripts.planJudgeScript}`);
8187
- console.log(` ${scripts.agentJudgeScript}`);
8188
- console.log(` ${scripts.stopSummaryScript}`);
8189
- console.log(` ${scripts.sessionStartScript}`);
8190
- console.log(` ${scripts.transcriptSyncScript}
8191
- `);
8318
+ console.log("Wrote hook scripts to ~/.synkro/hooks/\n");
8192
8319
  for (const mode of ["edit", "bash"]) {
8193
8320
  const pidFile = join8(SYNKRO_DIR4, "daemon", mode, "daemon.pid");
8194
8321
  try {
8195
- const pid = parseInt(readFileSync7(pidFile, "utf-8").trim(), 10);
8322
+ const pid = parseInt(readFileSync8(pidFile, "utf-8").trim(), 10);
8196
8323
  if (pid > 0) {
8197
8324
  process.kill(pid, "SIGTERM");
8198
8325
  console.log(`Stopped stale ${mode} grader daemon (pid ${pid})`);
@@ -8262,7 +8389,7 @@ async function installCommand(opts = {}) {
8262
8389
  email = info.email;
8263
8390
  } catch {
8264
8391
  }
8265
- const profile = await fetchUserProfile(gatewayUrl, token);
8392
+ const profile = await fetchUserProfile(gatewayUrl, token, hasClaudeCode);
8266
8393
  const cloudOnly = gradingMode === "byok" && storageMode === "cloud";
8267
8394
  const useLocalMcp = !cloudOnly;
8268
8395
  if (cloudOnly) {
@@ -8379,6 +8506,7 @@ async function installCommand(opts = {}) {
8379
8506
  } catch (err) {
8380
8507
  console.warn(` \u26A0 Could not cache judge prompts: ${err.message}`);
8381
8508
  }
8509
+ writeSynkroFileIfMissing({ hasClaudeCode, hasCursor, gradingMode });
8382
8510
  console.log();
8383
8511
  if (useLocalMcp) {
8384
8512
  const { assertDockerAvailable: assertDockerAvailable2 } = await Promise.resolve().then(() => (init_dockerInstall(), dockerInstall_exports));
@@ -8394,10 +8522,18 @@ async function installCommand(opts = {}) {
8394
8522
  }
8395
8523
  console.log("Installing Synkro server container...");
8396
8524
  const totalWorkers = parseInt(process.env.SYNKRO_WORKERS_PER_POOL || "8", 10);
8397
- const providers = [];
8398
- if (hasClaudeCode) providers.push("claude_code");
8399
- if (hasCursor) providers.push("cursor");
8525
+ const synkroFilePool = readSynkroFilePool2();
8526
+ let providers = [];
8527
+ if (synkroFilePool === "cursor") {
8528
+ providers = ["cursor"];
8529
+ } else if (synkroFilePool === "claude") {
8530
+ providers = ["claude_code"];
8531
+ } else {
8532
+ if (hasClaudeCode) providers.push("claude_code");
8533
+ if (hasCursor) providers.push("cursor");
8534
+ }
8400
8535
  const { claudeWorkers, cursorWorkers } = splitWorkers(totalWorkers, providers);
8536
+ if (synkroFilePool !== "auto") console.log(` .synkro: grader pool set to ${synkroFilePool}`);
8401
8537
  console.log(` worker pool: ${claudeWorkers} claude + ${cursorWorkers} cursor`);
8402
8538
  const connectedRepo = detectGitRepo2() || void 0;
8403
8539
  const { image, hostMcpPort, hostGraderPort, hostCwePort, hostPglitePort } = await dockerInstall({ claudeWorkers, cursorWorkers, connectedRepo });
@@ -8407,7 +8543,7 @@ async function installCommand(opts = {}) {
8407
8543
  const ready = await waitForContainerReady(6e4);
8408
8544
  if (ready) {
8409
8545
  console.log(" \u2713 container ready");
8410
- const mcpJwt = readFileSync7(join8(SYNKRO_DIR4, ".mcp-jwt"), "utf-8").trim();
8546
+ const mcpJwt = readFileSync8(join8(SYNKRO_DIR4, ".mcp-jwt"), "utf-8").trim();
8411
8547
  try {
8412
8548
  const ingestResp = await fetch(`http://127.0.0.1:${hostMcpPort}/api/ingest`, {
8413
8549
  method: "POST",
@@ -8431,14 +8567,14 @@ async function installCommand(opts = {}) {
8431
8567
  }
8432
8568
  console.log();
8433
8569
  }
8434
- if (transcriptConsent) {
8570
+ if (transcriptConsent && hasClaudeCode) {
8435
8571
  const repo = detectGitRepo2();
8436
8572
  if (repo) {
8437
8573
  if (storageMode === "local") {
8438
8574
  try {
8439
8575
  let mcpToken = "";
8440
8576
  try {
8441
- mcpToken = readFileSync7(join8(SYNKRO_DIR4, ".mcp-jwt"), "utf-8").trim();
8577
+ mcpToken = readFileSync8(join8(SYNKRO_DIR4, ".mcp-jwt"), "utf-8").trim();
8442
8578
  } catch {
8443
8579
  }
8444
8580
  if (mcpToken) {
@@ -8502,10 +8638,49 @@ async function installCommand(opts = {}) {
8502
8638
  }
8503
8639
  console.log("\u2713 Synkro installed.");
8504
8640
  }
8641
+ function writeSynkroFileIfMissing(opts) {
8642
+ try {
8643
+ const root = execSync6("git rev-parse --show-toplevel", { encoding: "utf-8", timeout: 3e3, stdio: ["pipe", "pipe", "pipe"] }).trim();
8644
+ if (!root) return;
8645
+ const fp = join8(root, ".synkro");
8646
+ if (existsSync9(fp)) {
8647
+ console.log(` .synkro: ${fp} (existing, respected)`);
8648
+ return;
8649
+ }
8650
+ let pool = "auto";
8651
+ if (opts.hasClaudeCode && !opts.hasCursor) pool = "claude";
8652
+ else if (opts.hasCursor && !opts.hasClaudeCode) pool = "cursor";
8653
+ const config = {
8654
+ version: 1,
8655
+ grader: {
8656
+ pool,
8657
+ mode: opts.gradingMode === "byok" ? "byok" : "local"
8658
+ },
8659
+ ruleset: "default",
8660
+ scanning: { cwe: true, cve: true }
8661
+ };
8662
+ writeFileSync7(fp, JSON.stringify(config, null, 2) + "\n", "utf-8");
8663
+ console.log(` .synkro: wrote ${fp} (pool=${pool}, mode=${config.grader.mode})`);
8664
+ } catch {
8665
+ }
8666
+ }
8667
+ function readSynkroFilePool2() {
8668
+ try {
8669
+ const root = execSync6("git rev-parse --show-toplevel", { encoding: "utf-8", timeout: 3e3, stdio: ["pipe", "pipe", "pipe"] }).trim();
8670
+ if (!root) return "auto";
8671
+ const fp = join8(root, ".synkro");
8672
+ if (!existsSync9(fp)) return "auto";
8673
+ const parsed = JSON.parse(readFileSync8(fp, "utf-8"));
8674
+ const pool = parsed?.grader?.pool;
8675
+ if (pool === "cursor" || pool === "claude") return pool;
8676
+ } catch {
8677
+ }
8678
+ return "auto";
8679
+ }
8505
8680
  function detectGitRepo2() {
8506
8681
  const run = (cmd2) => {
8507
8682
  try {
8508
- return execSync5(cmd2, { encoding: "utf-8", timeout: 5e3, stdio: ["pipe", "pipe", "pipe"] }).trim();
8683
+ return execSync6(cmd2, { encoding: "utf-8", timeout: 5e3, stdio: ["pipe", "pipe", "pipe"] }).trim();
8509
8684
  } catch {
8510
8685
  return "";
8511
8686
  }
@@ -8530,7 +8705,7 @@ function extractSessionInsights(projectsDir) {
8530
8705
  const sessionId = file.replace(".jsonl", "");
8531
8706
  const filePath = join8(projectsDir, file);
8532
8707
  try {
8533
- const content = readFileSync7(filePath, "utf-8");
8708
+ const content = readFileSync8(filePath, "utf-8");
8534
8709
  const lines = content.split("\n").filter(Boolean);
8535
8710
  for (let i = 0; i < lines.length; i++) {
8536
8711
  try {
@@ -8606,7 +8781,7 @@ function extractTextContent(content) {
8606
8781
  return "";
8607
8782
  }
8608
8783
  function parseTranscriptFile(filePath) {
8609
- const content = readFileSync7(filePath, "utf-8");
8784
+ const content = readFileSync8(filePath, "utf-8");
8610
8785
  const lines = content.split("\n").filter(Boolean);
8611
8786
  const messages = [];
8612
8787
  for (let i = 0; i < lines.length; i++) {
@@ -8676,7 +8851,7 @@ async function syncTranscriptsLocal(mcpPort, mcpToken, repo) {
8676
8851
  process.stdout.write(`\r Progress: ${i + 1}/${files.length} sessions (${totalMessages} messages embedded) `);
8677
8852
  }
8678
8853
  try {
8679
- const content = readFileSync7(join8(projectsDir, file), "utf-8");
8854
+ const content = readFileSync8(join8(projectsDir, file), "utf-8");
8680
8855
  const lineCount = content.split("\n").filter(Boolean).length;
8681
8856
  writeFileSync7(join8(OFFSETS_DIR, sessionId), String(lineCount), "utf-8");
8682
8857
  } catch {
@@ -8730,7 +8905,7 @@ async function syncTranscriptsBulk(gatewayUrl, token, repo) {
8730
8905
  const sessionId = file.replace(".jsonl", "");
8731
8906
  const filePath = join8(projectsDir, file);
8732
8907
  try {
8733
- const content = readFileSync7(filePath, "utf-8");
8908
+ const content = readFileSync8(filePath, "utf-8");
8734
8909
  const lineCount = content.split("\n").filter(Boolean).length;
8735
8910
  writeFileSync7(join8(OFFSETS_DIR, sessionId), String(lineCount), "utf-8");
8736
8911
  } catch {
@@ -8809,7 +8984,7 @@ rl.on('line', async (line) => {
8809
8984
  });
8810
8985
 
8811
8986
  // cli/local-cc/install.ts
8812
- import { existsSync as existsSync10, mkdirSync as mkdirSync9, writeFileSync as writeFileSync8, readFileSync as readFileSync8, chmodSync as chmodSync3, copyFileSync as copyFileSync2, renameSync as renameSync4, unlinkSync as unlinkSync4, openSync, fsyncSync, closeSync } from "fs";
8987
+ import { existsSync as existsSync10, 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";
8813
8988
  import { join as join9 } from "path";
8814
8989
  import { homedir as homedir9 } from "os";
8815
8990
  import { spawnSync as spawnSync4 } from "child_process";
@@ -8848,7 +9023,7 @@ function safelyMutateClaudeJson(mutator) {
8848
9023
  if (!existsSync10(CLAUDE_JSON_PATH)) {
8849
9024
  return;
8850
9025
  }
8851
- const originalText = readFileSync8(CLAUDE_JSON_PATH, "utf-8");
9026
+ const originalText = readFileSync9(CLAUDE_JSON_PATH, "utf-8");
8852
9027
  let parsed;
8853
9028
  try {
8854
9029
  parsed = JSON.parse(originalText);
@@ -9418,7 +9593,7 @@ var init_disconnect = __esm({
9418
9593
  });
9419
9594
 
9420
9595
  // cli/local-cc/turnLog.ts
9421
- import { appendFileSync, existsSync as existsSync12, mkdirSync as mkdirSync10, openSync as openSync2, readFileSync as readFileSync9, readSync, closeSync as closeSync2, statSync as statSync2, watchFile, unwatchFile } from "fs";
9596
+ import { appendFileSync, existsSync as existsSync12, mkdirSync as mkdirSync10, openSync as openSync2, readFileSync as readFileSync10, readSync, closeSync as closeSync2, statSync as statSync2, watchFile, unwatchFile } from "fs";
9422
9597
  import { dirname as dirname6, join as join11 } from "path";
9423
9598
  import { homedir as homedir11 } from "os";
9424
9599
  function truncate(s, max = PREVIEW_MAX) {
@@ -9460,7 +9635,7 @@ function readRecentTurns(n = 20) {
9460
9635
  try {
9461
9636
  const size = statSync2(TURN_LOG_PATH).size;
9462
9637
  if (size === 0) return [];
9463
- const text = readFileSync9(TURN_LOG_PATH, "utf-8");
9638
+ const text = readFileSync10(TURN_LOG_PATH, "utf-8");
9464
9639
  const lines = text.split("\n").filter(Boolean);
9465
9640
  const lastN = lines.slice(-n).reverse();
9466
9641
  return lastN.map((line) => {
@@ -9906,13 +10081,13 @@ var init_pueue = __esm({
9906
10081
  });
9907
10082
 
9908
10083
  // cli/local-cc/settings.ts
9909
- import { existsSync as existsSync13, readFileSync as readFileSync10 } from "fs";
10084
+ import { existsSync as existsSync13, readFileSync as readFileSync11 } from "fs";
9910
10085
  import { homedir as homedir13 } from "os";
9911
10086
  import { join as join13 } from "path";
9912
10087
  function isLocalCCEnabled() {
9913
10088
  if (!existsSync13(CONFIG_PATH3)) return false;
9914
10089
  try {
9915
- const content = readFileSync10(CONFIG_PATH3, "utf-8");
10090
+ const content = readFileSync11(CONFIG_PATH3, "utf-8");
9916
10091
  const match = content.match(/^SYNKRO_LOCAL_INFERENCE='([^']*)'/m);
9917
10092
  return match?.[1] === "yes";
9918
10093
  } catch {
@@ -9936,7 +10111,7 @@ import { spawnSync as spawnSync7 } from "child_process";
9936
10111
  import { homedir as homedir14 } from "os";
9937
10112
  import { join as join14 } from "path";
9938
10113
  import { readFileSync as fsReadFileSync, existsSync as fsExistsSync } from "fs";
9939
- import { existsSync as existsSync14, readFileSync as readFileSync11, writeFileSync as writeFileSync9 } from "fs";
10114
+ import { existsSync as existsSync14, readFileSync as readFileSync12, writeFileSync as writeFileSync9 } from "fs";
9940
10115
  function deploymentMode() {
9941
10116
  const env = (process.env.SYNKRO_DEPLOYMENT_MODE || "").toLowerCase();
9942
10117
  if (env === "docker") return "docker";
@@ -10043,14 +10218,14 @@ TROUBLESHOOTING
10043
10218
  }
10044
10219
  function readGatewayUrl() {
10045
10220
  if (existsSync14(CONFIG_PATH4)) {
10046
- const m = readFileSync11(CONFIG_PATH4, "utf-8").match(/^SYNKRO_GATEWAY_URL='([^']*)'/m);
10221
+ const m = readFileSync12(CONFIG_PATH4, "utf-8").match(/^SYNKRO_GATEWAY_URL='([^']*)'/m);
10047
10222
  if (m) return m[1];
10048
10223
  }
10049
10224
  return "https://api.synkro.sh";
10050
10225
  }
10051
10226
  function updateLocalInferenceFlag(enabled) {
10052
10227
  if (!existsSync14(CONFIG_PATH4)) return;
10053
- let content = readFileSync11(CONFIG_PATH4, "utf-8");
10228
+ let content = readFileSync12(CONFIG_PATH4, "utf-8");
10054
10229
  const flag = enabled ? "yes" : "no";
10055
10230
  if (content.includes("SYNKRO_LOCAL_INFERENCE=")) {
10056
10231
  content = content.replace(/^SYNKRO_LOCAL_INFERENCE='[^']*'/m, `SYNKRO_LOCAL_INFERENCE='${flag}'`);
@@ -10579,13 +10754,13 @@ var config_exports = {};
10579
10754
  __export(config_exports, {
10580
10755
  configCommand: () => configCommand
10581
10756
  });
10582
- import { readFileSync as readFileSync12, writeFileSync as writeFileSync10, existsSync as existsSync15 } from "fs";
10757
+ import { readFileSync as readFileSync13, writeFileSync as writeFileSync10, existsSync as existsSync15 } from "fs";
10583
10758
  import { join as join15 } from "path";
10584
10759
  import { homedir as homedir15 } from "os";
10585
10760
  function readConfigEnv() {
10586
10761
  if (!existsSync15(CONFIG_PATH5)) return {};
10587
10762
  const out = {};
10588
- for (const line of readFileSync12(CONFIG_PATH5, "utf-8").split("\n")) {
10763
+ for (const line of readFileSync13(CONFIG_PATH5, "utf-8").split("\n")) {
10589
10764
  const t = line.trim();
10590
10765
  if (!t || t.startsWith("#")) continue;
10591
10766
  const eq = t.indexOf("=");
@@ -10598,7 +10773,7 @@ function updateConfigValue(key, value) {
10598
10773
  console.error("No config found. Run `synkro install` first.");
10599
10774
  process.exit(1);
10600
10775
  }
10601
- const lines = readFileSync12(CONFIG_PATH5, "utf-8").split("\n");
10776
+ const lines = readFileSync13(CONFIG_PATH5, "utf-8").split("\n");
10602
10777
  const pattern = new RegExp(`^${key}=`);
10603
10778
  let found = false;
10604
10779
  const updated = lines.map((line) => {
@@ -10725,14 +10900,14 @@ var init_config = __esm({
10725
10900
  });
10726
10901
 
10727
10902
  // cli/bootstrap.js
10728
- import { readFileSync as readFileSync13, existsSync as existsSync16 } from "fs";
10903
+ import { readFileSync as readFileSync14, existsSync as existsSync16 } from "fs";
10729
10904
  import { resolve as resolve2 } from "path";
10730
10905
  var envCandidates = [
10731
10906
  resolve2(process.env.HOME ?? "", ".synkro", "config.env")
10732
10907
  ];
10733
10908
  for (const envPath of envCandidates) {
10734
10909
  if (!existsSync16(envPath)) continue;
10735
- const envContent = readFileSync13(envPath, "utf-8");
10910
+ const envContent = readFileSync14(envPath, "utf-8");
10736
10911
  for (const line of envContent.split("\n")) {
10737
10912
  const trimmed = line.trim();
10738
10913
  if (!trimmed || trimmed.startsWith("#")) continue;
@@ -10747,7 +10922,7 @@ var args = process.argv.slice(2);
10747
10922
  var cmd = args[0] || "";
10748
10923
  var subArgs = args.slice(1);
10749
10924
  function printVersion() {
10750
- console.log("1.6.31");
10925
+ console.log("1.6.32");
10751
10926
  }
10752
10927
  function printHelp2() {
10753
10928
  console.log(`Synkro CLI \u2014 runtime safety for AI coding agents