@synkro-sh/cli 1.6.82 → 1.6.83

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
@@ -1744,6 +1744,26 @@ async function hostedGrade(role: GradeRole, prompt: string, jwt: string, timeout
1744
1744
  return String(data.result || '');
1745
1745
  }
1746
1746
 
1747
+ // Option B: when a cloud grade fails (esp. a timeout), the REAL reason lives
1748
+ // inside the container, not on the wire \u2014 the client only ever sees "timed out".
1749
+ // Pull the org's per-worker claude/cursor sick reasons + log tails from the
1750
+ // hosted /debug endpoint so the diagnostic records WHY the grade actually died
1751
+ // (e.g. a claude 401 inside the worker). Best-effort + tightly timed; null if
1752
+ // the container is itself unreachable.
1753
+ async function fetchContainerSickReason(jwt: string, timeoutMs = 2500): Promise<string | null> {
1754
+ try {
1755
+ const r = await fetch(CONTAINERS_URL + '/debug', {
1756
+ headers: { Authorization: 'Bearer ' + jwt },
1757
+ signal: AbortSignal.timeout(timeoutMs),
1758
+ });
1759
+ if (!r.ok) return 'debug ' + r.status + ': ' + (await r.text().catch(() => '')).slice(0, 400);
1760
+ const data = await r.json().catch(() => null);
1761
+ return data ? JSON.stringify(data).slice(0, 8000) : null;
1762
+ } catch {
1763
+ return null;
1764
+ }
1765
+ }
1766
+
1747
1767
  // Cloud transcript capture \u2014 the cloud mirror of the local /api/conversation-sync
1748
1768
  // + /api/session-action telemetry. Posts to the API /api/v1/cli/sync-transcripts
1749
1769
  // (the org is derived from the login JWT, same one cloud grading uses). Best-effort:
@@ -3749,35 +3769,99 @@ export function isGraderNotConfigured(errorMessage: string): boolean {
3749
3769
  return lower.includes('no worker') || lower.includes('not configured') || lower.includes('agent_kind') || lower.includes('no pool');
3750
3770
  }
3751
3771
 
3752
- export function graderUnavailableMessage(hook: string, target: string, errorMessage: string, agentKind: AgentKind = 'claude_code'): string {
3772
+ // Categorize a grader failure into a stable machine category + a human "why"
3773
+ // (with a fix hint). Shared by the terminal message and the diagnostic log so
3774
+ // the two never disagree about the reason.
3775
+ export function classifyGraderError(errorMessage: string): { category: string; why: string } {
3776
+ const e = (errorMessage || '').toLowerCase();
3777
+ const snippet = (errorMessage || '').replace(/\\s+/g, ' ').trim().slice(0, 160);
3753
3778
  if (errorMessage === 'SYNKRO_CHANNEL_DOWN') {
3754
- return hook + ' ' + target + ' \u2192 local grader unavailable (container not running), skipped';
3779
+ return { category: 'channel_down', why: 'grader container not running (run \`synkro status\`)' };
3755
3780
  }
3756
3781
  if (isGraderNotConfigured(errorMessage)) {
3757
- const agent = agentKind === 'cursor' ? 'Cursor' : 'Claude Code';
3758
- return hook + ' ' + target + ' \u2192 local grader not configured for ' + agent + '. Add this agent to your synkro.toml file and run \`synkro install\`.';
3782
+ return { category: 'not_configured', why: 'grader not configured for this agent \u2014 add it to synkro.toml and run \`synkro install\`' };
3783
+ }
3784
+ if (/tim(e|ed)\\s*out|aborted|operation was aborted/.test(e)) {
3785
+ return { category: 'timeout', why: 'grade timed out (45s) \u2014 grader cold/slow, or its Claude token is invalid (re-run \`synkro install\`)' };
3786
+ }
3787
+ if (/\\b401\\b|unauthorized|token expired|invalid token|\\b403\\b|forbidden/.test(e)) {
3788
+ return { category: 'auth', why: 'auth rejected \u2014 session token expired (re-run \`synkro login\`, or \`synkro install\` if the grader Claude token is stale)' };
3789
+ }
3790
+ if (/econnrefused|enotfound|eai_again|fetch failed|socket|econnreset|getaddrinfo|network/.test(e)) {
3791
+ return { category: 'unreachable', why: 'grader unreachable \u2014 network or container down' };
3759
3792
  }
3760
- // In cloud-container mode the grade ran against the hosted ingress, NOT a local
3761
- // worker \u2014 say so, and distinguish a timeout (slow/cold grade) from a true
3762
- // outage so "local grader unavailable" stops masquerading as a dead grader.
3793
+ if (/\\b5\\d\\d\\b/.test(e)) {
3794
+ return { category: 'server_error', why: 'grader server error: ' + snippet };
3795
+ }
3796
+ return { category: 'unknown', why: snippet || 'unknown error' };
3797
+ }
3798
+
3799
+ export function graderUnavailableMessage(hook: string, target: string, errorMessage: string, agentKind: AgentKind = 'claude_code'): string {
3800
+ const base = hook + ' ' + target + ' \u2192 ';
3801
+ if (isGraderNotConfigured(errorMessage)) {
3802
+ const agent = agentKind === 'cursor' ? 'Cursor' : 'Claude Code';
3803
+ return base + 'local grader not configured for ' + agent + '. Add this agent to your synkro.toml file and run \`synkro install\`.';
3804
+ }
3805
+ // Always surface WHY (not just "unavailable"), categorized, with a fix hint \u2014
3806
+ // the full raw error + the exact graded prompt are in grader-unavailable.log.
3807
+ const where = process.env.SYNKRO_DEPLOY_LOCATION === 'cloud' ? 'cloud grader' : 'local grader';
3808
+ const { why } = classifyGraderError(errorMessage);
3809
+ return base + where + ' unavailable \u2014 ' + why + '; skipped (full detail: ~/.synkro/grader-unavailable.log)';
3810
+ }
3811
+
3812
+ // Diagnostic record for a skipped grade \u2014 the categorized reason AND the EXACT
3813
+ // prompt that was sent for inference (gradeInput), so you can see WHAT was being
3814
+ // graded when it failed. Routing differs by mode:
3815
+ // \u2022 CLOUD \u2192 ship to Timescale via /api/v1/cli/grader-unavailable (central, so
3816
+ // we can debug customer failures), enriched on timeout/outage with the
3817
+ // container's own claude error (option B). NOT written to the user's machine.
3818
+ // \u2022 LOCAL \u2192 the on-device ~/.synkro/grader-unavailable.log file.
3819
+ // Fire-and-forget in cloud mode: callers don't await \u2014 the pending fetch keeps
3820
+ // the hook process alive until it settles (or the watchdog ends the run).
3821
+ export async function logGraderUnavailable(
3822
+ hook: string, target: string, errorMessage: string,
3823
+ gradeInput?: string, ctx?: { sessionId?: string; repo?: string },
3824
+ ): Promise<void> {
3825
+ const { category, why } = classifyGraderError(errorMessage);
3826
+
3763
3827
  if (process.env.SYNKRO_DEPLOY_LOCATION === 'cloud') {
3764
- const isTimeout = /tim(e|ed)\\s*out|aborted|the operation was aborted/i.test(errorMessage);
3765
- if (isTimeout) {
3766
- return hook + ' ' + target + ' \u2192 cloud grade timed out (45s), skipped';
3828
+ try {
3829
+ const jwt = loadJwt();
3830
+ if (!jwt) return;
3831
+ // Only chase the container's internal reason for failures where it adds
3832
+ // signal (a timeout/outage hides the real claude error); skip it for fast,
3833
+ // already-explanatory failures like auth.
3834
+ let sickReason: string | null = null;
3835
+ if (category === 'timeout' || category === 'unreachable' || category === 'server_error') {
3836
+ sickReason = await fetchContainerSickReason(jwt);
3837
+ }
3838
+ await fetch(GATEWAY_URL + '/api/v1/cli/grader-unavailable', {
3839
+ method: 'POST',
3840
+ headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + jwt },
3841
+ body: JSON.stringify({
3842
+ hook, target, category, why,
3843
+ error: (errorMessage || '').slice(0, 2000),
3844
+ grade_input: gradeInput ? String(gradeInput).slice(0, 20000) : null,
3845
+ sick_reason: sickReason,
3846
+ session_id: ctx?.sessionId || null,
3847
+ repo: ctx?.repo || null,
3848
+ }),
3849
+ signal: AbortSignal.timeout(3000),
3850
+ }).catch(() => {});
3851
+ } catch {
3852
+ // best-effort \u2014 never let diagnostics cascade into a hook failure
3767
3853
  }
3768
- return hook + ' ' + target + ' \u2192 cloud grader unavailable, skipped';
3854
+ return;
3769
3855
  }
3770
- return hook + ' ' + target + ' \u2192 local grader unavailable, skipped';
3771
- }
3772
3856
 
3773
- export function logGraderUnavailable(hook: string, target: string, errorMessage: string): void {
3857
+ // Local mode: on-device diagnostic log.
3774
3858
  try {
3775
- const entry = {
3776
- ts: new Date().toISOString(),
3777
- hook,
3778
- target,
3779
- error: errorMessage.slice(0, 500),
3859
+ const entry: Record<string, unknown> = {
3860
+ ts: new Date().toISOString(), mode: 'local',
3861
+ hook, target, category, why,
3862
+ error: (errorMessage || '').slice(0, 1000),
3780
3863
  };
3864
+ if (gradeInput) entry.gradeInput = String(gradeInput).slice(0, 20000);
3781
3865
  appendFileSync(UNAVAIL_LOG, JSON.stringify(entry) + '\\n', 'utf-8');
3782
3866
  } catch {
3783
3867
  // best-effort \u2014 never let logging failure cascade into a hook failure
@@ -4037,7 +4121,7 @@ async function main() {
4037
4121
  }
4038
4122
  } catch (err) {
4039
4123
  const errMsg = (err as Error).message || String(err);
4040
- logGraderUnavailable('editGuard', fileShort, errMsg);
4124
+ logGraderUnavailable('editGuard', fileShort, errMsg, buildCombined(proposed), { sessionId, repo: gitRepo });
4041
4125
  outputJson({ systemMessage: tagStr + ' ' + graderUnavailableMessage('editGuard', fileShort, errMsg, graderPool) });
4042
4126
  return;
4043
4127
  }
@@ -4111,7 +4195,7 @@ async function main() {
4111
4195
  gradeResp = await localGrade('edit', graderPrompt, undefined, graderPool);
4112
4196
  } catch (err) {
4113
4197
  const errMsg = (err as Error).message || String(err);
4114
- logGraderUnavailable('editGuard', fileShort, errMsg);
4198
+ logGraderUnavailable('editGuard', fileShort, errMsg, graderPrompt, { sessionId, repo: gitRepo });
4115
4199
  outputJson({ systemMessage: tagStr + ' ' + graderUnavailableMessage('editGuard', fileShort, errMsg, graderPool) });
4116
4200
  return;
4117
4201
  }
@@ -4678,7 +4762,7 @@ async function main() {
4678
4762
  gradeResponses = [resp1, resp2];
4679
4763
  } catch (gradeErr: any) {
4680
4764
  const reason = gradeErr?.message || String(gradeErr);
4681
- logGraderUnavailable('cweGuard', fileShort, reason);
4765
+ logGraderUnavailable('cweGuard', fileShort, reason, buildCwePrompt(cweContent), { sessionId, repo: gitRepo });
4682
4766
  outputJson({ systemMessage: cweTag + ' ' + graderUnavailableMessage('cweGuard', fileShort, reason, graderPool) });
4683
4767
  return;
4684
4768
  }
@@ -4687,7 +4771,7 @@ async function main() {
4687
4771
  gradeResponses = [await localGradeCwe(buildCwePrompt(cweContent), graderPool)];
4688
4772
  } catch (gradeErr: any) {
4689
4773
  const reason = gradeErr?.message || String(gradeErr);
4690
- logGraderUnavailable('cweGuard', fileShort, reason);
4774
+ logGraderUnavailable('cweGuard', fileShort, reason, buildCwePrompt(cweContent), { sessionId, repo: gitRepo });
4691
4775
  outputJson({ systemMessage: cweTag + ' ' + graderUnavailableMessage('cweGuard', fileShort, reason, graderPool) });
4692
4776
  return;
4693
4777
  }
@@ -5435,7 +5519,7 @@ async function main() {
5435
5519
  gradeResp = await localGrade('bash', graderPrompt, undefined, graderPool);
5436
5520
  } catch (err) {
5437
5521
  const errMsg = (err as Error).message || String(err);
5438
- logGraderUnavailable('bashGuard', toolInput.command?.slice(0, 200) || '', errMsg);
5522
+ logGraderUnavailable('bashGuard', toolInput.command?.slice(0, 200) || '', errMsg, graderPrompt, { sessionId, repo: gitRepo });
5439
5523
  if (scanConcern) {
5440
5524
  const ctx = scanBlockContext + ' Synkro flagged this install but the grader is unavailable to check consent — ask the user for explicit consent, then retry.';
5441
5525
  outputJson({
@@ -5653,7 +5737,7 @@ async function main() {
5653
5737
  gradeResp = await localGrade('bash', graderPrompt, undefined, graderPool);
5654
5738
  } catch (err) {
5655
5739
  const errMsg = (err as Error).message || String(err);
5656
- logGraderUnavailable('agentGuard', subagentType || (description || '').slice(0, 100), errMsg);
5740
+ logGraderUnavailable('agentGuard', subagentType || (description || '').slice(0, 100), errMsg, graderPrompt, { sessionId, repo: gitRepo });
5657
5741
  outputJson({ systemMessage: tagStr + ' ' + graderUnavailableMessage('agentGuard', subagentType || 'agent', errMsg, graderPool) });
5658
5742
  return;
5659
5743
  }
@@ -5744,7 +5828,7 @@ import {
5744
5828
  loadJwt, ensureFreshJwt, detectRepo, loadConfig, route, tag, localGrade,
5745
5829
  parseVerdict, dispatchCapture, appendSessionAction, readSessionLog, compressSessionLog, postWithRetry, readStdin, log,
5746
5830
  outputJson, outputEmpty, setupCursorHookSignals, installHookWatchdog, isPlanTool, hookSessionId, GATEWAY_URL,
5747
- filterRules, graderUnavailableMessage, resolveTranscriptPath, isCursorInvokingCcHook,
5831
+ filterRules, graderUnavailableMessage, logGraderUnavailable, resolveTranscriptPath, isCursorInvokingCcHook,
5748
5832
  loadSynkroFile, effectiveGraderPool, synkroFilePresent, noSynkroSkipMessage,
5749
5833
  } from './_synkro-common.ts';
5750
5834
  import { existsSync, readFileSync, writeFileSync, readdirSync, statSync } from 'node:fs';
@@ -5864,7 +5948,9 @@ async function main() {
5864
5948
  try {
5865
5949
  gradeResp = await localGrade('plan', graderPrompt, undefined, graderPool);
5866
5950
  } catch (err) {
5867
- outputJson({ systemMessage: tagStr + ' ' + graderUnavailableMessage('planReview', 'plan', (err as Error).message || String(err), graderPool) });
5951
+ const errMsg = (err as Error).message || String(err);
5952
+ logGraderUnavailable('planReview', 'plan', errMsg, graderPrompt, { sessionId, repo: gitRepo });
5953
+ outputJson({ systemMessage: tagStr + ' ' + graderUnavailableMessage('planReview', 'plan', errMsg, graderPool) });
5868
5954
  return;
5869
5955
  }
5870
5956
 
@@ -6478,7 +6564,7 @@ async function main() {
6478
6564
  try {
6479
6565
  gradeResp = await localGrade('bash', graderPrompt, CURSOR_GRADE_TIMEOUT_MS, graderPool);
6480
6566
  } catch (e) {
6481
- logGraderUnavailable('bashGuard', command.slice(0, 200), (e as Error).message || String(e));
6567
+ logGraderUnavailable('bashGuard', command.slice(0, 200), (e as Error).message || String(e), graderPrompt, { sessionId, repo });
6482
6568
  if (scanConcern) {
6483
6569
  // Grader unavailable to run the consent check \u2014 fail closed on a
6484
6570
  // scanner-flagged install (ask-mode so the user can still consent).
@@ -10753,7 +10839,7 @@ function writeConfigEnv(opts) {
10753
10839
  `SYNKRO_CREDENTIALS_PATH=${shellQuoteSingle(credsPath)}`,
10754
10840
  `SYNKRO_TIER=${shellQuoteSingle(safeTier)}`,
10755
10841
  `SYNKRO_INFERENCE=${shellQuoteSingle(safeInference)}`,
10756
- `SYNKRO_VERSION=${shellQuoteSingle("1.6.82")}`
10842
+ `SYNKRO_VERSION=${shellQuoteSingle("1.6.83")}`
10757
10843
  ];
10758
10844
  if (safeSynkroBin) lines.push(`SYNKRO_CLI_BIN=${shellQuoteSingle(safeSynkroBin)}`);
10759
10845
  if (safeUserId) lines.push(`SYNKRO_USER_ID=${shellQuoteSingle(safeUserId)}`);
@@ -14614,7 +14700,7 @@ var args = process.argv.slice(2);
14614
14700
  var cmd = args[0] || "";
14615
14701
  var subArgs = args.slice(1);
14616
14702
  function printVersion() {
14617
- console.log("1.6.82");
14703
+ console.log("1.6.83");
14618
14704
  }
14619
14705
  function printHelp2() {
14620
14706
  console.log(`Synkro CLI \u2014 runtime safety for AI coding agents