@synkro-sh/cli 1.6.44 → 1.6.46

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 ?? [];
@@ -1280,6 +1280,39 @@ export function effectiveGraderPool(synkroFile: SynkroFileConfig, hookAgentKind:
1280
1280
  return 'cursor';
1281
1281
  }
1282
1282
 
1283
+ // \u2500\u2500\u2500 .synkro presence (per-repo onboarding) \u2500\u2500\u2500
1284
+ // A repo is "onboarded" to Synkro when it has a .synkro file at its git root.
1285
+ // The hooks are installed globally, so they fire in every repo \u2014 including ones
1286
+ // the user never set up. Without a .synkro there, grading is SKIPPED gracefully
1287
+ // rather than attempting a grade that surfaces a confusing "grader unavailable"
1288
+ // error: the tool call passes through and we show a one-time hint pointing at
1289
+ // "synkro install".
1290
+ export function synkroFilePresent(cwd?: string): boolean {
1291
+ try {
1292
+ const root = cwd ? findGitRoot(cwd) : '';
1293
+ if (!root) return false;
1294
+ return existsSync(root + '/.synkro');
1295
+ } catch { return false; }
1296
+ }
1297
+
1298
+ const NO_SYNKRO_HINT_DIR = join(HOME, '.synkro', '.no-synkro-hint');
1299
+
1300
+ // Returns the onboarding hint the FIRST time a session touches a repo with no
1301
+ // .synkro file, then null on subsequent calls so it doesn't repeat on every
1302
+ // tool call. Caller passes null straight through to outputEmpty().
1303
+ export function noSynkroSkipMessage(sessionId: string): string | null {
1304
+ try {
1305
+ mkdirSync(NO_SYNKRO_HINT_DIR, { recursive: true });
1306
+ const key = (sessionId || 'nosession').replace(/[^a-zA-Z0-9_-]/g, '_');
1307
+ const marker = join(NO_SYNKRO_HINT_DIR, key);
1308
+ if (existsSync(marker)) return null;
1309
+ writeFileSync(marker, '', { flag: 'w' });
1310
+ } catch {
1311
+ // best-effort dedup \u2014 if the marker can't be written, still show the hint
1312
+ }
1313
+ return '[synkro] No .synkro config in this repo \u2014 grading skipped. Run \`synkro install\` here to enable Synkro.';
1314
+ }
1315
+
1283
1316
  // \u2500\u2500\u2500 Channel Health \u2500\u2500\u2500
1284
1317
 
1285
1318
  export async function channelUp(port = 18929): Promise<boolean> {
@@ -3218,6 +3251,7 @@ export function isCursorInvokingCcHook(agentKind: string, model: string): boolea
3218
3251
  }
3219
3252
 
3220
3253
  let cursorHookExited = false;
3254
+ let hookFinalized = false;
3221
3255
 
3222
3256
  export function setupCursorHookSignals(): void {
3223
3257
  if (!isCursorHookFormat()) return;
@@ -3226,9 +3260,28 @@ export function setupCursorHookSignals(): void {
3226
3260
 
3227
3261
  function cursorHookExit(): never {
3228
3262
  cursorHookExited = true;
3263
+ hookFinalized = true;
3229
3264
  process.exit(0);
3230
3265
  }
3231
3266
 
3267
+ // Self-imposed deadline. CC/Cursor kill the hook process at the configured hook
3268
+ // timeout with a signal; if a grade fetch is still in flight when that happens,
3269
+ // the runtime dumps the in-flight request body (the grader prompt) to stderr and
3270
+ // exits non-zero \u2014 surfacing as a "hook error" toast with leaked prompt text.
3271
+ // To prevent that, each grading hook arms this watchdog a few seconds UNDER its
3272
+ // configured budget: when it fires we emit a clean empty result and exit 0
3273
+ // ourselves, so the harness never has to kill us. The timer is unref'd, so a
3274
+ // hook that finishes early exits normally and the watchdog never runs.
3275
+ export function installHookWatchdog(budgetMs: number): void {
3276
+ const t = setTimeout(() => {
3277
+ if (hookFinalized) return;
3278
+ hookFinalized = true;
3279
+ try { process.stdout.write('{}\\n'); } catch {}
3280
+ process.exit(0);
3281
+ }, budgetMs);
3282
+ if (typeof (t as any).unref === 'function') (t as any).unref();
3283
+ }
3284
+
3232
3285
  // \u2500\u2500\u2500 Grader-unavailable diagnostic log \u2500\u2500\u2500
3233
3286
  // Records every time a hook tried to call the local grader and fell open
3234
3287
  // because the call failed. JSONL at ~/.synkro/grader-unavailable.log so the
@@ -3308,6 +3361,8 @@ export function outputJson(obj: any): void {
3308
3361
  outputEmpty();
3309
3362
  return;
3310
3363
  }
3364
+ if (hookFinalized) return;
3365
+ hookFinalized = true;
3311
3366
  console.log(JSON.stringify(obj));
3312
3367
  }
3313
3368
 
@@ -3319,6 +3374,8 @@ export function outputEmpty(): void {
3319
3374
  }
3320
3375
  cursorHookExit();
3321
3376
  }
3377
+ if (hookFinalized) return;
3378
+ hookFinalized = true;
3322
3379
  console.log('{}');
3323
3380
  }
3324
3381
  `;
@@ -3328,10 +3385,10 @@ import {
3328
3385
  parseVerdict, dispatchCapture, ruleMode, reconstructContent, isPathUnder, postWithRetry,
3329
3386
  readStdin, extractTranscript, readLastPrompt, findNearestDeps, filePathFromToolInput,
3330
3387
  appendSessionAction, readSessionLog, compressSessionLog, log,
3331
- outputJson, outputEmpty, setupCursorHookSignals, isEditTool, hookSessionId, GATEWAY_URL,
3388
+ outputJson, outputEmpty, setupCursorHookSignals, installHookWatchdog, isEditTool, hookSessionId, GATEWAY_URL,
3332
3389
  logGraderUnavailable, graderUnavailableMessage, filterRules, ruleFilterText, normalizeMode, countEditLineDelta,
3333
3390
  captureLineMetrics, cursorModelFromPayload, resolveTranscriptPath, isCursorInvokingCcHook,
3334
- loadSynkroFile, effectiveGraderPool,
3391
+ loadSynkroFile, effectiveGraderPool, synkroFilePresent, noSynkroSkipMessage,
3335
3392
  type HookConfig, type Rule,
3336
3393
  } from './_synkro-common.ts';
3337
3394
  import { existsSync, readFileSync } from 'node:fs';
@@ -3341,6 +3398,7 @@ const agentKind = (process.env.SYNKRO_HOOK_FORMAT === 'cursor' || process.argv.i
3341
3398
 
3342
3399
  async function main() {
3343
3400
  setupCursorHookSignals();
3401
+ installHookWatchdog(27000);
3344
3402
  try {
3345
3403
  const input = await readStdin();
3346
3404
  if (!input.trim()) { outputEmpty(); return; }
@@ -3365,6 +3423,13 @@ async function main() {
3365
3423
 
3366
3424
  if (filePath.includes('/.synkro/hooks/')) { outputEmpty(); return; }
3367
3425
 
3426
+ if (!synkroFilePresent(cwd)) {
3427
+ const _skipMsg = noSynkroSkipMessage(sessionId);
3428
+ if (_skipMsg) outputJson({ systemMessage: _skipMsg, hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: _skipMsg } });
3429
+ else outputEmpty();
3430
+ return;
3431
+ }
3432
+
3368
3433
  const fileShort = basename(filePath);
3369
3434
  log('editGuard checking: ' + fileShort);
3370
3435
 
@@ -3463,7 +3528,7 @@ async function main() {
3463
3528
  'Last user prompt: ' + (lastPrompt || 'none'),
3464
3529
  'Org rules: ' + JSON.stringify(relevantRules),
3465
3530
  '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.',
3466
- '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.',
3531
+ '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.',
3467
3532
  '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.',
3468
3533
  ].join('\\n');
3469
3534
 
@@ -3585,10 +3650,10 @@ main();
3585
3650
  import {
3586
3651
  loadJwt, ensureFreshJwt, detectRepo, loadConfig, cweRoute, tag,
3587
3652
  localGradeCwe, parseVerdict, reconstructContent, readStdin, log,
3588
- outputJson, outputEmpty, setupCursorHookSignals, isEditTool, isShellTool, isCursorHookFormat,
3653
+ outputJson, outputEmpty, setupCursorHookSignals, installHookWatchdog, isEditTool, isShellTool, isCursorHookFormat,
3589
3654
  extractShellCodeWrites, hookSessionId, filePathFromToolInput, emitBlockScanFindings, dispatchFinding, dispatchCapture, GATEWAY_URL,
3590
3655
  logGraderUnavailable, graderUnavailableMessage, resolveTranscriptPath, isCursorInvokingCcHook,
3591
- loadSynkroFile, effectiveGraderPool,
3656
+ loadSynkroFile, effectiveGraderPool, synkroFilePresent, noSynkroSkipMessage,
3592
3657
  } from './_synkro-common.ts';
3593
3658
  import { basename, extname, resolve, join, dirname } from 'node:path';
3594
3659
  import { readFileSync, readdirSync, existsSync } from 'node:fs';
@@ -3725,6 +3790,7 @@ function scanPackageCapabilities(pkgName: string, cwd: string): PackageCapabilit
3725
3790
 
3726
3791
  async function main() {
3727
3792
  setupCursorHookSignals();
3793
+ installHookWatchdog(27000);
3728
3794
  try {
3729
3795
  const input = await readStdin();
3730
3796
  if (!input.trim()) { outputEmpty(); return; }
@@ -3741,6 +3807,13 @@ async function main() {
3741
3807
 
3742
3808
  if (isCursorInvokingCcHook(agentKind, ccModel)) { outputEmpty(); return; }
3743
3809
 
3810
+ if (!synkroFilePresent(cwd)) {
3811
+ const _skipMsg = noSynkroSkipMessage(sessionId);
3812
+ if (_skipMsg) outputJson({ systemMessage: _skipMsg, hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: _skipMsg } });
3813
+ else outputEmpty();
3814
+ return;
3815
+ }
3816
+
3744
3817
  const targets: CweScanTarget[] = [];
3745
3818
 
3746
3819
  if (isCursorHookFormat() && (shellCommand || isShellTool(toolName))) {
@@ -4147,7 +4220,7 @@ import {
4147
4220
  loadJwt, ensureFreshJwt, detectRepo, loadConfig, route, tag,
4148
4221
  reconstructContent, readStdin, findNearestDeps, filePathFromToolInput, log,
4149
4222
  outputJson, outputEmpty, setupCursorHookSignals, isEditTool, hookSessionId, dispatchFinding, dispatchCapture, dispatchScanResult, extractTranscript, emitBlockScanFindings, resolveTranscriptPath, GATEWAY_URL,
4150
- isCursorHookFormat,
4223
+ isCursorHookFormat, synkroFilePresent, noSynkroSkipMessage,
4151
4224
  } from './_synkro-common.ts';
4152
4225
  import { basename } from 'node:path';
4153
4226
  import { readFileSync } from 'node:fs';
@@ -4193,6 +4266,13 @@ async function main() {
4193
4266
 
4194
4267
  if (filePath.includes('/.synkro/hooks/')) { outputEmpty(); return; }
4195
4268
 
4269
+ if (!synkroFilePresent(cwd)) {
4270
+ const _skipMsg = noSynkroSkipMessage(sessionId);
4271
+ if (_skipMsg) outputJson({ systemMessage: _skipMsg, hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: _skipMsg } });
4272
+ else outputEmpty();
4273
+ return;
4274
+ }
4275
+
4196
4276
  const fileShort = basename(filePath);
4197
4277
 
4198
4278
  let jwt = loadJwt();
@@ -4429,7 +4509,7 @@ import {
4429
4509
  loadJwt, ensureFreshJwt, detectRepo, loadConfig, route, tag,
4430
4510
  readStdin, runInstallScan, emitBlockScanFindings, dispatchCapture, dispatchScanResult, hashCommand,
4431
4511
  outputJson, outputEmpty, setupCursorHookSignals, isShellTool, hookSessionId, log, GATEWAY_URL,
4432
- resolveTranscriptPath, isCursorHookFormat, loadSynkroFile, effectiveGraderPool,
4512
+ resolveTranscriptPath, isCursorHookFormat, loadSynkroFile, effectiveGraderPool, synkroFilePresent, noSynkroSkipMessage,
4433
4513
  } from './_synkro-common.ts';
4434
4514
  import { writeFileSync, mkdirSync } from 'node:fs';
4435
4515
  import { join } from 'node:path';
@@ -4454,6 +4534,14 @@ async function main() {
4454
4534
  // beforeShellExecution supplies command directly; preToolUse uses tool_name + tool_input.
4455
4535
  if (!isShellTool(toolName) && typeof payload.command !== 'string') { outputEmpty(); return; }
4456
4536
 
4537
+ const _cwd = payload.cwd || (Array.isArray(payload.workspace_roots) ? payload.workspace_roots[0] : '') || '';
4538
+ if (!synkroFilePresent(_cwd)) {
4539
+ const _skipMsg = noSynkroSkipMessage(hookSessionId(payload));
4540
+ if (_skipMsg) outputJson({ systemMessage: _skipMsg, hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: _skipMsg } });
4541
+ else outputEmpty();
4542
+ return;
4543
+ }
4544
+
4457
4545
  let jwt = loadJwt();
4458
4546
  if (!jwt) { outputEmpty(); return; }
4459
4547
  jwt = await ensureFreshJwt(jwt);
@@ -4548,9 +4636,9 @@ import {
4548
4636
  loadJwt, ensureFreshJwt, detectRepo, loadConfig, route, tag, localGrade,
4549
4637
  parseVerdict, dispatchCapture, dispatchFinding, ruleMode, postWithRetry, readStdin,
4550
4638
  extractTranscript, readLastPrompt, appendSessionAction, readSessionLog, compressSessionLog, log,
4551
- outputJson, outputEmpty, setupCursorHookSignals, isShellTool, hookSessionId, GATEWAY_URL,
4639
+ outputJson, outputEmpty, setupCursorHookSignals, installHookWatchdog, isShellTool, hookSessionId, GATEWAY_URL,
4552
4640
  logGraderUnavailable, graderUnavailableMessage, filterRules, ruleFilterText, normalizeMode, appendLocalTelemetry, isSafeInRepoRead,
4553
- loadSynkroFile, effectiveGraderPool,
4641
+ loadSynkroFile, effectiveGraderPool, synkroFilePresent, noSynkroSkipMessage,
4554
4642
  hashCommand, resolveTranscriptPath, isCursorHookFormat,
4555
4643
  type HookConfig, type Rule,
4556
4644
  } from './_synkro-common.ts';
@@ -4591,6 +4679,7 @@ function isDuplicate(command: string, sessionId: string): boolean {
4591
4679
 
4592
4680
  async function main() {
4593
4681
  setupCursorHookSignals();
4682
+ installHookWatchdog(27000);
4594
4683
  try {
4595
4684
  const input = await readStdin();
4596
4685
  if (!input.trim()) { outputEmpty(); return; }
@@ -4630,6 +4719,13 @@ async function main() {
4630
4719
 
4631
4720
  const gitRepo = detectRepo(cwd, transcriptPath, command, workspaceRoots);
4632
4721
 
4722
+ if (!synkroFilePresent(cwd)) {
4723
+ const _skipMsg = noSynkroSkipMessage(sessionId);
4724
+ if (_skipMsg) outputJson({ systemMessage: _skipMsg, hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: _skipMsg } });
4725
+ else outputEmpty();
4726
+ return;
4727
+ }
4728
+
4633
4729
  if (isDuplicate(command, sessionId)) {
4634
4730
  log('bashGuard skip (dedup): ' + command.slice(0, 80));
4635
4731
  outputEmpty();
@@ -4722,7 +4818,7 @@ async function main() {
4722
4818
  'Org rules: ' + JSON.stringify(relevantRules),
4723
4819
  scanConcern,
4724
4820
  '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.',
4725
- '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.',
4821
+ '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.',
4726
4822
  '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.',
4727
4823
  '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.',
4728
4824
  ].filter(Boolean).join('\\n');
@@ -4845,9 +4941,9 @@ import {
4845
4941
  loadJwt, ensureFreshJwt, detectRepo, loadConfig, route, tag, localGrade,
4846
4942
  parseVerdict, dispatchCapture, ruleMode, postWithRetry, readStdin,
4847
4943
  extractTranscript, readLastPrompt, appendSessionAction, readSessionLog, compressSessionLog, log,
4848
- outputJson, outputEmpty, setupCursorHookSignals, isAgentTool, hookSessionId, GATEWAY_URL,
4944
+ outputJson, outputEmpty, setupCursorHookSignals, installHookWatchdog, isAgentTool, hookSessionId, GATEWAY_URL,
4849
4945
  logGraderUnavailable, graderUnavailableMessage, filterRules, normalizeMode, resolveTranscriptPath, isCursorInvokingCcHook,
4850
- loadSynkroFile, effectiveGraderPool,
4946
+ loadSynkroFile, effectiveGraderPool, synkroFilePresent, noSynkroSkipMessage,
4851
4947
  type HookConfig, type Rule,
4852
4948
  } from './_synkro-common.ts';
4853
4949
 
@@ -4855,6 +4951,7 @@ const agentKind = (process.env.SYNKRO_HOOK_FORMAT === 'cursor' || process.argv.i
4855
4951
 
4856
4952
  async function main() {
4857
4953
  setupCursorHookSignals();
4954
+ installHookWatchdog(27000);
4858
4955
  try {
4859
4956
  const input = await readStdin();
4860
4957
  if (!input.trim()) { outputEmpty(); return; }
@@ -4877,6 +4974,13 @@ async function main() {
4877
4974
  const permissionMode = payload.permission_mode || '';
4878
4975
  const transcriptPath = resolveTranscriptPath(payload);
4879
4976
 
4977
+ if (!synkroFilePresent(cwd)) {
4978
+ const _skipMsg = noSynkroSkipMessage(sessionId);
4979
+ if (_skipMsg) outputJson({ systemMessage: _skipMsg, hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: _skipMsg } });
4980
+ else outputEmpty();
4981
+ return;
4982
+ }
4983
+
4880
4984
  const prompt = toolInput.prompt || '';
4881
4985
  const description = toolInput.description || '';
4882
4986
  const subagentType = toolInput.subagent_type || 'general-purpose';
@@ -4934,7 +5038,7 @@ async function main() {
4934
5038
  'Last user prompt: ' + (lastPrompt || 'none'),
4935
5039
  'Org rules: ' + JSON.stringify(relevantRules),
4936
5040
  '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.',
4937
- '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.',
5041
+ '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.',
4938
5042
  ].filter(Boolean).join('\\n');
4939
5043
 
4940
5044
  let gradeResp: string;
@@ -5032,9 +5136,9 @@ main();
5032
5136
  import {
5033
5137
  loadJwt, ensureFreshJwt, detectRepo, loadConfig, route, tag, localGrade,
5034
5138
  parseVerdict, dispatchCapture, appendSessionAction, readSessionLog, compressSessionLog, postWithRetry, readStdin, log,
5035
- outputJson, outputEmpty, setupCursorHookSignals, isPlanTool, hookSessionId, GATEWAY_URL,
5139
+ outputJson, outputEmpty, setupCursorHookSignals, installHookWatchdog, isPlanTool, hookSessionId, GATEWAY_URL,
5036
5140
  filterRules, graderUnavailableMessage, resolveTranscriptPath, isCursorInvokingCcHook,
5037
- loadSynkroFile, effectiveGraderPool,
5141
+ loadSynkroFile, effectiveGraderPool, synkroFilePresent, noSynkroSkipMessage,
5038
5142
  } from './_synkro-common.ts';
5039
5143
  import { existsSync, readFileSync, writeFileSync, readdirSync, statSync } from 'node:fs';
5040
5144
  import { join } from 'node:path';
@@ -5084,6 +5188,7 @@ function appendReviewToPlan(planFile: string, verdict: string): void {
5084
5188
 
5085
5189
  async function main() {
5086
5190
  setupCursorHookSignals();
5191
+ installHookWatchdog(42000);
5087
5192
  try {
5088
5193
  const input = await readStdin();
5089
5194
  if (!input.trim()) { outputEmpty(); return; }
@@ -5106,6 +5211,13 @@ async function main() {
5106
5211
  const transcriptPath = resolveTranscriptPath(payload);
5107
5212
  const gitRepo = detectRepo(cwd, transcriptPath, plan, workspaceRoots);
5108
5213
 
5214
+ if (!synkroFilePresent(cwd)) {
5215
+ const _skipMsg = noSynkroSkipMessage(sessionId);
5216
+ if (_skipMsg) outputJson({ systemMessage: _skipMsg, hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: _skipMsg } });
5217
+ else outputEmpty();
5218
+ return;
5219
+ }
5220
+
5109
5221
  appendSessionAction(sessionId, { ts: new Date().toISOString(), tool: 'ExitPlanMode', summary: 'plan review: ' + plan.slice(0, 80) });
5110
5222
 
5111
5223
  const planShort = plan.slice(0, 80);
@@ -5220,7 +5332,7 @@ main();
5220
5332
  import {
5221
5333
  loadJwt, detectRepo, loadConfig, tag, readStdin, aggregateUsage, cleanupSessionLog,
5222
5334
  outputJson, outputEmpty, shipCloud, setupCursorHookSignals, hookSessionId, GATEWAY_URL,
5223
- resolveTranscriptPath, emitUsageTick, cursorModelFromPayload, log,
5335
+ resolveTranscriptPath, emitUsageTick, cursorModelFromPayload, log, synkroFilePresent,
5224
5336
  } from './_synkro-common.ts';
5225
5337
 
5226
5338
  async function main() {
@@ -5235,6 +5347,7 @@ async function main() {
5235
5347
 
5236
5348
  const workspaceRoots = Array.isArray(payload.workspace_roots) ? payload.workspace_roots as string[] : [];
5237
5349
  const cwd = payload.cwd || workspaceRoots[0] || '';
5350
+ if (!synkroFilePresent(cwd)) { outputEmpty(); return; }
5238
5351
  const transcriptPath = resolveTranscriptPath(payload);
5239
5352
  const gitRepo = detectRepo(cwd, transcriptPath, '', workspaceRoots);
5240
5353
  const modelFallback = cursorModelFromPayload(payload);
@@ -5288,7 +5401,7 @@ main();
5288
5401
  import {
5289
5402
  loadJwt, detectRepo, channelUp, tag, readStdin, writeCachedRepo,
5290
5403
  outputJson, outputEmpty, setupCursorHookSignals, hookSessionId, resolveTranscriptPath, GATEWAY_URL,
5291
- isLocalStorageMode, loadSynkroFile, log, type HookConfig,
5404
+ isLocalStorageMode, loadSynkroFile, log, synkroFilePresent, type HookConfig,
5292
5405
  } from './_synkro-common.ts';
5293
5406
 
5294
5407
  async function main() {
@@ -5300,6 +5413,7 @@ async function main() {
5300
5413
  const payload = JSON.parse(input);
5301
5414
  const workspaceRoots = Array.isArray(payload.workspace_roots) ? payload.workspace_roots as string[] : [];
5302
5415
  const cwd = payload.cwd || workspaceRoots[0] || '';
5416
+ if (!synkroFilePresent(cwd)) { outputEmpty(); return; }
5303
5417
  const transcriptPath = resolveTranscriptPath(payload);
5304
5418
  const sessionId = hookSessionId(payload);
5305
5419
  const gitRepo = detectRepo(cwd, transcriptPath, '', workspaceRoots);
@@ -5379,7 +5493,7 @@ main();
5379
5493
  import {
5380
5494
  loadJwt, loadConfig, readStdin, hashCommand, consentGrant, consentHasActive, consentConsume,
5381
5495
  appendSessionAction,
5382
- outputEmpty, appendLocalTelemetry, shipCloud, setupCursorHookSignals, isShellTool, hookSessionId, GATEWAY_URL,
5496
+ outputEmpty, appendLocalTelemetry, shipCloud, setupCursorHookSignals, isShellTool, hookSessionId, GATEWAY_URL, synkroFilePresent,
5383
5497
  } from './_synkro-common.ts';
5384
5498
 
5385
5499
  async function main() {
@@ -5389,6 +5503,8 @@ async function main() {
5389
5503
  if (!input.trim()) { outputEmpty(); return; }
5390
5504
 
5391
5505
  const payload = JSON.parse(input);
5506
+ const _cwd = payload.cwd || (Array.isArray(payload.workspace_roots) ? payload.workspace_roots[0] : '') || '';
5507
+ if (!synkroFilePresent(_cwd)) { outputEmpty(); return; }
5392
5508
  const toolName = payload.tool_name || '';
5393
5509
  const shellCmd = typeof payload.command === 'string' ? payload.command : (payload.tool_input?.command || '');
5394
5510
  if (!isShellTool(toolName) && !shellCmd) { outputEmpty(); return; }
@@ -5443,7 +5559,7 @@ main();
5443
5559
  import {
5444
5560
  loadJwt, detectRepo, readStdin, aggregateUsage, appendLocalTelemetry,
5445
5561
  outputEmpty, setupCursorHookSignals, hookSessionId, GATEWAY_URL, readSessionLog, shipCloud,
5446
- resolveTranscriptPath, syncConversationTranscript, emitUsageTick, cursorModelFromPayload,
5562
+ resolveTranscriptPath, syncConversationTranscript, emitUsageTick, cursorModelFromPayload, synkroFilePresent,
5447
5563
  } from './_synkro-common.ts';
5448
5564
  import { readFileSync } from 'node:fs';
5449
5565
 
@@ -5457,6 +5573,7 @@ async function main() {
5457
5573
  const sessionId = hookSessionId(payload);
5458
5574
  const workspaceRoots = Array.isArray(payload.workspace_roots) ? payload.workspace_roots as string[] : [];
5459
5575
  const cwd = payload.cwd || workspaceRoots[0] || '';
5576
+ if (!synkroFilePresent(cwd)) { outputEmpty(); return; }
5460
5577
  const transcriptPath = resolveTranscriptPath(payload);
5461
5578
 
5462
5579
  if (!sessionId || !transcriptPath) {
@@ -5502,7 +5619,7 @@ async function main() {
5502
5619
  main();
5503
5620
  `;
5504
5621
  USER_PROMPT_SUBMIT_TS = `#!/usr/bin/env bun
5505
- import { readStdin, appendLocalTelemetry, aggregateUsage, outputEmpty, setupCursorHookSignals, hookSessionId, detectRepo, resolveTranscriptPath, syncConversationTranscript, emitUsageTick, cursorModelFromPayload } from './_synkro-common.ts';
5622
+ import { readStdin, appendLocalTelemetry, aggregateUsage, outputEmpty, setupCursorHookSignals, hookSessionId, detectRepo, resolveTranscriptPath, syncConversationTranscript, emitUsageTick, cursorModelFromPayload, synkroFilePresent } from './_synkro-common.ts';
5506
5623
  import { writeFileSync, mkdirSync } from 'node:fs';
5507
5624
  import { join, dirname } from 'node:path';
5508
5625
  import { homedir } from 'node:os';
@@ -5513,7 +5630,12 @@ async function main() {
5513
5630
  const input = await readStdin();
5514
5631
  if (!input.trim()) { outputEmpty(); return; }
5515
5632
  const payload = JSON.parse(input);
5516
- const msg = payload.message || payload.prompt || payload.content || '';
5633
+ const _cwd = typeof payload.cwd === 'string' ? payload.cwd
5634
+ : (Array.isArray(payload.workspace_roots) && typeof payload.workspace_roots[0] === 'string' ? payload.workspace_roots[0] : '');
5635
+ if (!synkroFilePresent(_cwd)) { outputEmpty(); return; }
5636
+ const msg = typeof payload.message === 'string' ? payload.message
5637
+ : typeof payload.prompt === 'string' ? payload.prompt
5638
+ : typeof payload.content === 'string' ? payload.content : '';
5517
5639
  if (msg) {
5518
5640
  const promptFile = join(homedir(), '.synkro', '.last-prompt');
5519
5641
  mkdirSync(dirname(promptFile), { recursive: true, mode: 0o700 });
@@ -5552,7 +5674,7 @@ import {
5552
5674
  isSafeInRepoRead, resolveTranscriptPath, postWithRetry, readStdin, hashCommand,
5553
5675
  extractTranscript, readLastPrompt, readSessionLog, compressSessionLog,
5554
5676
  appendLocalTelemetry, logGraderUnavailable, graderUnavailableMessage, log, GATEWAY_URL,
5555
- loadSynkroFile, effectiveGraderPool,
5677
+ loadSynkroFile, effectiveGraderPool, synkroFilePresent,
5556
5678
  type Rule,
5557
5679
  } from './_synkro-common.ts';
5558
5680
  import { createHash } from 'node:crypto';
@@ -5590,8 +5712,10 @@ function isDuplicate(command: string, sessionId: string): boolean {
5590
5712
  return false;
5591
5713
  }
5592
5714
 
5593
- // Cursor beforeShellExecution timeout is 15s; stay under it (JWT refresh + grade).
5594
- const CURSOR_GRADE_TIMEOUT_MS = 12000;
5715
+ // Cursor hook timeouts now match CC (30s for bash/edit/agent), so use the same
5716
+ // 24s internal grade budget as the CC path \u2014 stays under the 30s hook timeout
5717
+ // (JWT refresh + grade) and fails open cleanly via the caller's catch.
5718
+ const CURSOR_GRADE_TIMEOUT_MS = 24000;
5595
5719
  const CURSOR_CLOUD_TIMEOUT_MS = 9000;
5596
5720
 
5597
5721
  let hookDone = false;
@@ -5668,6 +5792,10 @@ async function main() {
5668
5792
  const model = rawModel ? (rawModel.startsWith('cursor/') ? rawModel : 'cursor/' + rawModel) : 'cursor';
5669
5793
  const repo = detectRepo(cwd, transcriptPath, command, workspaceRoots);
5670
5794
 
5795
+ // No .synkro at the resolved repo root \u2192 Synkro is dormant here; allow
5796
+ // without grading. Keyed off the validated detectRepo() root, not raw cwd.
5797
+ if (!repo || !synkroFilePresent(repo)) finishAllow();
5798
+
5671
5799
  const cmdShort = command.slice(0, 80);
5672
5800
  log('bashGuard checking: ' + cmdShort);
5673
5801
 
@@ -5733,7 +5861,7 @@ async function main() {
5733
5861
  'Org rules: ' + JSON.stringify(relevantRules),
5734
5862
  scanConcern,
5735
5863
  '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.',
5736
- '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.',
5864
+ '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.',
5737
5865
  '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.',
5738
5866
  '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.',
5739
5867
  ].filter(Boolean).join('\\n');
@@ -5851,7 +5979,7 @@ import {
5851
5979
  loadJwt, ensureFreshJwt, detectRepo, readStdin, resolveTranscriptPath,
5852
5980
  appendSessionAction, appendLocalTelemetry, shipCloud, log, GATEWAY_URL,
5853
5981
  countEditLineDelta, dispatchCapture, hookSessionId, cursorModelFromPayload,
5854
- isLocalStorageMode,
5982
+ isLocalStorageMode, synkroFilePresent, isPathUnder,
5855
5983
  } from './_synkro-common.ts';
5856
5984
  import { existsSync, readFileSync } from 'node:fs';
5857
5985
  import { basename, dirname, join } from 'node:path';
@@ -5877,6 +6005,10 @@ async function main() {
5877
6005
  const filePath = String(payload.file_path || payload.path || payload.target_file || '');
5878
6006
  if (!filePath) finish();
5879
6007
 
6008
+ // No .synkro at the resolved repo root \u2192 Synkro is dormant here; skip capture.
6009
+ const _root = detectRepo((typeof payload.cwd === 'string' && payload.cwd) || dirname(filePath), '', filePath, []);
6010
+ if (!_root || !isPathUnder(filePath, _root) || !synkroFilePresent(_root)) finish();
6011
+
5880
6012
  const workspaceRoots = Array.isArray(payload.workspace_roots)
5881
6013
  ? (payload.workspace_roots as unknown[]).filter((r): r is string => typeof r === 'string')
5882
6014
  : [];
@@ -5979,7 +6111,7 @@ main().catch((e) => {
5979
6111
  /** Capture Cursor agent thinking/response text before transcript JSONL redacts it. */
5980
6112
  import {
5981
6113
  readStdin, outputEmpty, setupCursorHookSignals, hookSessionId, detectRepo,
5982
- appendThoughtOverlay, pushConversationMessage,
6114
+ appendThoughtOverlay, pushConversationMessage, synkroFilePresent,
5983
6115
  } from './_synkro-common.ts';
5984
6116
 
5985
6117
  async function main() {
@@ -5999,6 +6131,7 @@ async function main() {
5999
6131
  ? payload.workspace_roots.filter((r): r is string => typeof r === 'string')
6000
6132
  : [];
6001
6133
  const cwd = (typeof payload.cwd === 'string' && payload.cwd) || workspaceRoots[0] || '';
6134
+ if (!synkroFilePresent(cwd)) { outputEmpty(); return; }
6002
6135
  const gitRepo = detectRepo(cwd, '', '', workspaceRoots);
6003
6136
 
6004
6137
  if (event === 'afterAgentThought') {
@@ -8270,7 +8403,7 @@ function writeConfigEnv(opts) {
8270
8403
  `SYNKRO_CREDENTIALS_PATH=${shellQuoteSingle(credsPath)}`,
8271
8404
  `SYNKRO_TIER=${shellQuoteSingle(safeTier)}`,
8272
8405
  `SYNKRO_INFERENCE=${shellQuoteSingle(safeInference)}`,
8273
- `SYNKRO_VERSION=${shellQuoteSingle("1.6.44")}`
8406
+ `SYNKRO_VERSION=${shellQuoteSingle("1.6.46")}`
8274
8407
  ];
8275
8408
  if (safeSynkroBin) lines.push(`SYNKRO_CLI_BIN=${shellQuoteSingle(safeSynkroBin)}`);
8276
8409
  if (safeUserId) lines.push(`SYNKRO_USER_ID=${shellQuoteSingle(safeUserId)}`);
@@ -11355,7 +11488,7 @@ var args = process.argv.slice(2);
11355
11488
  var cmd = args[0] || "";
11356
11489
  var subArgs = args.slice(1);
11357
11490
  function printVersion() {
11358
- console.log("1.6.44");
11491
+ console.log("1.6.46");
11359
11492
  }
11360
11493
  function printHelp2() {
11361
11494
  console.log(`Synkro CLI \u2014 runtime safety for AI coding agents