@synkro-sh/cli 1.6.82 → 1.6.84

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
@@ -1570,7 +1570,12 @@ export async function loadConfig(jwt: string, query?: string): Promise<HookConfi
1570
1570
  .filter((e: any) => e && typeof e.path === 'string')
1571
1571
  .map((e: any) => ({ path: e.path, cwe_id: e.cwe_id || '' }));
1572
1572
  }
1573
- return config;
1573
+ // Trust the local result as authoritative ONLY when it actually carried a
1574
+ // policy (or we're in local storage mode, where no cloud fallback exists).
1575
+ // A transient empty local read (policy:null) must NOT short-circuit the
1576
+ // gateway fallback \u2014 otherwise the grade runs ruleless and the tag flaps to
1577
+ // "[\u2026:all:\u2026] no org rules to evaluate" on a repo that DOES have rules.
1578
+ if (policy || isLocalStorageMode()) return config;
1574
1579
  }
1575
1580
  } catch {}
1576
1581
 
@@ -1744,6 +1749,26 @@ async function hostedGrade(role: GradeRole, prompt: string, jwt: string, timeout
1744
1749
  return String(data.result || '');
1745
1750
  }
1746
1751
 
1752
+ // Option B: when a cloud grade fails (esp. a timeout), the REAL reason lives
1753
+ // inside the container, not on the wire \u2014 the client only ever sees "timed out".
1754
+ // Pull the org's per-worker claude/cursor sick reasons + log tails from the
1755
+ // hosted /debug endpoint so the diagnostic records WHY the grade actually died
1756
+ // (e.g. a claude 401 inside the worker). Best-effort + tightly timed; null if
1757
+ // the container is itself unreachable.
1758
+ async function fetchContainerSickReason(jwt: string, timeoutMs = 2500): Promise<string | null> {
1759
+ try {
1760
+ const r = await fetch(CONTAINERS_URL + '/debug', {
1761
+ headers: { Authorization: 'Bearer ' + jwt },
1762
+ signal: AbortSignal.timeout(timeoutMs),
1763
+ });
1764
+ if (!r.ok) return 'debug ' + r.status + ': ' + (await r.text().catch(() => '')).slice(0, 400);
1765
+ const data = await r.json().catch(() => null);
1766
+ return data ? JSON.stringify(data).slice(0, 8000) : null;
1767
+ } catch {
1768
+ return null;
1769
+ }
1770
+ }
1771
+
1747
1772
  // Cloud transcript capture \u2014 the cloud mirror of the local /api/conversation-sync
1748
1773
  // + /api/session-action telemetry. Posts to the API /api/v1/cli/sync-transcripts
1749
1774
  // (the org is derived from the login JWT, same one cloud grading uses). Best-effort:
@@ -3007,7 +3032,7 @@ export async function syncConversationTranscript(
3007
3032
  const b = c as Record<string, unknown>;
3008
3033
  return {
3009
3034
  name: b.name,
3010
- input: JSON.stringify(b.input || b.arguments || {}).slice(0, 500),
3035
+ input: JSON.stringify(b.input || b.arguments || {}).slice(0, 20000),
3011
3036
  id: b.id,
3012
3037
  };
3013
3038
  });
@@ -3749,35 +3774,99 @@ export function isGraderNotConfigured(errorMessage: string): boolean {
3749
3774
  return lower.includes('no worker') || lower.includes('not configured') || lower.includes('agent_kind') || lower.includes('no pool');
3750
3775
  }
3751
3776
 
3752
- export function graderUnavailableMessage(hook: string, target: string, errorMessage: string, agentKind: AgentKind = 'claude_code'): string {
3777
+ // Categorize a grader failure into a stable machine category + a human "why"
3778
+ // (with a fix hint). Shared by the terminal message and the diagnostic log so
3779
+ // the two never disagree about the reason.
3780
+ export function classifyGraderError(errorMessage: string): { category: string; why: string } {
3781
+ const e = (errorMessage || '').toLowerCase();
3782
+ const snippet = (errorMessage || '').replace(/\\s+/g, ' ').trim().slice(0, 160);
3753
3783
  if (errorMessage === 'SYNKRO_CHANNEL_DOWN') {
3754
- return hook + ' ' + target + ' \u2192 local grader unavailable (container not running), skipped';
3784
+ return { category: 'channel_down', why: 'grader container not running (run \`synkro status\`)' };
3755
3785
  }
3756
3786
  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\`.';
3787
+ return { category: 'not_configured', why: 'grader not configured for this agent \u2014 add it to synkro.toml and run \`synkro install\`' };
3788
+ }
3789
+ if (/tim(e|ed)\\s*out|aborted|operation was aborted/.test(e)) {
3790
+ return { category: 'timeout', why: 'grade timed out (45s) \u2014 grader cold/slow, or its Claude token is invalid (re-run \`synkro install\`)' };
3791
+ }
3792
+ if (/\\b401\\b|unauthorized|token expired|invalid token|\\b403\\b|forbidden/.test(e)) {
3793
+ return { category: 'auth', why: 'auth rejected \u2014 session token expired (re-run \`synkro login\`, or \`synkro install\` if the grader Claude token is stale)' };
3794
+ }
3795
+ if (/econnrefused|enotfound|eai_again|fetch failed|socket|econnreset|getaddrinfo|network/.test(e)) {
3796
+ return { category: 'unreachable', why: 'grader unreachable \u2014 network or container down' };
3759
3797
  }
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.
3798
+ if (/\\b5\\d\\d\\b/.test(e)) {
3799
+ return { category: 'server_error', why: 'grader server error: ' + snippet };
3800
+ }
3801
+ return { category: 'unknown', why: snippet || 'unknown error' };
3802
+ }
3803
+
3804
+ export function graderUnavailableMessage(hook: string, target: string, errorMessage: string, agentKind: AgentKind = 'claude_code'): string {
3805
+ const base = hook + ' ' + target + ' \u2192 ';
3806
+ if (isGraderNotConfigured(errorMessage)) {
3807
+ const agent = agentKind === 'cursor' ? 'Cursor' : 'Claude Code';
3808
+ return base + 'local grader not configured for ' + agent + '. Add this agent to your synkro.toml file and run \`synkro install\`.';
3809
+ }
3810
+ // Always surface WHY (not just "unavailable"), categorized, with a fix hint \u2014
3811
+ // the full raw error + the exact graded prompt are in grader-unavailable.log.
3812
+ const where = process.env.SYNKRO_DEPLOY_LOCATION === 'cloud' ? 'cloud grader' : 'local grader';
3813
+ const { why } = classifyGraderError(errorMessage);
3814
+ return base + where + ' unavailable \u2014 ' + why + '; skipped (full detail: ~/.synkro/grader-unavailable.log)';
3815
+ }
3816
+
3817
+ // Diagnostic record for a skipped grade \u2014 the categorized reason AND the EXACT
3818
+ // prompt that was sent for inference (gradeInput), so you can see WHAT was being
3819
+ // graded when it failed. Routing differs by mode:
3820
+ // \u2022 CLOUD \u2192 ship to Timescale via /api/v1/cli/grader-unavailable (central, so
3821
+ // we can debug customer failures), enriched on timeout/outage with the
3822
+ // container's own claude error (option B). NOT written to the user's machine.
3823
+ // \u2022 LOCAL \u2192 the on-device ~/.synkro/grader-unavailable.log file.
3824
+ // Fire-and-forget in cloud mode: callers don't await \u2014 the pending fetch keeps
3825
+ // the hook process alive until it settles (or the watchdog ends the run).
3826
+ export async function logGraderUnavailable(
3827
+ hook: string, target: string, errorMessage: string,
3828
+ gradeInput?: string, ctx?: { sessionId?: string; repo?: string },
3829
+ ): Promise<void> {
3830
+ const { category, why } = classifyGraderError(errorMessage);
3831
+
3763
3832
  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';
3833
+ try {
3834
+ const jwt = loadJwt();
3835
+ if (!jwt) return;
3836
+ // Only chase the container's internal reason for failures where it adds
3837
+ // signal (a timeout/outage hides the real claude error); skip it for fast,
3838
+ // already-explanatory failures like auth.
3839
+ let sickReason: string | null = null;
3840
+ if (category === 'timeout' || category === 'unreachable' || category === 'server_error') {
3841
+ sickReason = await fetchContainerSickReason(jwt);
3842
+ }
3843
+ await fetch(GATEWAY_URL + '/api/v1/cli/grader-unavailable', {
3844
+ method: 'POST',
3845
+ headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + jwt },
3846
+ body: JSON.stringify({
3847
+ hook, target, category, why,
3848
+ error: (errorMessage || '').slice(0, 2000),
3849
+ grade_input: gradeInput ? String(gradeInput).slice(0, 20000) : null,
3850
+ sick_reason: sickReason,
3851
+ session_id: ctx?.sessionId || null,
3852
+ repo: ctx?.repo || null,
3853
+ }),
3854
+ signal: AbortSignal.timeout(3000),
3855
+ }).catch(() => {});
3856
+ } catch {
3857
+ // best-effort \u2014 never let diagnostics cascade into a hook failure
3767
3858
  }
3768
- return hook + ' ' + target + ' \u2192 cloud grader unavailable, skipped';
3859
+ return;
3769
3860
  }
3770
- return hook + ' ' + target + ' \u2192 local grader unavailable, skipped';
3771
- }
3772
3861
 
3773
- export function logGraderUnavailable(hook: string, target: string, errorMessage: string): void {
3862
+ // Local mode: on-device diagnostic log.
3774
3863
  try {
3775
- const entry = {
3776
- ts: new Date().toISOString(),
3777
- hook,
3778
- target,
3779
- error: errorMessage.slice(0, 500),
3864
+ const entry: Record<string, unknown> = {
3865
+ ts: new Date().toISOString(), mode: 'local',
3866
+ hook, target, category, why,
3867
+ error: (errorMessage || '').slice(0, 1000),
3780
3868
  };
3869
+ if (gradeInput) entry.gradeInput = String(gradeInput).slice(0, 20000);
3781
3870
  appendFileSync(UNAVAIL_LOG, JSON.stringify(entry) + '\\n', 'utf-8');
3782
3871
  } catch {
3783
3872
  // best-effort \u2014 never let logging failure cascade into a hook failure
@@ -4037,7 +4126,7 @@ async function main() {
4037
4126
  }
4038
4127
  } catch (err) {
4039
4128
  const errMsg = (err as Error).message || String(err);
4040
- logGraderUnavailable('editGuard', fileShort, errMsg);
4129
+ logGraderUnavailable('editGuard', fileShort, errMsg, buildCombined(proposed), { sessionId, repo: gitRepo });
4041
4130
  outputJson({ systemMessage: tagStr + ' ' + graderUnavailableMessage('editGuard', fileShort, errMsg, graderPool) });
4042
4131
  return;
4043
4132
  }
@@ -4111,7 +4200,7 @@ async function main() {
4111
4200
  gradeResp = await localGrade('edit', graderPrompt, undefined, graderPool);
4112
4201
  } catch (err) {
4113
4202
  const errMsg = (err as Error).message || String(err);
4114
- logGraderUnavailable('editGuard', fileShort, errMsg);
4203
+ logGraderUnavailable('editGuard', fileShort, errMsg, graderPrompt, { sessionId, repo: gitRepo });
4115
4204
  outputJson({ systemMessage: tagStr + ' ' + graderUnavailableMessage('editGuard', fileShort, errMsg, graderPool) });
4116
4205
  return;
4117
4206
  }
@@ -4678,7 +4767,7 @@ async function main() {
4678
4767
  gradeResponses = [resp1, resp2];
4679
4768
  } catch (gradeErr: any) {
4680
4769
  const reason = gradeErr?.message || String(gradeErr);
4681
- logGraderUnavailable('cweGuard', fileShort, reason);
4770
+ logGraderUnavailable('cweGuard', fileShort, reason, buildCwePrompt(cweContent), { sessionId, repo: gitRepo });
4682
4771
  outputJson({ systemMessage: cweTag + ' ' + graderUnavailableMessage('cweGuard', fileShort, reason, graderPool) });
4683
4772
  return;
4684
4773
  }
@@ -4687,7 +4776,7 @@ async function main() {
4687
4776
  gradeResponses = [await localGradeCwe(buildCwePrompt(cweContent), graderPool)];
4688
4777
  } catch (gradeErr: any) {
4689
4778
  const reason = gradeErr?.message || String(gradeErr);
4690
- logGraderUnavailable('cweGuard', fileShort, reason);
4779
+ logGraderUnavailable('cweGuard', fileShort, reason, buildCwePrompt(cweContent), { sessionId, repo: gitRepo });
4691
4780
  outputJson({ systemMessage: cweTag + ' ' + graderUnavailableMessage('cweGuard', fileShort, reason, graderPool) });
4692
4781
  return;
4693
4782
  }
@@ -5435,7 +5524,7 @@ async function main() {
5435
5524
  gradeResp = await localGrade('bash', graderPrompt, undefined, graderPool);
5436
5525
  } catch (err) {
5437
5526
  const errMsg = (err as Error).message || String(err);
5438
- logGraderUnavailable('bashGuard', toolInput.command?.slice(0, 200) || '', errMsg);
5527
+ logGraderUnavailable('bashGuard', toolInput.command?.slice(0, 200) || '', errMsg, graderPrompt, { sessionId, repo: gitRepo });
5439
5528
  if (scanConcern) {
5440
5529
  const ctx = scanBlockContext + ' Synkro flagged this install but the grader is unavailable to check consent — ask the user for explicit consent, then retry.';
5441
5530
  outputJson({
@@ -5653,7 +5742,7 @@ async function main() {
5653
5742
  gradeResp = await localGrade('bash', graderPrompt, undefined, graderPool);
5654
5743
  } catch (err) {
5655
5744
  const errMsg = (err as Error).message || String(err);
5656
- logGraderUnavailable('agentGuard', subagentType || (description || '').slice(0, 100), errMsg);
5745
+ logGraderUnavailable('agentGuard', subagentType || (description || '').slice(0, 100), errMsg, graderPrompt, { sessionId, repo: gitRepo });
5657
5746
  outputJson({ systemMessage: tagStr + ' ' + graderUnavailableMessage('agentGuard', subagentType || 'agent', errMsg, graderPool) });
5658
5747
  return;
5659
5748
  }
@@ -5744,7 +5833,7 @@ import {
5744
5833
  loadJwt, ensureFreshJwt, detectRepo, loadConfig, route, tag, localGrade,
5745
5834
  parseVerdict, dispatchCapture, appendSessionAction, readSessionLog, compressSessionLog, postWithRetry, readStdin, log,
5746
5835
  outputJson, outputEmpty, setupCursorHookSignals, installHookWatchdog, isPlanTool, hookSessionId, GATEWAY_URL,
5747
- filterRules, graderUnavailableMessage, resolveTranscriptPath, isCursorInvokingCcHook,
5836
+ filterRules, graderUnavailableMessage, logGraderUnavailable, resolveTranscriptPath, isCursorInvokingCcHook,
5748
5837
  loadSynkroFile, effectiveGraderPool, synkroFilePresent, noSynkroSkipMessage,
5749
5838
  } from './_synkro-common.ts';
5750
5839
  import { existsSync, readFileSync, writeFileSync, readdirSync, statSync } from 'node:fs';
@@ -5864,7 +5953,9 @@ async function main() {
5864
5953
  try {
5865
5954
  gradeResp = await localGrade('plan', graderPrompt, undefined, graderPool);
5866
5955
  } catch (err) {
5867
- outputJson({ systemMessage: tagStr + ' ' + graderUnavailableMessage('planReview', 'plan', (err as Error).message || String(err), graderPool) });
5956
+ const errMsg = (err as Error).message || String(err);
5957
+ logGraderUnavailable('planReview', 'plan', errMsg, graderPrompt, { sessionId, repo: gitRepo });
5958
+ outputJson({ systemMessage: tagStr + ' ' + graderUnavailableMessage('planReview', 'plan', errMsg, graderPool) });
5868
5959
  return;
5869
5960
  }
5870
5961
 
@@ -6478,7 +6569,7 @@ async function main() {
6478
6569
  try {
6479
6570
  gradeResp = await localGrade('bash', graderPrompt, CURSOR_GRADE_TIMEOUT_MS, graderPool);
6480
6571
  } catch (e) {
6481
- logGraderUnavailable('bashGuard', command.slice(0, 200), (e as Error).message || String(e));
6572
+ logGraderUnavailable('bashGuard', command.slice(0, 200), (e as Error).message || String(e), graderPrompt, { sessionId, repo });
6482
6573
  if (scanConcern) {
6483
6574
  // Grader unavailable to run the consent check \u2014 fail closed on a
6484
6575
  // scanner-flagged install (ask-mode so the user can still consent).
@@ -10753,7 +10844,7 @@ function writeConfigEnv(opts) {
10753
10844
  `SYNKRO_CREDENTIALS_PATH=${shellQuoteSingle(credsPath)}`,
10754
10845
  `SYNKRO_TIER=${shellQuoteSingle(safeTier)}`,
10755
10846
  `SYNKRO_INFERENCE=${shellQuoteSingle(safeInference)}`,
10756
- `SYNKRO_VERSION=${shellQuoteSingle("1.6.82")}`
10847
+ `SYNKRO_VERSION=${shellQuoteSingle("1.6.84")}`
10757
10848
  ];
10758
10849
  if (safeSynkroBin) lines.push(`SYNKRO_CLI_BIN=${shellQuoteSingle(safeSynkroBin)}`);
10759
10850
  if (safeUserId) lines.push(`SYNKRO_USER_ID=${shellQuoteSingle(safeUserId)}`);
@@ -14213,7 +14304,7 @@ function parseSession(filePath, sessionId) {
14213
14304
  const msgObj = e.message;
14214
14305
  const text = extractText(msgObj?.content ?? e.content);
14215
14306
  const blocks = kind === "assistant" && Array.isArray(msgObj?.content) ? msgObj.content : [];
14216
- const toolCalls = blocks.filter((b) => b?.type === "tool_use").map((b) => ({ name: b.name, input: JSON.stringify(b.input || {}).slice(0, 500), id: b.id }));
14307
+ const toolCalls = blocks.filter((b) => b?.type === "tool_use").map((b) => ({ name: b.name, input: JSON.stringify(b.input || {}).slice(0, 2e4), id: b.id }));
14217
14308
  if (!text.trim() && toolCalls.length === 0) continue;
14218
14309
  const msg = {
14219
14310
  message_index: i,
@@ -14614,7 +14705,7 @@ var args = process.argv.slice(2);
14614
14705
  var cmd = args[0] || "";
14615
14706
  var subArgs = args.slice(1);
14616
14707
  function printVersion() {
14617
- console.log("1.6.82");
14708
+ console.log("1.6.84");
14618
14709
  }
14619
14710
  function printHelp2() {
14620
14711
  console.log(`Synkro CLI \u2014 runtime safety for AI coding agents