@synkro-sh/cli 1.6.43 → 1.6.45

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
@@ -350,13 +350,13 @@ function installCursorHooks(hooksJsonPath, config) {
350
350
  });
351
351
  h.beforeShellExecution.push({
352
352
  command: cursorCcCmd(config.cwePrecheckScriptPath),
353
- timeout: 60,
353
+ timeout: 30,
354
354
  failClosed: false,
355
355
  [SYNKRO_MARKER2]: true
356
356
  });
357
357
  h.beforeShellExecution.push({
358
358
  command: bunRunCmd(config.bashJudgeScriptPath),
359
- timeout: 15,
359
+ timeout: 30,
360
360
  failClosed: false,
361
361
  [SYNKRO_MARKER2]: true
362
362
  });
@@ -371,36 +371,36 @@ function installCursorHooks(hooksJsonPath, config) {
371
371
  });
372
372
  h.preToolUse.push({
373
373
  command: cursorCcCmd(config.cwePrecheckScriptPath),
374
- timeout: 60,
374
+ timeout: 30,
375
375
  failClosed: false,
376
376
  matcher: "Shell|Bash|terminal|run_terminal_cmd|execute_command",
377
377
  [SYNKRO_MARKER2]: true
378
378
  });
379
379
  h.preToolUse.push({
380
380
  command: bunRunCmd(config.bashJudgeScriptPath),
381
- timeout: 15,
381
+ timeout: 30,
382
382
  failClosed: false,
383
383
  matcher: "Shell|Bash|Read|ReadFile|Grep|Glob|terminal|run_terminal_cmd|execute_command|read_file|grep_search|file_search|list_dir|codebase_search|delete_file",
384
384
  [SYNKRO_MARKER2]: true
385
385
  });
386
386
  pushCcHook(h, "preToolUse", config.editPrecheckScriptPath, {
387
- timeout: 15,
387
+ timeout: 30,
388
388
  matcher: "Write|Edit|StrReplace|MultiEdit|NotebookEdit|edit_file|reapply|edit_notebook|ApplyPatch|apply_patch"
389
389
  });
390
390
  pushCcHook(h, "preToolUse", config.cwePrecheckScriptPath, {
391
- timeout: 60,
391
+ timeout: 30,
392
392
  matcher: "Write|Edit|StrReplace|MultiEdit|NotebookEdit|edit_file|reapply|edit_notebook|ApplyPatch|apply_patch"
393
393
  });
394
394
  pushCcHook(h, "preToolUse", config.cvePrecheckScriptPath, {
395
- timeout: 20,
395
+ timeout: 10,
396
396
  matcher: "Write|Edit|StrReplace|MultiEdit|NotebookEdit|edit_file|reapply|edit_notebook|ApplyPatch|apply_patch"
397
397
  });
398
398
  pushCcHook(h, "preToolUse", config.agentJudgeScriptPath, {
399
- timeout: 15,
399
+ timeout: 30,
400
400
  matcher: "Agent|Task"
401
401
  });
402
402
  pushCcHook(h, "preToolUse", config.planJudgeScriptPath, {
403
- timeout: 20,
403
+ timeout: 45,
404
404
  matcher: "ExitPlanMode|SwitchMode|CreatePlan"
405
405
  });
406
406
  h.afterFileEdit = h.afterFileEdit ?? [];
@@ -1499,7 +1499,12 @@ async function cloudGrade(surface: string, prompt: string, jwt: string, timeoutM
1499
1499
  return String(data.verdict || '');
1500
1500
  }
1501
1501
 
1502
- export async function localGrade(surface: string, prompt: string, timeoutMs = 30000, agentKind: AgentKind = 'claude_code'): Promise<string> {
1502
+ // Default 24s \u2014 MUST stay below the CC hook timeout (30s for edit/bash/cwe in
1503
+ // ccHookConfig.ts) so the AbortSignal fires and the caller's catch fails open
1504
+ // cleanly. If this matches or exceeds the CC budget, CC force-kills the bun
1505
+ // process mid-fetch (exit 142 / SIGALRM) and surfaces the in-flight grader
1506
+ // prompt as a hook error instead of a clean pass.
1507
+ export async function localGrade(surface: string, prompt: string, timeoutMs = 24000, agentKind: AgentKind = 'claude_code'): Promise<string> {
1503
1508
  const jwt = loadJwt();
1504
1509
  if (!jwt) throw new Error('NO_JWT');
1505
1510
  // BYOK grading mode routes the grade through an LLM API instead of the
@@ -1511,7 +1516,10 @@ export async function localGrade(surface: string, prompt: string, timeoutMs = 30
1511
1516
  return channelGrade(ROLE_MAP[surface] || 'grade-edit', prompt, jwt, 18929, timeoutMs, agentKind);
1512
1517
  }
1513
1518
 
1514
- export async function localGradeCwe(prompt: string, agentKind: AgentKind = 'claude_code', timeoutMs = 45000): Promise<string> {
1519
+ // Default 24s \u2014 was 45000, which EXCEEDED the CC cwe hook timeout (30s) and
1520
+ // guaranteed a force-kill on any slow grade. Two cwe chunks grade in parallel
1521
+ // within the same CC budget, so each must finish well under it.
1522
+ export async function localGradeCwe(prompt: string, agentKind: AgentKind = 'claude_code', timeoutMs = 24000): Promise<string> {
1515
1523
  const jwt = loadJwt();
1516
1524
  if (!jwt) throw new Error('NO_JWT');
1517
1525
  return channelGrade('grade-cwe', prompt, jwt, 18930, timeoutMs, agentKind);
@@ -3210,6 +3218,7 @@ export function isCursorInvokingCcHook(agentKind: string, model: string): boolea
3210
3218
  }
3211
3219
 
3212
3220
  let cursorHookExited = false;
3221
+ let hookFinalized = false;
3213
3222
 
3214
3223
  export function setupCursorHookSignals(): void {
3215
3224
  if (!isCursorHookFormat()) return;
@@ -3218,9 +3227,28 @@ export function setupCursorHookSignals(): void {
3218
3227
 
3219
3228
  function cursorHookExit(): never {
3220
3229
  cursorHookExited = true;
3230
+ hookFinalized = true;
3221
3231
  process.exit(0);
3222
3232
  }
3223
3233
 
3234
+ // Self-imposed deadline. CC/Cursor kill the hook process at the configured hook
3235
+ // timeout with a signal; if a grade fetch is still in flight when that happens,
3236
+ // the runtime dumps the in-flight request body (the grader prompt) to stderr and
3237
+ // exits non-zero \u2014 surfacing as a "hook error" toast with leaked prompt text.
3238
+ // To prevent that, each grading hook arms this watchdog a few seconds UNDER its
3239
+ // configured budget: when it fires we emit a clean empty result and exit 0
3240
+ // ourselves, so the harness never has to kill us. The timer is unref'd, so a
3241
+ // hook that finishes early exits normally and the watchdog never runs.
3242
+ export function installHookWatchdog(budgetMs: number): void {
3243
+ const t = setTimeout(() => {
3244
+ if (hookFinalized) return;
3245
+ hookFinalized = true;
3246
+ try { process.stdout.write('{}\\n'); } catch {}
3247
+ process.exit(0);
3248
+ }, budgetMs);
3249
+ if (typeof (t as any).unref === 'function') (t as any).unref();
3250
+ }
3251
+
3224
3252
  // \u2500\u2500\u2500 Grader-unavailable diagnostic log \u2500\u2500\u2500
3225
3253
  // Records every time a hook tried to call the local grader and fell open
3226
3254
  // because the call failed. JSONL at ~/.synkro/grader-unavailable.log so the
@@ -3300,6 +3328,8 @@ export function outputJson(obj: any): void {
3300
3328
  outputEmpty();
3301
3329
  return;
3302
3330
  }
3331
+ if (hookFinalized) return;
3332
+ hookFinalized = true;
3303
3333
  console.log(JSON.stringify(obj));
3304
3334
  }
3305
3335
 
@@ -3311,6 +3341,8 @@ export function outputEmpty(): void {
3311
3341
  }
3312
3342
  cursorHookExit();
3313
3343
  }
3344
+ if (hookFinalized) return;
3345
+ hookFinalized = true;
3314
3346
  console.log('{}');
3315
3347
  }
3316
3348
  `;
@@ -3320,7 +3352,7 @@ import {
3320
3352
  parseVerdict, dispatchCapture, ruleMode, reconstructContent, isPathUnder, postWithRetry,
3321
3353
  readStdin, extractTranscript, readLastPrompt, findNearestDeps, filePathFromToolInput,
3322
3354
  appendSessionAction, readSessionLog, compressSessionLog, log,
3323
- outputJson, outputEmpty, setupCursorHookSignals, isEditTool, hookSessionId, GATEWAY_URL,
3355
+ outputJson, outputEmpty, setupCursorHookSignals, installHookWatchdog, isEditTool, hookSessionId, GATEWAY_URL,
3324
3356
  logGraderUnavailable, graderUnavailableMessage, filterRules, ruleFilterText, normalizeMode, countEditLineDelta,
3325
3357
  captureLineMetrics, cursorModelFromPayload, resolveTranscriptPath, isCursorInvokingCcHook,
3326
3358
  loadSynkroFile, effectiveGraderPool,
@@ -3333,6 +3365,7 @@ const agentKind = (process.env.SYNKRO_HOOK_FORMAT === 'cursor' || process.argv.i
3333
3365
 
3334
3366
  async function main() {
3335
3367
  setupCursorHookSignals();
3368
+ installHookWatchdog(27000);
3336
3369
  try {
3337
3370
  const input = await readStdin();
3338
3371
  if (!input.trim()) { outputEmpty(); return; }
@@ -3455,7 +3488,7 @@ async function main() {
3455
3488
  'Last user prompt: ' + (lastPrompt || 'none'),
3456
3489
  'Org rules: ' + JSON.stringify(relevantRules),
3457
3490
  '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.',
3458
- '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.',
3491
+ '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. An initial user instruction is NEVER consent \u2014 only a response to a shown block counts.',
3459
3492
  '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.',
3460
3493
  ].join('\\n');
3461
3494
 
@@ -3577,7 +3610,7 @@ main();
3577
3610
  import {
3578
3611
  loadJwt, ensureFreshJwt, detectRepo, loadConfig, cweRoute, tag,
3579
3612
  localGradeCwe, parseVerdict, reconstructContent, readStdin, log,
3580
- outputJson, outputEmpty, setupCursorHookSignals, isEditTool, isShellTool, isCursorHookFormat,
3613
+ outputJson, outputEmpty, setupCursorHookSignals, installHookWatchdog, isEditTool, isShellTool, isCursorHookFormat,
3581
3614
  extractShellCodeWrites, hookSessionId, filePathFromToolInput, emitBlockScanFindings, dispatchFinding, dispatchCapture, GATEWAY_URL,
3582
3615
  logGraderUnavailable, graderUnavailableMessage, resolveTranscriptPath, isCursorInvokingCcHook,
3583
3616
  loadSynkroFile, effectiveGraderPool,
@@ -3717,6 +3750,7 @@ function scanPackageCapabilities(pkgName: string, cwd: string): PackageCapabilit
3717
3750
 
3718
3751
  async function main() {
3719
3752
  setupCursorHookSignals();
3753
+ installHookWatchdog(27000);
3720
3754
  try {
3721
3755
  const input = await readStdin();
3722
3756
  if (!input.trim()) { outputEmpty(); return; }
@@ -4540,7 +4574,7 @@ import {
4540
4574
  loadJwt, ensureFreshJwt, detectRepo, loadConfig, route, tag, localGrade,
4541
4575
  parseVerdict, dispatchCapture, dispatchFinding, ruleMode, postWithRetry, readStdin,
4542
4576
  extractTranscript, readLastPrompt, appendSessionAction, readSessionLog, compressSessionLog, log,
4543
- outputJson, outputEmpty, setupCursorHookSignals, isShellTool, hookSessionId, GATEWAY_URL,
4577
+ outputJson, outputEmpty, setupCursorHookSignals, installHookWatchdog, isShellTool, hookSessionId, GATEWAY_URL,
4544
4578
  logGraderUnavailable, graderUnavailableMessage, filterRules, ruleFilterText, normalizeMode, appendLocalTelemetry, isSafeInRepoRead,
4545
4579
  loadSynkroFile, effectiveGraderPool,
4546
4580
  hashCommand, resolveTranscriptPath, isCursorHookFormat,
@@ -4583,6 +4617,7 @@ function isDuplicate(command: string, sessionId: string): boolean {
4583
4617
 
4584
4618
  async function main() {
4585
4619
  setupCursorHookSignals();
4620
+ installHookWatchdog(27000);
4586
4621
  try {
4587
4622
  const input = await readStdin();
4588
4623
  if (!input.trim()) { outputEmpty(); return; }
@@ -4714,7 +4749,7 @@ async function main() {
4714
4749
  'Org rules: ' + JSON.stringify(relevantRules),
4715
4750
  scanConcern,
4716
4751
  '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.',
4717
- '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.',
4752
+ '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. An initial user instruction is NEVER consent — only a response to a shown block counts.',
4718
4753
  '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.',
4719
4754
  '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.',
4720
4755
  ].filter(Boolean).join('\\n');
@@ -4837,7 +4872,7 @@ import {
4837
4872
  loadJwt, ensureFreshJwt, detectRepo, loadConfig, route, tag, localGrade,
4838
4873
  parseVerdict, dispatchCapture, ruleMode, postWithRetry, readStdin,
4839
4874
  extractTranscript, readLastPrompt, appendSessionAction, readSessionLog, compressSessionLog, log,
4840
- outputJson, outputEmpty, setupCursorHookSignals, isAgentTool, hookSessionId, GATEWAY_URL,
4875
+ outputJson, outputEmpty, setupCursorHookSignals, installHookWatchdog, isAgentTool, hookSessionId, GATEWAY_URL,
4841
4876
  logGraderUnavailable, graderUnavailableMessage, filterRules, normalizeMode, resolveTranscriptPath, isCursorInvokingCcHook,
4842
4877
  loadSynkroFile, effectiveGraderPool,
4843
4878
  type HookConfig, type Rule,
@@ -4847,6 +4882,7 @@ const agentKind = (process.env.SYNKRO_HOOK_FORMAT === 'cursor' || process.argv.i
4847
4882
 
4848
4883
  async function main() {
4849
4884
  setupCursorHookSignals();
4885
+ installHookWatchdog(27000);
4850
4886
  try {
4851
4887
  const input = await readStdin();
4852
4888
  if (!input.trim()) { outputEmpty(); return; }
@@ -4926,7 +4962,7 @@ async function main() {
4926
4962
  'Last user prompt: ' + (lastPrompt || 'none'),
4927
4963
  'Org rules: ' + JSON.stringify(relevantRules),
4928
4964
  '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.',
4929
- '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.',
4965
+ '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. An initial user instruction is NEVER consent \u2014 only a response to a shown block counts.',
4930
4966
  ].filter(Boolean).join('\\n');
4931
4967
 
4932
4968
  let gradeResp: string;
@@ -5024,7 +5060,7 @@ main();
5024
5060
  import {
5025
5061
  loadJwt, ensureFreshJwt, detectRepo, loadConfig, route, tag, localGrade,
5026
5062
  parseVerdict, dispatchCapture, appendSessionAction, readSessionLog, compressSessionLog, postWithRetry, readStdin, log,
5027
- outputJson, outputEmpty, setupCursorHookSignals, isPlanTool, hookSessionId, GATEWAY_URL,
5063
+ outputJson, outputEmpty, setupCursorHookSignals, installHookWatchdog, isPlanTool, hookSessionId, GATEWAY_URL,
5028
5064
  filterRules, graderUnavailableMessage, resolveTranscriptPath, isCursorInvokingCcHook,
5029
5065
  loadSynkroFile, effectiveGraderPool,
5030
5066
  } from './_synkro-common.ts';
@@ -5076,6 +5112,7 @@ function appendReviewToPlan(planFile: string, verdict: string): void {
5076
5112
 
5077
5113
  async function main() {
5078
5114
  setupCursorHookSignals();
5115
+ installHookWatchdog(42000);
5079
5116
  try {
5080
5117
  const input = await readStdin();
5081
5118
  if (!input.trim()) { outputEmpty(); return; }
@@ -5582,8 +5619,10 @@ function isDuplicate(command: string, sessionId: string): boolean {
5582
5619
  return false;
5583
5620
  }
5584
5621
 
5585
- // Cursor beforeShellExecution timeout is 15s; stay under it (JWT refresh + grade).
5586
- const CURSOR_GRADE_TIMEOUT_MS = 12000;
5622
+ // Cursor hook timeouts now match CC (30s for bash/edit/agent), so use the same
5623
+ // 24s internal grade budget as the CC path \u2014 stays under the 30s hook timeout
5624
+ // (JWT refresh + grade) and fails open cleanly via the caller's catch.
5625
+ const CURSOR_GRADE_TIMEOUT_MS = 24000;
5587
5626
  const CURSOR_CLOUD_TIMEOUT_MS = 9000;
5588
5627
 
5589
5628
  let hookDone = false;
@@ -5725,7 +5764,7 @@ async function main() {
5725
5764
  'Org rules: ' + JSON.stringify(relevantRules),
5726
5765
  scanConcern,
5727
5766
  '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.',
5728
- '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.',
5767
+ '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. An initial user instruction is NEVER consent \u2014 only a response to a shown block counts.',
5729
5768
  '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.',
5730
5769
  '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.',
5731
5770
  ].filter(Boolean).join('\\n');
@@ -8262,7 +8301,7 @@ function writeConfigEnv(opts) {
8262
8301
  `SYNKRO_CREDENTIALS_PATH=${shellQuoteSingle(credsPath)}`,
8263
8302
  `SYNKRO_TIER=${shellQuoteSingle(safeTier)}`,
8264
8303
  `SYNKRO_INFERENCE=${shellQuoteSingle(safeInference)}`,
8265
- `SYNKRO_VERSION=${shellQuoteSingle("1.6.43")}`
8304
+ `SYNKRO_VERSION=${shellQuoteSingle("1.6.45")}`
8266
8305
  ];
8267
8306
  if (safeSynkroBin) lines.push(`SYNKRO_CLI_BIN=${shellQuoteSingle(safeSynkroBin)}`);
8268
8307
  if (safeUserId) lines.push(`SYNKRO_USER_ID=${shellQuoteSingle(safeUserId)}`);
@@ -8439,28 +8478,59 @@ async function installCommand(opts = {}) {
8439
8478
  const ghToken = null;
8440
8479
  setApiBaseUrl(`${gatewayUrl}/api`);
8441
8480
  await promptRepoConnection({ linkRepo: opts.linkRepo });
8481
+ const existingSynkro = readFullSynkroFile();
8442
8482
  const detected = detectAgents();
8443
- if (detected.length === 0) {
8444
- console.error("No supported coding agents detected. Install Claude Code or Cursor first.");
8445
- process.exit(1);
8446
- }
8447
- console.log("Detected agents:");
8448
- for (const a of detected) {
8449
- console.log(` \u2713 ${a.name}${a.version ? ` (${a.version})` : ""}`);
8450
- }
8451
- console.log();
8452
- const agents = await promptAgentSelection(detected);
8453
- if (agents.length < detected.length) {
8454
- console.log(`Installing hooks for: ${agents.map((a) => a.name).join(", ")}
8483
+ let agents;
8484
+ let gradingMode;
8485
+ let storageMode;
8486
+ let transcriptConsent = true;
8487
+ if (existingSynkro) {
8488
+ const wantCC = existingSynkro.harness.includes("claude-code");
8489
+ const wantCursor = existingSynkro.harness.includes("cursor");
8490
+ agents = detected.filter(
8491
+ (a) => a.kind === "claude_code" && wantCC || a.kind === "cursor" && wantCursor
8492
+ );
8493
+ if (agents.length === 0 && detected.length > 0) agents = detected;
8494
+ gradingMode = existingSynkro.grader.mode === "byok" ? "byok" : "local";
8495
+ storageMode = "local";
8496
+ console.log(`Using .synkro config:`);
8497
+ console.log(` harness: ${existingSynkro.harness.join(", ")}`);
8498
+ console.log(` grading: ${gradingMode} pool: ${existingSynkro.grader.pool}`);
8499
+ for (const a of agents) {
8500
+ console.log(` \u2713 ${a.name}${a.version ? ` (${a.version})` : ""}`);
8501
+ }
8502
+ console.log();
8503
+ } else {
8504
+ if (detected.length === 0) {
8505
+ console.error("No supported coding agents detected. Install Claude Code or Cursor first.");
8506
+ process.exit(1);
8507
+ }
8508
+ console.log("Detected agents:");
8509
+ for (const a of detected) {
8510
+ console.log(` \u2713 ${a.name}${a.version ? ` (${a.version})` : ""}`);
8511
+ }
8512
+ console.log();
8513
+ agents = await promptAgentSelection(detected);
8514
+ if (agents.length < detected.length) {
8515
+ console.log(`Installing hooks for: ${agents.map((a) => a.name).join(", ")}
8455
8516
  `);
8456
- }
8457
- const gradingMode = await promptGradingMode();
8458
- const storageMode = await promptStorageMode();
8459
- console.log(` grading: ${gradingMode} storage: ${storageMode}
8517
+ }
8518
+ gradingMode = await promptGradingMode();
8519
+ storageMode = await promptStorageMode();
8520
+ console.log(` grading: ${gradingMode} storage: ${storageMode}
8460
8521
  `);
8461
- if (gradingMode === "byok") {
8462
- console.log(" BYOK grading uses your own provider key \u2014 register one in the");
8463
- console.log(" dashboard under Settings \u2192 Provider Keys if you have not already.\n");
8522
+ if (gradingMode === "byok") {
8523
+ console.log(" BYOK grading uses your own provider key \u2014 register one in the");
8524
+ console.log(" dashboard under Settings \u2192 Provider Keys if you have not already.\n");
8525
+ }
8526
+ if (process.stdin.isTTY) {
8527
+ transcriptConsent = await promptTranscriptConsent();
8528
+ if (transcriptConsent) {
8529
+ console.log(" \u2713 Session import enabled\n");
8530
+ } else {
8531
+ console.log(" \u2717 Session import skipped\n");
8532
+ }
8533
+ }
8464
8534
  }
8465
8535
  ensureSynkroDir();
8466
8536
  const scripts = writeHookScripts();
@@ -8476,15 +8546,6 @@ async function installCommand(opts = {}) {
8476
8546
  } catch {
8477
8547
  }
8478
8548
  }
8479
- let transcriptConsent = true;
8480
- if (process.stdin.isTTY) {
8481
- transcriptConsent = await promptTranscriptConsent();
8482
- if (transcriptConsent) {
8483
- console.log(" \u2713 Session import enabled\n");
8484
- } else {
8485
- console.log(" \u2717 Session import skipped\n");
8486
- }
8487
- }
8488
8549
  let hasClaudeCode = false;
8489
8550
  let hasCursor = false;
8490
8551
  for (const agent of agents) {
@@ -11325,7 +11386,7 @@ var args = process.argv.slice(2);
11325
11386
  var cmd = args[0] || "";
11326
11387
  var subArgs = args.slice(1);
11327
11388
  function printVersion() {
11328
- console.log("1.6.43");
11389
+ console.log("1.6.45");
11329
11390
  }
11330
11391
  function printHelp2() {
11331
11392
  console.log(`Synkro CLI \u2014 runtime safety for AI coding agents