@synkro-sh/cli 1.6.72 → 1.6.74

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
@@ -145,7 +145,7 @@ function installCCHooks(settingsPath, config) {
145
145
  {
146
146
  type: "command",
147
147
  command: config.bashJudgeScriptPath,
148
- timeout: 30
148
+ timeout: 50
149
149
  }
150
150
  ],
151
151
  [SYNKRO_MARKER]: true
@@ -156,12 +156,12 @@ function installCCHooks(settingsPath, config) {
156
156
  {
157
157
  type: "command",
158
158
  command: config.editPrecheckScriptPath,
159
- timeout: 30
159
+ timeout: 50
160
160
  },
161
161
  {
162
162
  type: "command",
163
163
  command: config.cwePrecheckScriptPath,
164
- timeout: 30
164
+ timeout: 50
165
165
  },
166
166
  {
167
167
  type: "command",
@@ -177,7 +177,7 @@ function installCCHooks(settingsPath, config) {
177
177
  {
178
178
  type: "command",
179
179
  command: config.agentJudgeScriptPath,
180
- timeout: 30
180
+ timeout: 50
181
181
  }
182
182
  ],
183
183
  [SYNKRO_MARKER]: true
@@ -188,7 +188,7 @@ function installCCHooks(settingsPath, config) {
188
188
  {
189
189
  type: "command",
190
190
  command: config.planJudgeScriptPath,
191
- timeout: 45
191
+ timeout: 50
192
192
  }
193
193
  ],
194
194
  [SYNKRO_MARKER]: true
@@ -200,7 +200,7 @@ function installCCHooks(settingsPath, config) {
200
200
  {
201
201
  type: "command",
202
202
  command: config.mcpGateScriptPath,
203
- timeout: 30
203
+ timeout: 50
204
204
  }
205
205
  ],
206
206
  [SYNKRO_MARKER]: true
@@ -1272,7 +1272,7 @@ export function findGitRoot(cwd: string): string {
1272
1272
  export interface SynkroFileConfig {
1273
1273
  version: number;
1274
1274
  harness: ('claude-code' | 'cursor')[];
1275
- grader: { pool: 'auto' | 'claude' | 'cursor'; mode?: string };
1275
+ grader: { pool: 'auto' | 'claude' | 'cursor'; mode?: string; combinedEditGrade?: boolean };
1276
1276
  workers: { claude?: number; cursor?: number };
1277
1277
  ruleset: string;
1278
1278
  skills: string[];
@@ -1379,6 +1379,7 @@ export function loadSynkroFile(cwd?: string): SynkroFileConfig {
1379
1379
  grader: {
1380
1380
  pool: normalizePoolToken(parsed.grader?.pool),
1381
1381
  mode: ['local', 'byok'].includes(parsed.grader?.mode) ? parsed.grader.mode : undefined,
1382
+ combinedEditGrade: parsed.grader?.combined_edit_grade === true,
1382
1383
  },
1383
1384
  workers: {
1384
1385
  // Counts may live under the grader block (unified form) or the legacy
@@ -1632,7 +1633,7 @@ export function tag(rt: string, config: HookConfig, grader?: string): string {
1632
1633
 
1633
1634
  // \u2500\u2500\u2500 Local Grading (direct channel call) \u2500\u2500\u2500
1634
1635
 
1635
- type GradeRole = 'grade-edit' | 'grade-bash' | 'grade-plan' | 'grade-cwe';
1636
+ type GradeRole = 'grade-edit' | 'grade-bash' | 'grade-plan' | 'grade-cwe' | 'grade-edit-cwe';
1636
1637
 
1637
1638
  // Which coding agent fired this grade. The dispatcher routes grades to a
1638
1639
  // worker pool of the matching kind so a Cursor grade is judged by Cursor and
@@ -1642,8 +1643,21 @@ export type AgentKind = 'claude_code' | 'cursor';
1642
1643
 
1643
1644
  const ROLE_MAP: Record<string, GradeRole> = {
1644
1645
  edit: 'grade-edit', bash: 'grade-bash', plan: 'grade-plan', cwe: 'grade-cwe',
1646
+ 'edit-cwe': 'grade-edit-cwe',
1645
1647
  };
1646
1648
 
1649
+ // Combined edit-time grade (org rules + CWE in one inference). OFF by default;
1650
+ // flipped on by env var or synkro.toml [grader] combined_edit_grade = true.
1651
+ // When on: the edit-precheck hook runs ONE grade-edit-cwe call and the
1652
+ // cwe-precheck hook no-ops (the combined call owns CWE) \u2014 halving inferences
1653
+ // per edit. See GRADER_PRIMER_EDIT_CWE.
1654
+ export function combinedEditGradeEnabled(synkroFile?: SynkroFileConfig): boolean {
1655
+ const env = process.env.SYNKRO_COMBINED_EDIT_GRADE;
1656
+ if (env === '1' || env === 'true') return true;
1657
+ if (env === '0' || env === 'false') return false;
1658
+ return synkroFile?.grader?.combinedEditGrade === true;
1659
+ }
1660
+
1647
1661
  async function channelGrade(role: GradeRole, prompt: string, _jwt: string, port: number, timeoutMs = 30000, agentKind: AgentKind = 'claude_code'): Promise<string> {
1648
1662
  const body = JSON.stringify({ role, payload: prompt, content: prompt, agent_kind: agentKind });
1649
1663
 
@@ -1689,7 +1703,12 @@ async function cloudGrade(surface: string, prompt: string, jwt: string, timeoutM
1689
1703
  // JWT (the Worker derives the org from the token and routes to that org's
1690
1704
  // container). Any non-2xx throws so the caller's catch fails open.
1691
1705
  const CONTAINERS_URL = process.env.SYNKRO_CONTAINERS_URL || 'https://containers.synkro.sh';
1692
- async function hostedGrade(role: GradeRole, prompt: string, jwt: string, timeoutMs = 24000, agentKind: AgentKind = 'claude_code'): Promise<string> {
1706
+ // Cloud grades cross the network and can hit a cold/queued worker, so they get a
1707
+ // longer leash than on-device channel grades. The hook chain is sized around it:
1708
+ // grade abort (45s) < watchdog (48s) < CC hook budget (50s) \u2014 the grade aborts
1709
+ // FIRST so the caller's catch fails open cleanly before CC ever force-kills us.
1710
+ const CLOUD_GRADE_TIMEOUT_MS = 45000;
1711
+ async function hostedGrade(role: GradeRole, prompt: string, jwt: string, timeoutMs = CLOUD_GRADE_TIMEOUT_MS, agentKind: AgentKind = 'claude_code'): Promise<string> {
1693
1712
  const resp = await fetch(CONTAINERS_URL + '/grade/submit', {
1694
1713
  method: 'POST',
1695
1714
  headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + jwt },
@@ -1705,18 +1724,20 @@ async function hostedGrade(role: GradeRole, prompt: string, jwt: string, timeout
1705
1724
  return String(data.result || '');
1706
1725
  }
1707
1726
 
1708
- // Default 24s \u2014 MUST stay below the CC hook timeout (30s for edit/bash/cwe in
1709
- // ccHookConfig.ts) so the AbortSignal fires and the caller's catch fails open
1710
- // cleanly. If this matches or exceeds the CC budget, CC force-kills the bun
1711
- // process mid-fetch (exit 142 / SIGALRM) and surfaces the in-flight grader
1712
- // prompt as a hook error instead of a clean pass.
1727
+ // Local-channel default 24s \u2014 MUST stay below the CC hook timeout (50s for
1728
+ // edit/bash/cwe in ccHookConfig.ts) so the AbortSignal fires and the caller's
1729
+ // catch fails open cleanly. A hung on-device worker should fail open fast; only
1730
+ // cloud grades (network + cold workers) get the longer 45s leash below.
1713
1731
  export async function localGrade(surface: string, prompt: string, timeoutMs = 24000, agentKind: AgentKind = 'claude_code'): Promise<string> {
1714
1732
  const jwt = loadJwt();
1715
1733
  if (!jwt) throw new Error('NO_JWT');
1716
1734
  // Cloud-container: the grader runs on Cloudflare Containers \u2014 route to the
1717
1735
  // hosted ingress instead of the local channel. Same prompt + parseVerdict.
1736
+ // Cloud grades get the full 45s (not the local 24s): the round-trip + a cold
1737
+ // worker routinely needs >24s, which is exactly what was timing out and
1738
+ // failing open on big bash prompts.
1718
1739
  if (process.env.SYNKRO_DEPLOY_LOCATION === 'cloud') {
1719
- return hostedGrade(ROLE_MAP[surface] || 'grade-edit', prompt, jwt, timeoutMs, agentKind);
1740
+ return hostedGrade(ROLE_MAP[surface] || 'grade-edit', prompt, jwt, CLOUD_GRADE_TIMEOUT_MS, agentKind);
1720
1741
  }
1721
1742
  // BYOK grading mode routes the grade through an LLM API instead of the
1722
1743
  // on-device channel worker pool. The grader prompt + parseVerdict are shared.
@@ -1727,14 +1748,14 @@ export async function localGrade(surface: string, prompt: string, timeoutMs = 24
1727
1748
  return channelGrade(ROLE_MAP[surface] || 'grade-edit', prompt, jwt, 18929, timeoutMs, agentKind);
1728
1749
  }
1729
1750
 
1730
- // Default 24s \u2014 was 45000, which EXCEEDED the CC cwe hook timeout (30s) and
1731
- // guaranteed a force-kill on any slow grade. Two cwe chunks grade in parallel
1732
- // within the same CC budget, so each must finish well under it.
1751
+ // Local-channel default 24s. Two cwe chunks grade in parallel within the same CC
1752
+ // budget (now 50s), so each must finish under it. Cloud cwe grades take the 45s
1753
+ // hosted leash (parallel chunks \u2192 wall-clock ~45s, still inside the 50s budget).
1733
1754
  export async function localGradeCwe(prompt: string, agentKind: AgentKind = 'claude_code', timeoutMs = 24000): Promise<string> {
1734
1755
  const jwt = loadJwt();
1735
1756
  if (!jwt) throw new Error('NO_JWT');
1736
1757
  if (process.env.SYNKRO_DEPLOY_LOCATION === 'cloud') {
1737
- return hostedGrade('grade-cwe', prompt, jwt, timeoutMs, agentKind);
1758
+ return hostedGrade('grade-cwe', prompt, jwt, CLOUD_GRADE_TIMEOUT_MS, agentKind);
1738
1759
  }
1739
1760
  return channelGrade('grade-cwe', prompt, jwt, 18930, timeoutMs, agentKind);
1740
1761
  }
@@ -2181,6 +2202,67 @@ export function parseVerdict(resp: string): Verdict {
2181
2202
  return verdict;
2182
2203
  }
2183
2204
 
2205
+ export interface CombinedFinding {
2206
+ id: string;
2207
+ reason: string;
2208
+ suggestedFix: string;
2209
+ severity: string;
2210
+ ruleMode: string;
2211
+ codeSnippet: string;
2212
+ }
2213
+
2214
+ // Splits a grade-edit-cwe verdict (one <synkro-verdict> whose <violation> blocks
2215
+ // MIX org-rule ids and cwe-### ids) into (a) a rules Verdict the existing rule
2216
+ // enforcement consumes verbatim and (b) a list of CWE findings for the CWE block
2217
+ // path. A block whose rule_id starts with "cwe-" is a CWE finding; anything else
2218
+ // is an org-rule violation (org rules take precedence). Org-rule verdict is ok
2219
+ // UNLESS a non-cwe violation is present \u2014 so a CWE-only failure still passes the
2220
+ // rule gate while blocking on CWE.
2221
+ export function parseCombinedVerdict(resp: string): { ruleVerdict: Verdict; cweFindings: CombinedFinding[] } {
2222
+ const base = parseVerdict(resp);
2223
+ const ruleVerdict: Verdict = { ok: true, reason: '', suggestedFix: '', ruleId: '', ruleMode: '', severity: 'low', category: 'clean' };
2224
+ const cweFindings: CombinedFinding[] = [];
2225
+ const blocks = resp.match(/<violation>[\\s\\S]*?<\\/violation>/g) || [];
2226
+ let firstRule: CombinedFinding | null = null;
2227
+ for (const b of blocks) {
2228
+ const idRaw = (b.match(/<rule_id>([^<]+)<\\/rule_id>/) || [])[1];
2229
+ if (!idRaw) continue;
2230
+ const id = idRaw.trim();
2231
+ const finding: CombinedFinding = {
2232
+ id,
2233
+ reason: ((b.match(/<reason>([\\s\\S]*?)<\\/reason>/) || [])[1] || '').trim(),
2234
+ suggestedFix: ((b.match(/<suggested_fix>([\\s\\S]*?)<\\/suggested_fix>/) || [])[1] || '').trim(),
2235
+ severity: ((b.match(/<severity>([^<]+)<\\/severity>/) || [])[1] || '').trim(),
2236
+ ruleMode: ((b.match(/<rule_mode>([^<]+)<\\/rule_mode>/) || [])[1] || '').trim(),
2237
+ codeSnippet: ((b.match(/<code_snippet>([\\s\\S]*?)<\\/code_snippet>/) || [])[1] || '').replace(/^\\n+|\\n+$/g, ''),
2238
+ };
2239
+ if (/^cwe-/i.test(id)) {
2240
+ finding.id = id.replace(/^cwe-/i, 'CWE-');
2241
+ if (!cweFindings.some(f => f.id === finding.id)) cweFindings.push(finding);
2242
+ } else if (!firstRule) {
2243
+ firstRule = finding;
2244
+ }
2245
+ }
2246
+ if (firstRule) {
2247
+ ruleVerdict.ok = false;
2248
+ ruleVerdict.ruleId = firstRule.id;
2249
+ ruleVerdict.reason = firstRule.reason;
2250
+ ruleVerdict.suggestedFix = firstRule.suggestedFix;
2251
+ ruleVerdict.ruleMode = firstRule.ruleMode;
2252
+ ruleVerdict.severity = firstRule.severity || 'high';
2253
+ ruleVerdict.category = base.category || 'uncategorized';
2254
+ } else if (base.ok === false && base.ruleId && !/^cwe-/i.test(base.ruleId)) {
2255
+ // No <violation> blocks but a top-level rule_id failure (defensive).
2256
+ ruleVerdict.ok = false;
2257
+ ruleVerdict.ruleId = base.ruleId;
2258
+ ruleVerdict.reason = base.reason;
2259
+ ruleVerdict.suggestedFix = base.suggestedFix;
2260
+ ruleVerdict.ruleMode = base.ruleMode;
2261
+ ruleVerdict.severity = base.severity || 'high';
2262
+ }
2263
+ return { ruleVerdict, cweFindings };
2264
+ }
2265
+
2184
2266
  // \u2500\u2500\u2500 Telemetry Dispatch \u2500\u2500\u2500
2185
2267
 
2186
2268
  // Gated cloud telemetry POST \u2014 fires only when storage mode is 'cloud'. The
@@ -3540,6 +3622,16 @@ export function graderUnavailableMessage(hook: string, target: string, errorMess
3540
3622
  const agent = agentKind === 'cursor' ? 'Cursor' : 'Claude Code';
3541
3623
  return hook + ' ' + target + ' \u2192 local grader not configured for ' + agent + '. Add this agent to your synkro.toml file and run \`synkro install\`.';
3542
3624
  }
3625
+ // In cloud-container mode the grade ran against the hosted ingress, NOT a local
3626
+ // worker \u2014 say so, and distinguish a timeout (slow/cold grade) from a true
3627
+ // outage so "local grader unavailable" stops masquerading as a dead grader.
3628
+ if (process.env.SYNKRO_DEPLOY_LOCATION === 'cloud') {
3629
+ const isTimeout = /tim(e|ed)\\s*out|aborted|the operation was aborted/i.test(errorMessage);
3630
+ if (isTimeout) {
3631
+ return hook + ' ' + target + ' \u2192 cloud grade timed out (45s), skipped';
3632
+ }
3633
+ return hook + ' ' + target + ' \u2192 cloud grader unavailable, skipped';
3634
+ }
3543
3635
  return hook + ' ' + target + ' \u2192 local grader unavailable, skipped';
3544
3636
  }
3545
3637
 
@@ -3619,7 +3711,7 @@ export function outputEmpty(): void {
3619
3711
  EDIT_PRECHECK_TS = `#!/usr/bin/env bun
3620
3712
  import {
3621
3713
  loadJwt, ensureFreshJwt, detectRepo, loadConfig, route, tag, localGrade,
3622
- parseVerdict, dispatchCapture, ruleMode, reconstructContent, isPathUnder, postWithRetry,
3714
+ parseVerdict, parseCombinedVerdict, combinedEditGradeEnabled, dispatchCapture, dispatchFinding, ruleMode, reconstructContent, isPathUnder, postWithRetry,
3623
3715
  readStdin, extractTranscript, readLastPrompt, findNearestDeps, filePathFromToolInput,
3624
3716
  appendSessionAction, readSessionLog, compressSessionLog, log,
3625
3717
  outputJson, outputEmpty, setupCursorHookSignals, installHookWatchdog, isEditTool, hookSessionId, GATEWAY_URL,
@@ -3635,7 +3727,7 @@ const agentKind = (process.env.SYNKRO_HOOK_FORMAT === 'cursor' || process.argv.i
3635
3727
 
3636
3728
  async function main() {
3637
3729
  setupCursorHookSignals();
3638
- installHookWatchdog(27000);
3730
+ installHookWatchdog(48000);
3639
3731
  try {
3640
3732
  const input = await readStdin();
3641
3733
  if (!input.trim()) { outputEmpty(); return; }
@@ -3769,6 +3861,90 @@ async function main() {
3769
3861
  '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.',
3770
3862
  ].join('\\n');
3771
3863
 
3864
+ // \u2500\u2500\u2500 Combined org-rules + CWE in ONE inference (SYNKRO_COMBINED_EDIT_GRADE) \u2500\u2500\u2500
3865
+ // Self-contained early-return branch \u2014 the default two-grade path below is
3866
+ // left untouched. Appends the CWE rule set to the rules prompt, runs one
3867
+ // grade-edit-cwe call, then splits the verdict: org-rule violations drive
3868
+ // the same ask/fix enforcement; cwe-### violations drive a fix-mode block.
3869
+ // The cwe-precheck hook no-ops when this flag is on (see CWE_PRECHECK_TS).
3870
+ if (combinedEditGradeEnabled(synkroFile)) {
3871
+ const fileExt = (filePath.match(/\\.([^.\\/]+)$/) || ['', ''])[1].toLowerCase();
3872
+ let cweRules: any[] = [];
3873
+ try {
3874
+ const cr = await fetch(GATEWAY_URL + '/api/v1/cwe-rules?ext=' + encodeURIComponent(fileExt), {
3875
+ headers: { Authorization: 'Bearer ' + jwt },
3876
+ signal: AbortSignal.timeout(4000),
3877
+ });
3878
+ if (cr.ok) cweRules = ((await cr.json() as any) || {}).rules || [];
3879
+ } catch { /* CWE rules optional \u2014 rule grading still runs in the combined pass */ }
3880
+
3881
+ const combinedPrompt = graderPrompt
3882
+ + '\\n\\nCWE rules to ALSO check the proposed content against (emit cwe-### violations with a <code_snippet>): '
3883
+ + JSON.stringify(cweRules);
3884
+
3885
+ let cResp: string;
3886
+ try {
3887
+ cResp = await localGrade('edit-cwe', combinedPrompt, undefined, graderPool);
3888
+ } catch (err) {
3889
+ const errMsg = (err as Error).message || String(err);
3890
+ logGraderUnavailable('editGuard', fileShort, errMsg);
3891
+ outputJson({ systemMessage: tagStr + ' ' + graderUnavailableMessage('editGuard', fileShort, errMsg, graderPool) });
3892
+ return;
3893
+ }
3894
+
3895
+ const { ruleVerdict, cweFindings } = parseCombinedVerdict(cResp);
3896
+ const editContent = 'file=' + filePath + ' content=' + proposed.slice(0, 2000);
3897
+ const violatedRules = ruleVerdict.ruleId ? [ruleVerdict.ruleId] : [];
3898
+ const cweBlock = cweFindings.slice(0, 5)
3899
+ .map(f => '[' + f.id + '] ' + (f.reason || 'weakness') + (f.suggestedFix ? ' Fix: ' + f.suggestedFix : ''))
3900
+ .join('\\n');
3901
+ for (const f of cweFindings) {
3902
+ dispatchFinding(jwt, { session_id: sessionId, file_path: filePath, finding_type: 'cwe', finding_id: f.id, status: 'open' }, config.captureDepth);
3903
+ }
3904
+
3905
+ // Org-rule violation \u2192 ask/fix enforcement (CWE findings appended to the deny).
3906
+ if (!ruleVerdict.ok) {
3907
+ const mode = normalizeMode(ruleVerdict.ruleMode || ruleMode(ruleVerdict.ruleId, config.rules));
3908
+ const guardReason = (ruleVerdict.ruleId ? '(' + ruleVerdict.ruleId + ') ' : '') + (ruleVerdict.reason || 'policy violation');
3909
+ let denyReason = mode === 'fix'
3910
+ ? 'Guard: ' + guardReason + '\\nFix all issues before retrying. Do NOT ask the user to make the edit manually \u2014 resolve the violation in code yourself.'
3911
+ : 'Guard: ' + guardReason + '\\nAsk the user for explicit consent before retrying.';
3912
+ if (cweBlock) denyReason += '\\nAlso resolve these CWE weaknesses in code:\\n' + cweBlock;
3913
+ dispatchCapture(jwt, 'edit', 'block', ruleVerdict.severity || 'critical', ruleVerdict.category || 'security',
3914
+ toolName, gitRepo, sessionId, config.captureDepth, {
3915
+ command: editContent, reasoning: guardReason,
3916
+ rulesChecked: relevantRules, violatedRules,
3917
+ ccModel: captureModel, ...lineMetrics,
3918
+ });
3919
+ outputJson({
3920
+ systemMessage: tagStr + ' editGuard ' + fileShort + ' \u2192 blocked: ' + guardReason + (cweFindings.length ? ' (+' + cweFindings.length + ' CWE)' : ''),
3921
+ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: denyReason, additionalContext: denyReason },
3922
+ });
3923
+ return;
3924
+ }
3925
+
3926
+ // Rules clean but CWE weakness(es) \u2192 fix-mode block (resolve in code).
3927
+ if (cweFindings.length > 0) {
3928
+ const ctx = 'CWE: ' + cweBlock + '\\nFix all issues before retrying. Do NOT ask the user to make the edit manually \u2014 resolve the weakness in code yourself.';
3929
+ outputJson({
3930
+ systemMessage: tagStr + ' editGuard ' + fileShort + ' \u2192 blocked (' + cweFindings.map(f => f.id).join(', ') + ')',
3931
+ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: ctx, additionalContext: ctx },
3932
+ });
3933
+ return;
3934
+ }
3935
+
3936
+ // Both clean.
3937
+ dispatchCapture(jwt, 'edit', 'pass', 'clean', ruleVerdict.category || 'trivial_edit',
3938
+ toolName, gitRepo, sessionId, config.captureDepth, {
3939
+ command: editContent, reasoning: ruleVerdict.reason || 'no violations (rules + CWE)',
3940
+ rulesChecked: relevantRules, violatedRules: [],
3941
+ ccModel: captureModel, ...lineMetrics,
3942
+ });
3943
+ const cPassLine = tagStr + ' editGuard ' + fileShort + ' \u2192 pass: ' + (ruleVerdict.reason || 'clean (rules + CWE)');
3944
+ outputJson({ systemMessage: cPassLine, hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: 'Synkro combined edit judge (rules + CWE). ' + (ruleVerdict.reason || 'clean') } });
3945
+ return;
3946
+ }
3947
+
3772
3948
  let gradeResp: string;
3773
3949
  try {
3774
3950
  gradeResp = await localGrade('edit', graderPrompt, undefined, graderPool);
@@ -3890,7 +4066,7 @@ import {
3890
4066
  outputJson, outputEmpty, setupCursorHookSignals, installHookWatchdog, isEditTool, isShellTool, isCursorHookFormat,
3891
4067
  extractShellCodeWrites, hookSessionId, filePathFromToolInput, emitBlockScanFindings, cweSnippetLines, dispatchFinding, dispatchCapture, GATEWAY_URL,
3892
4068
  logGraderUnavailable, graderUnavailableMessage, resolveTranscriptPath, isCursorInvokingCcHook,
3893
- loadSynkroFile, effectiveGraderPool, synkroFilePresent, noSynkroSkipMessage,
4069
+ loadSynkroFile, effectiveGraderPool, synkroFilePresent, noSynkroSkipMessage, combinedEditGradeEnabled,
3894
4070
  } from './_synkro-common.ts';
3895
4071
  import { basename, extname, resolve, join, dirname } from 'node:path';
3896
4072
  import { readFileSync, readdirSync, existsSync } from 'node:fs';
@@ -4029,7 +4205,7 @@ function scanPackageCapabilities(pkgName: string, cwd: string): PackageCapabilit
4029
4205
 
4030
4206
  async function main() {
4031
4207
  setupCursorHookSignals();
4032
- installHookWatchdog(27000);
4208
+ installHookWatchdog(48000);
4033
4209
  try {
4034
4210
  const input = await readStdin();
4035
4211
  if (!input.trim()) { outputEmpty(); return; }
@@ -4118,6 +4294,11 @@ async function main() {
4118
4294
  const graderPool = effectiveGraderPool(synkroFile, agentKind);
4119
4295
  const rt = await cweRoute(config, synkroFile);
4120
4296
 
4297
+ // Combined-grade mode: the edit-precheck hook runs CWE inside its single
4298
+ // grade-edit-cwe call, so this standalone CWE scan must no-op — otherwise the
4299
+ // edit would be graded for CWE twice (defeating the inference-merge).
4300
+ if (combinedEditGradeEnabled(synkroFile)) { outputEmpty(); return; }
4301
+
4121
4302
  if (config.silent) {
4122
4303
  outputJson({ systemMessage: '[synkro:' + rt + ':cweScan:' + (graderPool === 'claude_code' ? 'claude' : graderPool) + '] skipped (silent mode)' });
4123
4304
  return;
@@ -4543,14 +4724,16 @@ async function main() {
4543
4724
  jwt = await ensureFreshJwt(jwt);
4544
4725
 
4545
4726
  const config = await loadConfig(jwt);
4546
- const rt = await route(config);
4547
4727
 
4548
4728
  if (config.silent) {
4549
- outputJson({ systemMessage: '[synkro:' + rt + ':cveScan] ' + fileShort + ' \u2192 skipped (silent mode)' });
4729
+ outputJson({ systemMessage: '[synkro:cveScan] ' + fileShort + ' \u2192 skipped (silent mode)' });
4550
4730
  return;
4551
4731
  }
4552
4732
 
4553
- const cveTag = '[synkro:' + rt + ':cveScan]';
4733
+ // CVE scanning has no local/cloud distinction from the user's view \u2014 it's an
4734
+ // OSV database lookup that always runs server-side \u2014 so the tag omits the
4735
+ // location segment that grading/CWE carry. Just [synkro:cveScan].
4736
+ const cveTag = '[synkro:cveScan]';
4554
4737
 
4555
4738
  // Reconstruct proposed content
4556
4739
  const proposed = reconstructContent(toolName, toolInput, filePath, cwd);
@@ -4650,7 +4833,7 @@ async function main() {
4650
4833
  finding_ids: violatedIds, severity: 'critical',
4651
4834
  repo: gitRepo || undefined,
4652
4835
  });
4653
- const tagStr = '[synkro:' + rt + ':pkgScan]';
4836
+ const tagStr = '[synkro:pkgScan]';
4654
4837
  const denyReason = tagStr + ' BLOCKED: ' + summary + '\\nDo not write this version. Pick a fixed/safe version instead.';
4655
4838
  outputJson({
4656
4839
  systemMessage: denyReason,
@@ -4942,7 +5125,7 @@ function isDuplicate(command: string, sessionId: string): boolean {
4942
5125
 
4943
5126
  async function main() {
4944
5127
  setupCursorHookSignals();
4945
- installHookWatchdog(27000);
5128
+ installHookWatchdog(48000);
4946
5129
  try {
4947
5130
  const input = await readStdin();
4948
5131
  if (!input.trim()) { outputEmpty(); return; }
@@ -5214,7 +5397,7 @@ const agentKind = (process.env.SYNKRO_HOOK_FORMAT === 'cursor' || process.argv.i
5214
5397
 
5215
5398
  async function main() {
5216
5399
  setupCursorHookSignals();
5217
- installHookWatchdog(27000);
5400
+ installHookWatchdog(48000);
5218
5401
  try {
5219
5402
  const input = await readStdin();
5220
5403
  if (!input.trim()) { outputEmpty(); return; }
@@ -5451,7 +5634,7 @@ function appendReviewToPlan(planFile: string, verdict: string): void {
5451
5634
 
5452
5635
  async function main() {
5453
5636
  setupCursorHookSignals();
5454
- installHookWatchdog(42000);
5637
+ installHookWatchdog(48000);
5455
5638
  try {
5456
5639
  const input = await readStdin();
5457
5640
  if (!input.trim()) { outputEmpty(); return; }
@@ -10386,7 +10569,7 @@ function writeConfigEnv(opts) {
10386
10569
  `SYNKRO_CREDENTIALS_PATH=${shellQuoteSingle(credsPath)}`,
10387
10570
  `SYNKRO_TIER=${shellQuoteSingle(safeTier)}`,
10388
10571
  `SYNKRO_INFERENCE=${shellQuoteSingle(safeInference)}`,
10389
- `SYNKRO_VERSION=${shellQuoteSingle("1.6.72")}`
10572
+ `SYNKRO_VERSION=${shellQuoteSingle("1.6.74")}`
10390
10573
  ];
10391
10574
  if (safeSynkroBin) lines.push(`SYNKRO_CLI_BIN=${shellQuoteSingle(safeSynkroBin)}`);
10392
10575
  if (safeUserId) lines.push(`SYNKRO_USER_ID=${shellQuoteSingle(safeUserId)}`);
@@ -12075,7 +12258,14 @@ function writePluginFiles() {
12075
12258
  c.pluginSettingsPath,
12076
12259
  JSON.stringify({
12077
12260
  fastMode: true,
12078
- enabledMcpjsonServers: ["synkro-local"]
12261
+ enabledMcpjsonServers: ["synkro-local"],
12262
+ // Extended thinking is pure latency tax on a structured verdict grade and
12263
+ // emits reasoning the verdict parser isn't tuned for. effortLevel only
12264
+ // SOFTENS it; CLAUDE_CODE_DISABLE_THINKING=1 is the authoritative off
12265
+ // switch. Mirror the cloud pool (pool-config.sh) so local + cloud graders
12266
+ // behave identically.
12267
+ effortLevel: "low",
12268
+ env: { CLAUDE_CODE_DISABLE_THINKING: "1" }
12079
12269
  }, null, 2) + "\n",
12080
12270
  "utf-8"
12081
12271
  );
@@ -12316,7 +12506,7 @@ tmux kill-session -t "=$SESSION" 2>/dev/null || true
12316
12506
  # Start claude inside a detached tmux session so it has a real pty.
12317
12507
  # Redirect stderr to the log so we can see why it dies.
12318
12508
  tmux new-session -d -s "$SESSION" \\
12319
- "SYNKRO_CHANNEL_PORT=${CHANNEL_1_PORT} claude --dangerously-load-development-channels server:synkro-local --dangerously-skip-permissions --setting-sources project,local --model claude-sonnet-4-6 2>>$LOG; echo 'claude exited with code '$'?' >> $LOG"
12509
+ "SYNKRO_CHANNEL_PORT=${CHANNEL_1_PORT} CLAUDE_CODE_DISABLE_THINKING=1 claude --dangerously-load-development-channels server:synkro-local --dangerously-skip-permissions --setting-sources project,local --model claude-sonnet-4-6 2>>$LOG; echo 'claude exited with code '$'?' >> $LOG"
12320
12510
 
12321
12511
  # Claude's --dangerously-load-development-channels shows a confirmation
12322
12512
  # prompt: option 1 = "I am using this for local development" (accept),
@@ -12378,7 +12568,7 @@ log "claude version: $(claude --version 2>&1 | head -1)"
12378
12568
  tmux kill-session -t "=$SESSION" 2>/dev/null || true
12379
12569
 
12380
12570
  tmux new-session -d -s "$SESSION" \\
12381
- "SYNKRO_CHANNEL_PORT=${CHANNEL_2_PORT} claude --dangerously-load-development-channels server:synkro-local --dangerously-skip-permissions --setting-sources project,local --model claude-sonnet-4-6 2>>$LOG; echo 'claude exited with code '$'?' >> $LOG"
12571
+ "SYNKRO_CHANNEL_PORT=${CHANNEL_2_PORT} CLAUDE_CODE_DISABLE_THINKING=1 claude --dangerously-load-development-channels server:synkro-local --dangerously-skip-permissions --setting-sources project,local --model claude-sonnet-4-6 2>>$LOG; echo 'claude exited with code '$'?' >> $LOG"
12382
12572
 
12383
12573
  sleep 3
12384
12574
  if tmux has-session -t "=$SESSION" 2>/dev/null; then
@@ -12434,7 +12624,7 @@ log "claude version: $(claude --version 2>&1 | head -1)"
12434
12624
  tmux kill-session -t "=$SESSION" 2>/dev/null || true
12435
12625
 
12436
12626
  tmux new-session -d -s "$SESSION" \\
12437
- "SYNKRO_CHANNEL_PORT=${CHANNEL_3_PORT} claude --dangerously-load-development-channels server:synkro-local --dangerously-skip-permissions --setting-sources project,local --model claude-sonnet-4-6 2>>$LOG; echo 'claude exited with code '$'?' >> $LOG"
12627
+ "SYNKRO_CHANNEL_PORT=${CHANNEL_3_PORT} CLAUDE_CODE_DISABLE_THINKING=1 claude --dangerously-load-development-channels server:synkro-local --dangerously-skip-permissions --setting-sources project,local --model claude-sonnet-4-6 2>>$LOG; echo 'claude exited with code '$'?' >> $LOG"
12438
12628
 
12439
12629
  sleep 3
12440
12630
  if tmux has-session -t "=$SESSION" 2>/dev/null; then
@@ -12490,7 +12680,7 @@ log "claude version: $(claude --version 2>&1 | head -1)"
12490
12680
  tmux kill-session -t "=$SESSION" 2>/dev/null || true
12491
12681
 
12492
12682
  tmux new-session -d -s "$SESSION" \\
12493
- "SYNKRO_CHANNEL_PORT=${CHANNEL_4_PORT} claude --dangerously-load-development-channels server:synkro-local --dangerously-skip-permissions --setting-sources project,local --model claude-sonnet-4-6 2>>$LOG; echo 'claude exited with code '$'?' >> $LOG"
12683
+ "SYNKRO_CHANNEL_PORT=${CHANNEL_4_PORT} CLAUDE_CODE_DISABLE_THINKING=1 claude --dangerously-load-development-channels server:synkro-local --dangerously-skip-permissions --setting-sources project,local --model claude-sonnet-4-6 2>>$LOG; echo 'claude exited with code '$'?' >> $LOG"
12494
12684
 
12495
12685
  sleep 3
12496
12686
  if tmux has-session -t "=$SESSION" 2>/dev/null; then
@@ -14031,7 +14221,7 @@ var args = process.argv.slice(2);
14031
14221
  var cmd = args[0] || "";
14032
14222
  var subArgs = args.slice(1);
14033
14223
  function printVersion() {
14034
- console.log("1.6.72");
14224
+ console.log("1.6.74");
14035
14225
  }
14036
14226
  function printHelp2() {
14037
14227
  console.log(`Synkro CLI \u2014 runtime safety for AI coding agents