@synkro-sh/cli 1.6.52 → 1.6.53

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
@@ -193,6 +193,19 @@ function installCCHooks(settingsPath, config) {
193
193
  ],
194
194
  [SYNKRO_MARKER]: true
195
195
  });
196
+ if (config.taskActivateIntentScriptPath) {
197
+ settings.hooks.PreToolUse.push({
198
+ matcher: "mcp__synkro-guardrails__activate_standard",
199
+ hooks: [
200
+ {
201
+ type: "command",
202
+ command: config.taskActivateIntentScriptPath,
203
+ timeout: 5
204
+ }
205
+ ],
206
+ [SYNKRO_MARKER]: true
207
+ });
208
+ }
196
209
  settings.hooks.PostToolUse.push({
197
210
  matcher: "Bash",
198
211
  hooks: [
@@ -807,7 +820,7 @@ synkro_post_with_retry() {
807
820
  function stubHook(surface, optsLiteral) {
808
821
  return "#!/usr/bin/env bun\nimport { runStub } from './_synkro-stub-common.ts';\nrunStub(" + JSON.stringify(surface) + ", " + optsLiteral + ");\n";
809
822
  }
810
- var SYNKRO_COMMON_TS, EDIT_PRECHECK_TS, CWE_PRECHECK_TS, CVE_PRECHECK_TS, INSTALL_SCAN_TS, BASH_JUDGE_TS, AGENT_JUDGE_TS, PLAN_JUDGE_TS, STOP_SUMMARY_TS, SESSION_START_TS, BASH_FOLLOWUP_TS, TRANSCRIPT_SYNC_TS, USER_PROMPT_SUBMIT_TS, CURSOR_BASH_JUDGE_TS, CURSOR_EDIT_CAPTURE_TS, CURSOR_AGENT_CAPTURE_TS, STUB_COMMON_TS, STUB_EDIT_PRECHECK_TS, STUB_CWE_PRECHECK_TS, STUB_CVE_PRECHECK_TS, STUB_BASH_JUDGE_TS, STUB_INSTALL_SCAN_TS, STUB_AGENT_JUDGE_TS, STUB_PLAN_JUDGE_TS, STUB_STOP_SUMMARY_TS, STUB_SESSION_START_TS, STUB_TRANSCRIPT_SYNC_TS, STUB_USER_PROMPT_SUBMIT_TS, STUB_BASH_FOLLOWUP_TS, STUB_CURSOR_BASH_JUDGE_TS, STUB_CURSOR_EDIT_CAPTURE_TS, STUB_CURSOR_AGENT_CAPTURE_TS;
823
+ var SYNKRO_COMMON_TS, EDIT_PRECHECK_TS, CWE_PRECHECK_TS, CVE_PRECHECK_TS, INSTALL_SCAN_TS, BASH_JUDGE_TS, AGENT_JUDGE_TS, PLAN_JUDGE_TS, STOP_SUMMARY_TS, SESSION_START_TS, BASH_FOLLOWUP_TS, TRANSCRIPT_SYNC_TS, USER_PROMPT_SUBMIT_TS, CURSOR_BASH_JUDGE_TS, CURSOR_EDIT_CAPTURE_TS, CURSOR_AGENT_CAPTURE_TS, STUB_COMMON_TS, STUB_EDIT_PRECHECK_TS, STUB_CWE_PRECHECK_TS, STUB_CVE_PRECHECK_TS, STUB_BASH_JUDGE_TS, STUB_INSTALL_SCAN_TS, STUB_AGENT_JUDGE_TS, STUB_PLAN_JUDGE_TS, STUB_STOP_SUMMARY_TS, STUB_SESSION_START_TS, STUB_TRANSCRIPT_SYNC_TS, STUB_USER_PROMPT_SUBMIT_TS, STUB_BASH_FOLLOWUP_TS, STUB_TASK_ACTIVATE_INTENT_TS, STUB_CURSOR_BASH_JUDGE_TS, STUB_CURSOR_EDIT_CAPTURE_TS, STUB_CURSOR_AGENT_CAPTURE_TS;
811
824
  var init_hookScriptsTs = __esm({
812
825
  "cli/installer/hookScriptsTs.ts"() {
813
826
  "use strict";
@@ -1167,7 +1180,7 @@ export function findGitRoot(cwd: string): string {
1167
1180
  } catch { return ''; }
1168
1181
  }
1169
1182
 
1170
- // \u2500\u2500\u2500 .synkro file \u2500\u2500\u2500
1183
+ // \u2500\u2500\u2500 synkro.toml file \u2500\u2500\u2500
1171
1184
 
1172
1185
  export interface SynkroFileConfig {
1173
1186
  version: number;
@@ -1189,51 +1202,49 @@ const SYNKRO_FILE_DEFAULTS: SynkroFileConfig = {
1189
1202
  scanning: { cwe: true, cve: true },
1190
1203
  };
1191
1204
 
1192
- function parseSynkroYaml(raw: string): Record<string, any> {
1205
+ // Coerce a single TOML value token to JS.
1206
+ function parseTomlValue(raw: string): any {
1207
+ const v = raw.trim();
1208
+ if (v.startsWith('[') && v.endsWith(']')) {
1209
+ const inner = v.slice(1, -1).trim();
1210
+ if (!inner) return [];
1211
+ return inner.split(',').map((s: string) => parseTomlValue(s)).filter((s: any) => s !== '');
1212
+ }
1213
+ if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) return v.slice(1, -1);
1214
+ if (v === 'true') return true;
1215
+ if (v === 'false') return false;
1216
+ if (/^-?\\d+$/.test(v)) return parseInt(v, 10);
1217
+ return v;
1218
+ }
1219
+
1220
+ // Minimal TOML reader for synkro.toml's fixed schema (top-level scalars/arrays
1221
+ // + [grader]/[scanning] tables). Dependency-free \u2014 this runs inside the
1222
+ // materialized _synkro-common.ts which has no node_modules. Returns the same
1223
+ // nested-object shape the old YAML parser did.
1224
+ function parseSynkroToml(raw: string): Record<string, any> {
1193
1225
  const result: Record<string, any> = {};
1194
- const lines = raw.split('\\n');
1195
- let currentKey = '';
1196
- let currentObj: Record<string, any> | null = null;
1197
- let currentArr: string[] | null = null;
1198
-
1199
- for (const line of lines) {
1200
- if (!line.trim() || line.trim().startsWith('#')) continue;
1201
-
1202
- if (line.match(/^\\S/) && line.includes(':')) {
1203
- if (currentObj && currentKey) result[currentKey] = currentObj;
1204
- if (currentArr && currentKey) result[currentKey] = currentArr;
1205
- currentObj = null;
1206
- currentArr = null;
1207
-
1208
- const colonIdx = line.indexOf(':');
1209
- const key = line.slice(0, colonIdx).trim();
1210
- const val = line.slice(colonIdx + 1).trim();
1211
- currentKey = key;
1212
-
1213
- if (val) {
1214
- if (val === '[]') result[key] = [];
1215
- else if (val === 'true') result[key] = true;
1216
- else if (val === 'false') result[key] = false;
1217
- else if (/^\\d+$/.test(val)) result[key] = parseInt(val, 10);
1218
- else result[key] = val;
1219
- currentKey = '';
1220
- }
1221
- } else if (line.match(/^ - /)) {
1222
- if (!currentArr) currentArr = [];
1223
- currentArr.push(line.replace(/^ - /, '').trim());
1224
- } else if (line.match(/^ \\S/) && line.includes(':')) {
1225
- if (!currentObj) currentObj = {};
1226
- const colonIdx = line.indexOf(':');
1227
- const k = line.slice(0, colonIdx).trim();
1228
- const v = line.slice(colonIdx + 1).trim();
1229
- if (v === 'true') currentObj[k] = true;
1230
- else if (v === 'false') currentObj[k] = false;
1231
- else if (/^\\d+$/.test(v)) currentObj[k] = parseInt(v, 10);
1232
- else currentObj[k] = v;
1233
- }
1234
- }
1235
- if (currentObj && currentKey) result[currentKey] = currentObj;
1236
- if (currentArr && currentKey) result[currentKey] = currentArr;
1226
+ let section: string | null = null;
1227
+ for (const rawLine of raw.split('\\n')) {
1228
+ const line = rawLine.trim();
1229
+ if (!line || line.startsWith('#')) continue;
1230
+ const sec = line.match(/^\\[([^\\]]+)\\]$/);
1231
+ if (sec) {
1232
+ section = sec[1].trim();
1233
+ if (!result[section]) result[section] = {};
1234
+ continue;
1235
+ }
1236
+ const eq = line.indexOf('=');
1237
+ if (eq === -1) continue;
1238
+ const key = line.slice(0, eq).trim().replace(/^["']|["']$/g, '');
1239
+ let valRaw = line.slice(eq + 1).trim();
1240
+ if (!valRaw.startsWith('"') && !valRaw.startsWith("'") && !valRaw.startsWith('[')) {
1241
+ const h = valRaw.indexOf('#');
1242
+ if (h !== -1) valRaw = valRaw.slice(0, h).trim();
1243
+ }
1244
+ const value = parseTomlValue(valRaw);
1245
+ if (section) (result[section] as Record<string, any>)[key] = value;
1246
+ else result[key] = value;
1247
+ }
1237
1248
  return result;
1238
1249
  }
1239
1250
 
@@ -1266,11 +1277,11 @@ export function loadSynkroFile(cwd?: string): SynkroFileConfig {
1266
1277
  if (_synkroFileCache) return _synkroFileCache;
1267
1278
  const root = cwd ? findGitRoot(cwd) : '';
1268
1279
  if (!root) { _synkroFileCache = SYNKRO_FILE_DEFAULTS; return _synkroFileCache; }
1269
- const fp = root + '/.synkro';
1280
+ const fp = root + '/synkro.toml';
1270
1281
  try {
1271
1282
  if (!existsSync(fp)) { _synkroFileCache = SYNKRO_FILE_DEFAULTS; return _synkroFileCache; }
1272
1283
  const raw = readFileSync(fp, 'utf-8');
1273
- const parsed = raw.trimStart().startsWith('{') ? JSON.parse(raw) : parseSynkroYaml(raw);
1284
+ const parsed = parseSynkroToml(raw);
1274
1285
  const validHarness = ['claude-code', 'cursor'] as const;
1275
1286
  const harness = Array.isArray(parsed.harness)
1276
1287
  ? parsed.harness.filter((h: string) => validHarness.includes(h as any))
@@ -1309,13 +1320,13 @@ export function effectiveGraderPool(synkroFile: SynkroFileConfig, hookAgentKind:
1309
1320
  return 'cursor';
1310
1321
  }
1311
1322
 
1312
- // \u2500\u2500\u2500 .synkro presence (per-repo onboarding) \u2500\u2500\u2500
1313
- // A repo is "onboarded" to Synkro when it has a .synkro file at its git root.
1314
- // The hooks are installed globally, so they fire in every repo \u2014 including ones
1315
- // the user never set up. Without a .synkro there, grading is SKIPPED gracefully
1316
- // rather than attempting a grade that surfaces a confusing "grader unavailable"
1317
- // error: the tool call passes through and we show a one-time hint pointing at
1318
- // "synkro install".
1323
+ // \u2500\u2500\u2500 synkro.toml presence (per-repo onboarding) \u2500\u2500\u2500
1324
+ // A repo is "onboarded" to Synkro when it has a synkro.toml file at its git
1325
+ // root. The hooks are installed globally, so they fire in every repo \u2014
1326
+ // including ones the user never set up. Without a synkro.toml there, grading is
1327
+ // SKIPPED gracefully rather than attempting a grade that surfaces a confusing
1328
+ // "grader unavailable" error: the tool call passes through and we show a
1329
+ // one-time hint pointing at "synkro install".
1319
1330
  export function synkroFilePresent(cwd?: string): boolean {
1320
1331
  try {
1321
1332
  const root = cwd ? findGitRoot(cwd) : '';
@@ -1325,18 +1336,17 @@ export function synkroFilePresent(cwd?: string): boolean {
1325
1336
  // under ~), never treat it as onboarded \u2014 otherwise grading fires in every
1326
1337
  // directory under home.
1327
1338
  if (root === HOME) return false;
1328
- // A repo is onboarded ONLY via a regular .synkro FILE at its root. statSync +
1329
- // isFile is required because existsSync also matches a DIRECTORY named
1330
- // .synkro (exactly what ~/.synkro is) \u2014 the root cause of grading running in
1331
- // repos that have no .synkro file.
1332
- return statSync(root + '/.synkro').isFile();
1339
+ // A repo is onboarded ONLY via a regular synkro.toml FILE at its root.
1340
+ // statSync + isFile guards against a directory of that name being mistaken
1341
+ // for config.
1342
+ return statSync(root + '/synkro.toml').isFile();
1333
1343
  } catch { return false; }
1334
1344
  }
1335
1345
 
1336
1346
  const NO_SYNKRO_HINT_DIR = join(HOME, '.synkro', '.no-synkro-hint');
1337
1347
 
1338
1348
  // Returns the onboarding hint the FIRST time a session touches a repo with no
1339
- // .synkro file, then null on subsequent calls so it doesn't repeat on every
1349
+ // synkro.toml file, then null on subsequent calls so it doesn't repeat on every
1340
1350
  // tool call. Caller passes null straight through to outputEmpty().
1341
1351
  export function noSynkroSkipMessage(sessionId: string): string | null {
1342
1352
  try {
@@ -1348,7 +1358,7 @@ export function noSynkroSkipMessage(sessionId: string): string | null {
1348
1358
  } catch {
1349
1359
  // best-effort dedup \u2014 if the marker can't be written, still show the hint
1350
1360
  }
1351
- return '[synkro] No .synkro config in this repo \u2014 grading skipped. Run \`synkro install\` here to enable Synkro.';
1361
+ return '[synkro] No synkro.toml config in this repo \u2014 grading skipped. Run \`synkro install\` here to enable Synkro.';
1352
1362
  }
1353
1363
 
1354
1364
  // \u2500\u2500\u2500 Channel Health \u2500\u2500\u2500
@@ -2056,6 +2066,7 @@ export function dispatchCapture(
2056
2066
  ccModel?: string;
2057
2067
  linesAdded?: number;
2058
2068
  linesRemoved?: number;
2069
+ codeChange?: string;
2059
2070
  },
2060
2071
  ): void {
2061
2072
  // Fire-and-forget
@@ -2091,6 +2102,7 @@ export function dispatchCapture(
2091
2102
  if (opts.recentUserMessages) localBody.recent_user_messages = opts.recentUserMessages;
2092
2103
  if (opts.linesAdded != null) localBody.lines_added = opts.linesAdded;
2093
2104
  if (opts.linesRemoved != null) localBody.lines_removed = opts.linesRemoved;
2105
+ if (opts.codeChange) localBody.code_change = opts.codeChange;
2094
2106
  }
2095
2107
  appendLocalTelemetry(localBody);
2096
2108
 
@@ -2102,6 +2114,9 @@ export function dispatchCapture(
2102
2114
  if (opts.rulesChecked) body.rules_checked = opts.rulesChecked;
2103
2115
  if (opts.violatedRules) body.violated_rules = opts.violatedRules;
2104
2116
  if (opts.recentUserMessages) body.recent_user_messages = opts.recentUserMessages;
2117
+ if (opts.linesAdded != null) body.lines_added = opts.linesAdded;
2118
+ if (opts.linesRemoved != null) body.lines_removed = opts.linesRemoved;
2119
+ if (opts.codeChange) body.code_change = opts.codeChange;
2105
2120
  }
2106
2121
  shipCloud(jwt, '/api/v1/hook/capture', body);
2107
2122
  }
@@ -3142,6 +3157,8 @@ export type ScanFindingInput = {
3142
3157
  aliases?: string[];
3143
3158
  references?: Array<{ type: string; url: string }>;
3144
3159
  cwe_name?: string;
3160
+ code_snippet?: string;
3161
+ offending_lines?: string;
3145
3162
  };
3146
3163
 
3147
3164
  /** Persist open scan_findings rows for a block and flush the telemetry spool. */
@@ -3159,12 +3176,39 @@ export function emitBlockScanFindings(
3159
3176
  file_path: ctx.file_path,
3160
3177
  repo: ctx.repo,
3161
3178
  status: 'open',
3179
+ // These are BLOCK findings \u2014 Synkro denied the action, nothing landed.
3180
+ // prevented \u2192 recorded as auto-resolved on the server.
3181
+ prevented: true,
3162
3182
  ...f,
3163
3183
  }, captureDepth);
3164
3184
  }
3165
3185
  if (rows.length > 0) drainSpool().catch(() => {});
3166
3186
  }
3167
3187
 
3188
+ /**
3189
+ * Derive a 1-based file line range ("42" or "42-45") for a grader-reported
3190
+ * offending snippet by locating it verbatim in the reconstructed file. Mirrors
3191
+ * the container scanner's locateSnippetLines so cloud + local storage record
3192
+ * the same lines. Returns undefined when the snippet can't be located.
3193
+ */
3194
+ export function cweSnippetLines(haystack: string, snippet?: string): string | undefined {
3195
+ if (!haystack || !snippet) return undefined;
3196
+ const trimmed = snippet.replace(/^\\n+|\\n+$/g, '');
3197
+ if (!trimmed) return undefined;
3198
+ let idx = haystack.indexOf(trimmed);
3199
+ let spanLines = trimmed.split('\\n').length;
3200
+ if (idx < 0) {
3201
+ const firstLine = trimmed.split('\\n').map((l) => l.trim()).find(Boolean);
3202
+ if (!firstLine) return undefined;
3203
+ idx = haystack.indexOf(firstLine);
3204
+ if (idx < 0) return undefined;
3205
+ spanLines = 1;
3206
+ }
3207
+ const startLine = haystack.slice(0, idx).split('\\n').length;
3208
+ const endLine = startLine + spanLines - 1;
3209
+ return endLine > startLine ? startLine + '-' + endLine : String(startLine);
3210
+ }
3211
+
3168
3212
  export function dispatchFinding(
3169
3213
  jwt: string,
3170
3214
  finding: {
@@ -3183,6 +3227,9 @@ export function dispatchFinding(
3183
3227
  aliases?: string[];
3184
3228
  references?: Array<{ type: string; url: string }>;
3185
3229
  cwe_name?: string;
3230
+ code_snippet?: string;
3231
+ offending_lines?: string;
3232
+ prevented?: boolean;
3186
3233
  },
3187
3234
  captureDepth: string,
3188
3235
  ): void {
@@ -3209,6 +3256,9 @@ export function dispatchFinding(
3209
3256
  cwe_name: finding.cwe_name,
3210
3257
  detail: finding.detail,
3211
3258
  description: finding.description,
3259
+ code_snippet: finding.code_snippet,
3260
+ offending_lines: finding.offending_lines,
3261
+ prevented: finding.prevented,
3212
3262
  };
3213
3263
  shipCloud(jwt, '/api/v1/hook/finding', cloudBody);
3214
3264
  }
@@ -3339,7 +3389,7 @@ export function graderUnavailableMessage(hook: string, target: string, errorMess
3339
3389
  }
3340
3390
  if (isGraderNotConfigured(errorMessage)) {
3341
3391
  const agent = agentKind === 'cursor' ? 'Cursor' : 'Claude Code';
3342
- return hook + ' ' + target + ' \u2192 local grader not configured for ' + agent + '. Add this agent to your .synkro file and run \`synkro install\`.';
3392
+ return hook + ' ' + target + ' \u2192 local grader not configured for ' + agent + '. Add this agent to your synkro.toml file and run \`synkro install\`.';
3343
3393
  }
3344
3394
  return hook + ' ' + target + ' \u2192 local grader unavailable, skipped';
3345
3395
  }
@@ -3689,7 +3739,7 @@ import {
3689
3739
  loadJwt, ensureFreshJwt, detectRepo, loadConfig, cweRoute, tag,
3690
3740
  localGradeCwe, parseVerdict, reconstructContent, readStdin, log,
3691
3741
  outputJson, outputEmpty, setupCursorHookSignals, installHookWatchdog, isEditTool, isShellTool, isCursorHookFormat,
3692
- extractShellCodeWrites, hookSessionId, filePathFromToolInput, emitBlockScanFindings, dispatchFinding, dispatchCapture, GATEWAY_URL,
3742
+ extractShellCodeWrites, hookSessionId, filePathFromToolInput, emitBlockScanFindings, cweSnippetLines, dispatchFinding, dispatchCapture, GATEWAY_URL,
3693
3743
  logGraderUnavailable, graderUnavailableMessage, resolveTranscriptPath, isCursorInvokingCcHook,
3694
3744
  loadSynkroFile, effectiveGraderPool, synkroFilePresent, noSynkroSkipMessage,
3695
3745
  } from './_synkro-common.ts';
@@ -3725,6 +3775,8 @@ interface CweScanTarget {
3725
3775
  cweDiffSection: string;
3726
3776
  toolName: string;
3727
3777
  toolInput: any;
3778
+ /** Full reconstructed file (or written content) — used to locate snippet line ranges. */
3779
+ proposed: string;
3728
3780
  }
3729
3781
 
3730
3782
  const JS_DANGEROUS_MODULES = new Set([
@@ -3867,6 +3919,7 @@ async function main() {
3867
3919
  cweDiffSection: '',
3868
3920
  toolName: toolName || 'Shell',
3869
3921
  toolInput: {},
3922
+ proposed: w.content,
3870
3923
  });
3871
3924
  }
3872
3925
  if (targets.length === 0) { outputEmpty(); return; }
@@ -3901,7 +3954,7 @@ async function main() {
3901
3954
  cweContent = proposed.slice(0, 4000);
3902
3955
  }
3903
3956
 
3904
- targets.push({ filePath, cweContent, cweDiffSection, toolName, toolInput });
3957
+ targets.push({ filePath, cweContent, cweDiffSection, toolName, toolInput, proposed });
3905
3958
  } else {
3906
3959
  outputEmpty();
3907
3960
  return;
@@ -3927,6 +3980,7 @@ async function main() {
3927
3980
  const cweDiffSection = scan.cweDiffSection;
3928
3981
  const scanToolName = scan.toolName;
3929
3982
  const scanToolInput = scan.toolInput;
3983
+ const proposedFull = scan.proposed || scan.cweContent;
3930
3984
  const gitRepo = detectRepo(cwd, transcriptPath, filePath, workspaceRoots);
3931
3985
  const fileShort = basename(filePath);
3932
3986
  const fileExt = extname(filePath).toLowerCase();
@@ -4012,12 +4066,15 @@ async function main() {
4012
4066
  { session_id: sessionId, file_path: filePath, repo: gitRepo || undefined },
4013
4067
  activeCweIds.map((cweId) => {
4014
4068
  const f = findings.find((x: any) => x.cwe === cweId);
4069
+ const snippet = f?.code_snippet || undefined;
4015
4070
  return {
4016
4071
  finding_type: 'cwe' as const,
4017
4072
  finding_id: cweId,
4018
4073
  severity: f?.severity || 'high',
4019
4074
  detail: f?.reason || 'code weakness detected',
4020
4075
  cwe_name: f?.name || undefined,
4076
+ code_snippet: snippet,
4077
+ offending_lines: cweSnippetLines(proposedFull, snippet),
4021
4078
  };
4022
4079
  }),
4023
4080
  {
@@ -4147,6 +4204,7 @@ async function main() {
4147
4204
 
4148
4205
  const cweIds: string[] = [];
4149
4206
  const fixes: Record<string, string> = {};
4207
+ const snippets: Record<string, string> = {};
4150
4208
  let mergedReason = '';
4151
4209
  let mergedSeverity = '';
4152
4210
  let mergedCategory = '';
@@ -4169,6 +4227,19 @@ async function main() {
4169
4227
  for (let i = 0; i < Math.min(respIds.length, fMatches.length); i++) {
4170
4228
  if (!fixes[respIds[i]]) fixes[respIds[i]] = fMatches[i].replace(/<\/?suggested_fix>/g, '').trim();
4171
4229
  }
4230
+ // Per-<violation> pass for the verbatim snippet (code may contain '<'),
4231
+ // paired with its rule_id so each finding carries its own offending code.
4232
+ const vBlocks = gradeResp.match(/<violation>[\s\S]*?<\/violation>/g) || [];
4233
+ for (const block of vBlocks.slice(0, 8)) {
4234
+ const bId = block.match(/<rule_id>([^<]+)<\/rule_id>/)?.[1];
4235
+ if (!bId) continue;
4236
+ const id = bId.trim().replace(/^cwe-/i, 'CWE-');
4237
+ const snM = block.match(/<code_snippet>([\s\S]*?)<\/code_snippet>/);
4238
+ if (snM && !snippets[id]) {
4239
+ const snip = snM[1].replace(/^\n+|\n+$/g, '');
4240
+ if (snip.trim()) snippets[id] = snip;
4241
+ }
4242
+ }
4172
4243
  }
4173
4244
  }
4174
4245
 
@@ -4203,13 +4274,18 @@ async function main() {
4203
4274
  jwt,
4204
4275
  config.captureDepth,
4205
4276
  { session_id: sessionId, file_path: filePath, repo: gitRepo || undefined },
4206
- activeCweIds.map((cweId) => ({
4207
- finding_type: 'cwe' as const,
4208
- finding_id: cweId,
4209
- severity: verdict.severity || 'high',
4210
- detail: verdict.reason || 'code weakness detected',
4211
- cwe_name: cweNameMap.get(cweId.toUpperCase()) || undefined,
4212
- })),
4277
+ activeCweIds.map((cweId) => {
4278
+ const snippet = snippets[cweId] || undefined;
4279
+ return {
4280
+ finding_type: 'cwe' as const,
4281
+ finding_id: cweId,
4282
+ severity: verdict.severity || 'high',
4283
+ detail: verdict.reason || 'code weakness detected',
4284
+ cwe_name: cweNameMap.get(cweId.toUpperCase()) || undefined,
4285
+ code_snippet: snippet,
4286
+ offending_lines: cweSnippetLines(proposedFull, snippet),
4287
+ };
4288
+ }),
4213
4289
  {
4214
4290
  finding_type: 'cwe',
4215
4291
  finding_id: activeCweIds[0] || 'CWE-UNKNOWN',
@@ -5830,7 +5906,7 @@ async function main() {
5830
5906
  const model = rawModel ? (rawModel.startsWith('cursor/') ? rawModel : 'cursor/' + rawModel) : 'cursor';
5831
5907
  const repo = detectRepo(cwd, transcriptPath, command, workspaceRoots);
5832
5908
 
5833
- // No .synkro at the resolved repo root \u2192 Synkro is dormant here; allow
5909
+ // No synkro.toml at the resolved repo root \u2192 Synkro is dormant here; allow
5834
5910
  // without grading. Keyed off the validated detectRepo() root, not raw cwd.
5835
5911
  if (!repo || !synkroFilePresent(repo)) finishAllow();
5836
5912
 
@@ -6043,7 +6119,7 @@ async function main() {
6043
6119
  const filePath = String(payload.file_path || payload.path || payload.target_file || '');
6044
6120
  if (!filePath) finish();
6045
6121
 
6046
- // No .synkro at the resolved repo root \u2192 Synkro is dormant here; skip capture.
6122
+ // No synkro.toml at the resolved repo root \u2192 Synkro is dormant here; skip capture.
6047
6123
  const _root = detectRepo((typeof payload.cwd === 'string' && payload.cwd) || dirname(filePath), '', filePath, []);
6048
6124
  if (!_root || !isPathUnder(filePath, _root) || !synkroFilePresent(_root)) finish();
6049
6125
 
@@ -6222,7 +6298,7 @@ function gitRoot(cwd: string): string {
6222
6298
  } catch { return ''; }
6223
6299
  }
6224
6300
 
6225
- // Once-per-session onboarding hint for repos with no .synkro file.
6301
+ // Once-per-session onboarding hint for repos with no synkro.toml file.
6226
6302
  function noSynkroHint(sessionId: string): string | null {
6227
6303
  const dir = join(HOME, '.synkro', '.no-synkro-hint');
6228
6304
  try {
@@ -6232,7 +6308,7 @@ function noSynkroHint(sessionId: string): string | null {
6232
6308
  if (existsSync(marker)) return null;
6233
6309
  writeFileSync(marker, '', { flag: 'w' });
6234
6310
  } catch {}
6235
- return '[synkro] No .synkro config in this repo — grading skipped. Run synkro install here to enable Synkro.';
6311
+ return '[synkro] No synkro.toml config in this repo — grading skipped. Run synkro install here to enable Synkro.';
6236
6312
  }
6237
6313
 
6238
6314
  function filePathFromToolInput(ti: any): string {
@@ -6263,13 +6339,13 @@ export async function runStub(surface: string, opts: StubOpts = {}): Promise<voi
6263
6339
  const sessionId = String(payload.session_id || payload.conversation_id || '');
6264
6340
  const root = gitRoot(cwd);
6265
6341
 
6266
- // Dormancy: a repo is onboarded only if it has a .synkro FILE at its git
6342
+ // Dormancy: a repo is onboarded only if it has a synkro.toml FILE at its git
6267
6343
  // root. Guard root !== HOME — ~/.synkro is the config DIRECTORY, so without
6268
- // this a home-rooted cwd (dotfiles in git, non-git dir under home) would
6269
- // "find" the config dir and look onboarded.
6344
+ // this a home-rooted cwd (dotfiles in git, non-git dir under home) could
6345
+ // look onboarded.
6270
6346
  let synkroFileText = '';
6271
- if (root && root !== HOME && existsSync(join(root, '.synkro'))) {
6272
- try { synkroFileText = readFileSync(join(root, '.synkro'), 'utf-8'); } catch {}
6347
+ if (root && root !== HOME && existsSync(join(root, 'synkro.toml'))) {
6348
+ try { synkroFileText = readFileSync(join(root, 'synkro.toml'), 'utf-8'); } catch {}
6273
6349
  }
6274
6350
  if (!synkroFileText) {
6275
6351
  if (opts.telemetry) { out(failOpen(harness)); return; }
@@ -6303,16 +6379,32 @@ export async function runStub(surface: string, opts: StubOpts = {}): Promise<voi
6303
6379
  envelope.planText = String(ti.plan || ti.content || payload.plan || '');
6304
6380
  }
6305
6381
 
6306
- const timeoutMs = opts.telemetry ? 6000 : 28000;
6382
+ // bash-followup is telemetry, but it can carry the INLINE "task complete ✓ N/N"
6383
+ // result (the server waits up to 30s for the completion grade), so it needs a
6384
+ // wait above that budget — not the 6s telemetry default — or the stub abandons
6385
+ // the request before the result lands and the completion shows up a hook late.
6386
+ const timeoutMs = surface === 'bash-followup' ? 32000 : (opts.telemetry ? 6000 : 28000);
6307
6387
  const url = 'http://127.0.0.1:' + PORT + '/api/scan/' + surface + (harness === 'cursor' ? '?harness=cursor' : '');
6308
- const resp = await fetch(url, {
6309
- method: 'POST',
6310
- headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + loadMcpJwt() },
6311
- body: JSON.stringify(envelope),
6312
- signal: AbortSignal.timeout(timeoutMs),
6313
- });
6314
- const text = await resp.text();
6315
- out(resp.ok ? (text.trim() || failOpen(harness)) : failOpen(harness));
6388
+ const body = JSON.stringify(envelope);
6389
+ const headers = { 'Content-Type': 'application/json', Authorization: 'Bearer ' + loadMcpJwt() };
6390
+ // Telemetry hooks (bash-followup, transcript-sync, session telemetry) IGNORE the
6391
+ // response and the server processes them DETACHED, so delivery RELIABILITY beats
6392
+ // latency: retry a few times with backoff. Right after a server restart the
6393
+ // event loop can be briefly busy / not-yet-listening and a single POST silently
6394
+ // drops the event (this was the followup flakiness). Double-delivery is safe here
6395
+ // the server side is idempotent-ish (completion in-flight guard, dup evidence
6396
+ // is harmless). Blocking hooks make ONE attempt: their response IS the verdict
6397
+ // and a retry would double-grade. Normal case: first attempt wins, no delay.
6398
+ const attempts = opts.telemetry ? 3 : 1;
6399
+ let text = '';
6400
+ for (let i = 0; i < attempts; i++) {
6401
+ try {
6402
+ const resp = await fetch(url, { method: 'POST', headers, body, signal: AbortSignal.timeout(timeoutMs) });
6403
+ if (resp.ok) { text = (await resp.text()).trim(); break; }
6404
+ } catch (e) { /* connection refused / timeout → retry below */ }
6405
+ if (i < attempts - 1) await new Promise((r) => setTimeout(r, 500 * (i + 1)));
6406
+ }
6407
+ out(text || failOpen(harness));
6316
6408
  } catch {
6317
6409
  out(failOpen(harness));
6318
6410
  }
@@ -6325,11 +6417,30 @@ export async function runStub(surface: string, opts: StubOpts = {}): Promise<voi
6325
6417
  STUB_INSTALL_SCAN_TS = stubHook("install-scan", "{ needsTranscript: true }");
6326
6418
  STUB_AGENT_JUDGE_TS = stubHook("agent-judge", "{ needsTranscript: true }");
6327
6419
  STUB_PLAN_JUDGE_TS = stubHook("plan-judge", "{ needsPlan: true }");
6328
- STUB_STOP_SUMMARY_TS = stubHook("stop-summary", "{ needsTranscript: true, fullTranscript: true, telemetry: true }");
6420
+ STUB_STOP_SUMMARY_TS = stubHook("stop-summary", "{ needsTranscript: true, fullTranscript: true }");
6329
6421
  STUB_SESSION_START_TS = stubHook("session-start", "{ telemetry: true }");
6330
6422
  STUB_TRANSCRIPT_SYNC_TS = stubHook("transcript-sync", "{ needsTranscript: true, fullTranscript: true, telemetry: true }");
6331
6423
  STUB_USER_PROMPT_SUBMIT_TS = stubHook("prompt-submit", "{ telemetry: true }");
6332
6424
  STUB_BASH_FOLLOWUP_TS = stubHook("bash-followup", "{ telemetry: true }");
6425
+ STUB_TASK_ACTIVATE_INTENT_TS = `#!/usr/bin/env bun
6426
+ import { readFileSync } from 'node:fs';
6427
+ import { homedir } from 'node:os';
6428
+ import { join } from 'node:path';
6429
+ const PORT = process.env.SYNKRO_MCP_PORT || '18931';
6430
+ function mcpJwt(): string { try { return readFileSync(join(homedir(), '.synkro', '.mcp-jwt'), 'utf-8').trim(); } catch { return ''; } }
6431
+ const chunks: Buffer[] = [];
6432
+ for await (const chunk of process.stdin) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
6433
+ try {
6434
+ const payload = JSON.parse(Buffer.concat(chunks).toString('utf-8') || '{}');
6435
+ await fetch('http://127.0.0.1:' + PORT + '/api/local/task-activate-intent', {
6436
+ method: 'POST',
6437
+ headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + mcpJwt() },
6438
+ body: JSON.stringify({ session_id: payload.session_id, tool_input: payload.tool_input }),
6439
+ signal: AbortSignal.timeout(5000),
6440
+ });
6441
+ } catch {}
6442
+ process.stdout.write('{}\\n');
6443
+ `;
6333
6444
  STUB_CURSOR_BASH_JUDGE_TS = stubHook("bash-judge", "{ needsTranscript: true, harness: 'cursor' }");
6334
6445
  STUB_CURSOR_EDIT_CAPTURE_TS = stubHook("edit-precheck", "{ needsFile: true, needsTranscript: true, harness: 'cursor' }");
6335
6446
  STUB_CURSOR_AGENT_CAPTURE_TS = stubHook("agent-judge", "{ needsTranscript: true, harness: 'cursor' }");
@@ -7476,7 +7587,7 @@ function resolveGraderPool(parsed) {
7476
7587
  if (np === "claude_code") counts.claude_code = Math.max(0, Math.floor(v));
7477
7588
  else if (np === "cursor") counts.cursor = Math.max(0, Math.floor(v));
7478
7589
  else if (flagUnknown) {
7479
- warnings.push(`.synkro: ${where}.${k} is not a known provider (use claude-code or cursor) \u2014 ignored`);
7590
+ warnings.push(`synkro.toml: ${where}.${k} is not a known provider (use claude-code or cursor) \u2014 ignored`);
7480
7591
  }
7481
7592
  }
7482
7593
  };
@@ -7487,7 +7598,7 @@ function resolveGraderPool(parsed) {
7487
7598
  if (rawPool != null && rawPool !== "auto") {
7488
7599
  const np = normalizeProvider(String(rawPool));
7489
7600
  if (np) pool = np;
7490
- else warnings.push(`.synkro: grader.pool="${rawPool}" is not recognized (use auto | claude-code | cursor) \u2014 falling back to auto`);
7601
+ else warnings.push(`synkro.toml: grader.pool="${rawPool}" is not recognized (use auto | claude-code | cursor) \u2014 falling back to auto`);
7491
7602
  }
7492
7603
  if (counts.claude_code != null || counts.cursor != null) {
7493
7604
  return { claudeWorkers: counts.claude_code, cursorWorkers: counts.cursor, pool, warnings };
@@ -7497,57 +7608,52 @@ function resolveGraderPool(parsed) {
7497
7608
  function poolLabel(pool) {
7498
7609
  return pool === "claude_code" ? "claude-code" : pool;
7499
7610
  }
7500
- function parseSynkroYaml(raw) {
7611
+ function parseTomlValue(raw) {
7612
+ const v = raw.trim();
7613
+ if (v.startsWith("[") && v.endsWith("]")) {
7614
+ const inner = v.slice(1, -1).trim();
7615
+ if (!inner) return [];
7616
+ return inner.split(",").map((s) => parseTomlValue(s)).filter((s) => s !== "");
7617
+ }
7618
+ if (v.startsWith('"') && v.endsWith('"') || v.startsWith("'") && v.endsWith("'")) return v.slice(1, -1);
7619
+ if (v === "true") return true;
7620
+ if (v === "false") return false;
7621
+ if (/^-?\d+$/.test(v)) return parseInt(v, 10);
7622
+ return v;
7623
+ }
7624
+ function parseSynkroToml(raw) {
7501
7625
  const result = {};
7502
- const lines = raw.split("\n");
7503
- let currentKey = "";
7504
- let currentObj = null;
7505
- let currentArr = null;
7506
- for (const line of lines) {
7507
- if (!line.trim() || line.trim().startsWith("#")) continue;
7508
- if (/^\S/.test(line) && line.includes(":")) {
7509
- if (currentObj && currentKey) result[currentKey] = currentObj;
7510
- if (currentArr && currentKey) result[currentKey] = currentArr;
7511
- currentObj = null;
7512
- currentArr = null;
7513
- const ci = line.indexOf(":");
7514
- const key = line.slice(0, ci).trim();
7515
- const val = line.slice(ci + 1).trim();
7516
- currentKey = key;
7517
- if (val) {
7518
- if (val === "[]") result[key] = [];
7519
- else if (val === "true") result[key] = true;
7520
- else if (val === "false") result[key] = false;
7521
- else if (/^\d+$/.test(val)) result[key] = parseInt(val, 10);
7522
- else result[key] = val;
7523
- currentKey = "";
7524
- }
7525
- } else if (/^ - /.test(line)) {
7526
- if (!currentArr) currentArr = [];
7527
- currentArr.push(line.replace(/^ - /, "").trim());
7528
- } else if (/^ \S/.test(line) && line.includes(":")) {
7529
- if (!currentObj) currentObj = {};
7530
- const ci = line.indexOf(":");
7531
- const k = line.slice(0, ci).trim();
7532
- const v = line.slice(ci + 1).trim();
7533
- if (v === "true") currentObj[k] = true;
7534
- else if (v === "false") currentObj[k] = false;
7535
- else if (/^\d+$/.test(v)) currentObj[k] = parseInt(v, 10);
7536
- else currentObj[k] = v;
7537
- }
7538
- }
7539
- if (currentObj && currentKey) result[currentKey] = currentObj;
7540
- if (currentArr && currentKey) result[currentKey] = currentArr;
7626
+ let section = null;
7627
+ for (const rawLine of raw.split("\n")) {
7628
+ const line = rawLine.trim();
7629
+ if (!line || line.startsWith("#")) continue;
7630
+ const sec = line.match(/^\[([^\]]+)\]$/);
7631
+ if (sec) {
7632
+ section = sec[1].trim();
7633
+ if (!result[section]) result[section] = {};
7634
+ continue;
7635
+ }
7636
+ const eq = line.indexOf("=");
7637
+ if (eq === -1) continue;
7638
+ const key = line.slice(0, eq).trim().replace(/^["']|["']$/g, "");
7639
+ let valRaw = line.slice(eq + 1).trim();
7640
+ if (!valRaw.startsWith('"') && !valRaw.startsWith("'") && !valRaw.startsWith("[")) {
7641
+ const h = valRaw.indexOf("#");
7642
+ if (h !== -1) valRaw = valRaw.slice(0, h).trim();
7643
+ }
7644
+ const value = parseTomlValue(valRaw);
7645
+ if (section) result[section][key] = value;
7646
+ else result[key] = value;
7647
+ }
7541
7648
  return result;
7542
7649
  }
7543
7650
  function readSynkroFileConfig() {
7544
7651
  try {
7545
7652
  const root = execSync4("git rev-parse --show-toplevel 2>/dev/null", { encoding: "utf-8", timeout: 3e3, stdio: ["pipe", "pipe", "pipe"] }).trim();
7546
7653
  if (!root) return { pool: "auto", warnings: [] };
7547
- const fp = join6(root, ".synkro");
7654
+ const fp = join6(root, "synkro.toml");
7548
7655
  if (!existsSync8(fp)) return { pool: "auto", warnings: [] };
7549
- const raw = readFileSync6(fp, "utf-8");
7550
- const parsed = raw.trimStart().startsWith("{") ? JSON.parse(raw) : parseSynkroYaml(raw);
7656
+ const parsed = parseSynkroToml(readFileSync6(fp, "utf-8"));
7551
7657
  return resolveGraderPool(parsed);
7552
7658
  } catch {
7553
7659
  }
@@ -8580,6 +8686,7 @@ function writeHookScripts(mode = "stub") {
8580
8686
  const cursorEditCapturePath = join8(HOOKS_DIR, "cursor-edit-capture.ts");
8581
8687
  const cursorAgentCapturePath = join8(HOOKS_DIR, "cursor-agent-capture.ts");
8582
8688
  const mcpStdioProxyPath = join8(HOOKS_DIR, "mcp-stdio-proxy.ts");
8689
+ const taskActivateIntentScriptPath = join8(HOOKS_DIR, "cc-task-activate-intent.ts");
8583
8690
  if (mode === "stub") {
8584
8691
  const stubCommonPath = join8(HOOKS_DIR, "_synkro-stub-common.ts");
8585
8692
  const stubFiles = [
@@ -8596,6 +8703,7 @@ function writeHookScripts(mode = "stub") {
8596
8703
  [transcriptSyncScriptPath, STUB_TRANSCRIPT_SYNC_TS],
8597
8704
  [userPromptSubmitScriptPath, STUB_USER_PROMPT_SUBMIT_TS],
8598
8705
  [installScanScriptPath, STUB_INSTALL_SCAN_TS],
8706
+ [taskActivateIntentScriptPath, STUB_TASK_ACTIVATE_INTENT_TS],
8599
8707
  [cursorBashJudgePath, STUB_CURSOR_BASH_JUDGE_TS],
8600
8708
  [cursorEditCapturePath, STUB_CURSOR_EDIT_CAPTURE_TS],
8601
8709
  [cursorAgentCapturePath, STUB_CURSOR_AGENT_CAPTURE_TS]
@@ -8627,7 +8735,8 @@ function writeHookScripts(mode = "stub") {
8627
8735
  installScanScript: installScanScriptPath,
8628
8736
  cursorBashJudgeScript: cursorBashJudgePath,
8629
8737
  cursorEditCaptureScript: cursorEditCapturePath,
8630
- cursorAgentCaptureScript: cursorAgentCapturePath
8738
+ cursorAgentCaptureScript: cursorAgentCapturePath,
8739
+ taskActivateIntentScript: taskActivateIntentScriptPath
8631
8740
  };
8632
8741
  }
8633
8742
  writeFileSync7(bashScriptPath, BASH_JUDGE_TS, "utf-8");
@@ -8649,6 +8758,7 @@ function writeHookScripts(mode = "stub") {
8649
8758
  writeFileSync7(cursorAgentCapturePath, CURSOR_AGENT_CAPTURE_TS, "utf-8");
8650
8759
  writeFileSync7(mcpStdioProxyPath, MCP_STDIO_PROXY_SRC, "utf-8");
8651
8760
  writeFileSync7(installExtractCorePath, "/**\n * Deterministic install-command extraction \u2014 no LLM, no network.\n * Shared by the API pkg-scan route and the hook scripts (copied to ~/.synkro/hooks/).\n */\nimport { parse as shellParse } from 'shell-quote';\n\nexport interface DeterministicPkgRequest {\n name: string;\n version: string;\n ecosystem: string;\n}\n\ninterface RawInstall {\n ecosystem: string;\n name: string;\n versionSpec: string | null;\n source: 'registry' | 'git' | 'local' | 'url' | 'unknown';\n}\n\nconst SEPARATOR_OPS = new Set(['&&', '||', ';', '|', '&', '\\n']);\n\n/** Split a shell command into command segments (each an argv string array). */\nexport function segmentCommand(command: string): string[][] {\n let tokens: unknown[];\n try {\n tokens = shellParse(command) as unknown[];\n } catch {\n return [command.split(/\\s+/).filter(Boolean)];\n }\n const segments: string[][] = [];\n let current: string[] = [];\n for (const tok of tokens) {\n if (typeof tok === 'string') {\n current.push(tok);\n continue;\n }\n if (tok && typeof tok === 'object') {\n const op = (tok as { op?: string }).op;\n const pattern = (tok as { pattern?: string }).pattern;\n if (op && SEPARATOR_OPS.has(op)) {\n if (current.length) segments.push(current);\n current = [];\n } else if (typeof pattern === 'string') {\n current.push(pattern);\n }\n }\n }\n if (current.length) segments.push(current);\n return segments;\n}\n\nconst PM_TABLE: Record<string, { subs: Set<string>; ecosystem: string }> = {\n npm: { subs: new Set(['install', 'i', 'add', 'ci']), ecosystem: 'npm' },\n pnpm: { subs: new Set(['add', 'install', 'i', 'dlx']), ecosystem: 'npm' },\n yarn: { subs: new Set(['add', 'install']), ecosystem: 'npm' },\n bun: { subs: new Set(['add', 'install', 'i']), ecosystem: 'npm' },\n pip: { subs: new Set(['install']), ecosystem: 'PyPI' },\n pip3: { subs: new Set(['install']), ecosystem: 'PyPI' },\n cargo: { subs: new Set(['add', 'install']), ecosystem: 'crates.io' },\n go: { subs: new Set(['get', 'install']), ecosystem: 'Go' },\n gem: { subs: new Set(['install']), ecosystem: 'RubyGems' },\n composer: { subs: new Set(['require']), ecosystem: 'Packagist' },\n};\nconst SYSTEM_PMS = new Set(['apt', 'apt-get', 'apk', 'brew', 'dnf', 'yum', 'pacman']);\nconst SYSTEM_SUBS = new Set(['install', 'add']);\n\nconst WRAPPERS = new Set(['sudo', 'doas', 'command', 'env', 'xargs', 'nice', 'time']);\nconst VALUE_FLAGS = new Set([\n '--filter', '-F', '-C', '--dir', '--prefix', '--registry', '--tag', '--features',\n '-v', '--version', '--index-url', '--extra-index-url', '--target', '-t',\n]);\n\nfunction basename(p: string): string {\n const i = p.lastIndexOf('/');\n return i >= 0 ? p.slice(i + 1) : p;\n}\n\nfunction stripPrefixes(argv: string[]): string[] {\n let i = 0;\n while (i < argv.length) {\n const t = argv[i];\n if (WRAPPERS.has(basename(t).toLowerCase())) { i++; continue; }\n if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(t)) { i++; continue; }\n break;\n }\n return argv.slice(i);\n}\n\nfunction looksLikePath(tok: string): boolean {\n return tok === '.' || tok === '..' || /^\\.{0,2}\\//.test(tok) || tok.startsWith('~/') || tok.startsWith('file:');\n}\n\n/** Shell redirect fragments (e.g. `2>&1` \u2192 argv `2`, `1`) \u2014 not package names. */\nfunction isRedirectFragment(tok: string): boolean {\n if (/^\\d+$/.test(tok)) return true;\n if (/^[<>]|[<>]$/.test(tok)) return true;\n if (tok === '&' || tok === '|') return true;\n if (/^\\d*[<>]/.test(tok)) return true;\n return false;\n}\n\nfunction parsePackageToken(tok: string, ecosystem: string): RawInstall | null {\n if (/^(https?:)?\\/\\//.test(tok) || tok.startsWith('git+') || tok.startsWith('git:')) {\n return { ecosystem, name: tok, versionSpec: null, source: tok.includes('git') ? 'git' : 'url' };\n }\n if (looksLikePath(tok)) {\n return { ecosystem, name: basename(tok.replace(/\\/+$/, '')) || tok, versionSpec: null, source: 'local' };\n }\n if (ecosystem === 'PyPI') {\n const noExtras = tok.replace(/\\[[^\\]]*\\]/g, '');\n const m = noExtras.match(/^([A-Za-z0-9_.-]+)\\s*([=~!<>].*)?$/);\n if (!m) return null;\n return { ecosystem, name: m[1], versionSpec: m[2] ? m[2].trim() : null, source: 'registry' };\n }\n if (ecosystem === 'Packagist') {\n // composer uses vendor/package:version-constraint (e.g. monolog/monolog:1.0.0).\n // Split on the first ':' after the vendor/ slash; never on the '/'.\n const ci = tok.indexOf(':');\n if (ci > 0) return { ecosystem, name: tok.slice(0, ci), versionSpec: tok.slice(ci + 1) || null, source: 'registry' };\n return { ecosystem, name: tok, versionSpec: null, source: 'registry' };\n }\n const at = tok.lastIndexOf('@');\n if (at > 0) {\n return { ecosystem, name: tok.slice(0, at), versionSpec: tok.slice(at + 1) || null, source: 'registry' };\n }\n return { ecosystem, name: tok, versionSpec: null, source: 'registry' };\n}\n\n/** Deterministic extraction for a single command segment. */\nexport function extractSegment(rawArgv: string[]): RawInstall[] {\n let argv = stripPrefixes(rawArgv);\n if (argv.length < 2) return [];\n let bin = basename(argv[0]).toLowerCase();\n\n if (bin === 'uv' && argv[1] === 'pip') { argv = argv.slice(1); bin = 'pip'; }\n if ((bin === 'python' || bin === 'python3') && argv.includes('-m')) {\n const mi = argv.indexOf('-m');\n if (argv[mi + 1] === 'pip') { argv = ['pip', ...argv.slice(mi + 2)]; bin = 'pip'; }\n }\n\n const isSystem = SYSTEM_PMS.has(bin);\n const entry = PM_TABLE[bin];\n if (!entry && !isSystem) return [];\n const ecosystem = entry ? entry.ecosystem : 'system';\n const subs = entry ? entry.subs : SYSTEM_SUBS;\n\n let subIdx = -1;\n for (let i = 1; i < argv.length; i++) {\n if (subs.has(argv[i].toLowerCase())) { subIdx = i; break; }\n }\n if (subIdx === -1) return [];\n\n const installs: RawInstall[] = [];\n // gem pins the version with a separate `-v`/`--version` flag rather than an\n // inline spec; capture it and apply to the package(s) in this segment.\n let flagVersion: string | null = null;\n for (let i = subIdx + 1; i < argv.length; i++) {\n const tok = argv[i];\n if (isRedirectFragment(tok)) break;\n if (tok.startsWith('-')) {\n const lower = tok.toLowerCase();\n if (ecosystem === 'RubyGems' && (lower === '-v' || lower === '--version')) {\n flagVersion = (argv[i + 1] || '').replace(/^[v=\\s]+/, '') || null;\n i++;\n continue;\n }\n if (VALUE_FLAGS.has(tok)) i++;\n continue;\n }\n const parsed = parsePackageToken(tok, ecosystem);\n if (parsed) installs.push(parsed);\n }\n if (flagVersion) {\n for (const ins of installs) {\n if (ins.source === 'registry' && !ins.versionSpec) ins.versionSpec = flagVersion;\n }\n }\n return installs;\n}\n\nconst ECO_TO_OSV: Record<string, string | null> = {\n npm: 'npm',\n pypi: 'PyPI', PyPI: 'PyPI',\n cargo: 'crates.io', 'crates.io': 'crates.io',\n go: 'Go', Go: 'Go',\n rubygems: 'RubyGems', RubyGems: 'RubyGems',\n packagist: 'Packagist', Packagist: 'Packagist',\n maven: 'Maven', Maven: 'Maven',\n nuget: 'NuGet', NuGet: 'NuGet',\n apt: null, brew: null, system: null, other: null,\n};\n\nfunction normalizeName(name: string, osvEco: string): string {\n const n = name.trim();\n if (osvEco === 'npm') return n.toLowerCase();\n if (osvEco === 'PyPI') return n.toLowerCase().replace(/[-_.]+/g, '-');\n return n;\n}\n\nfunction concretePin(spec: string | null): string | null {\n if (!spec) return null;\n const c = spec.trim().replace(/^[v=\\s]+/, '');\n if (c.toLowerCase() === 'latest' || c === '') return null;\n if (/[\\^~><|*\\sx]/i.test(c)) return null;\n return /^\\d[\\w.\\-+]*$/.test(c) ? c : null;\n}\n\nconst PKG_JSON_DEP_FIELDS = ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'];\n\nfunction safeParseObject(text: string): Record<string, any> | null {\n try {\n const v = JSON.parse(text);\n return v && typeof v === 'object' && !Array.isArray(v) ? v : null;\n } catch {\n return null;\n }\n}\n\n/**\n * Diff two package.json contents and return the registry packages that are\n * newly added or whose version spec changed in the new content. The caller\n * scans these against the vuln DB before letting the edit land \u2014 so a bare\n * `pnpm install` afterwards has nothing left to vet. Non-registry sources\n * (file:, link:, workspace:, git, http, relative paths) are skipped.\n */\nexport function extractPackageJsonDelta(oldText: string, newText: string): DeterministicPkgRequest[] {\n const newJson = safeParseObject(newText);\n if (!newJson) return [];\n const oldJson = safeParseObject(oldText) || {};\n\n const out = new Map<string, DeterministicPkgRequest>();\n for (const field of PKG_JSON_DEP_FIELDS) {\n const oldDeps = (oldJson[field] && typeof oldJson[field] === 'object') ? oldJson[field] : {};\n const newDeps = (newJson[field] && typeof newJson[field] === 'object') ? newJson[field] : {};\n for (const [rawName, version] of Object.entries(newDeps)) {\n if (typeof version !== 'string') continue;\n if (oldDeps[rawName] === version) continue;\n const spec = version.trim();\n if (\n spec.startsWith('file:') || spec.startsWith('link:') ||\n spec.startsWith('http') || spec.startsWith('git') ||\n spec.startsWith('workspace:') || spec.startsWith('catalog:') ||\n spec.startsWith('npm:') ||\n spec.startsWith('./') || spec.startsWith('../') ||\n spec === '' || spec === '*'\n ) continue;\n const name = rawName.toLowerCase();\n out.set(name, {\n name,\n version: concretePin(spec) ?? '*',\n ecosystem: 'npm',\n });\n }\n }\n return [...out.values()];\n}\n\n/**\n * Parse registry installs from a shell command without LLM/network.\n * Unpinned versions use '*' so OSV scans the full advisory history.\n */\nexport function extractDeterministicPkgRequests(command: string): DeterministicPkgRequest[] {\n const merged = new Map<string, DeterministicPkgRequest>();\n for (const r of segmentCommand(command).flatMap(extractSegment)) {\n if (r.source !== 'registry') continue;\n const osvEco = ECO_TO_OSV[r.ecosystem] ?? ECO_TO_OSV[r.ecosystem.toLowerCase()] ?? null;\n if (!osvEco) continue;\n const name = normalizeName(r.name, osvEco);\n if (!name) continue;\n const key = osvEco + '|' + name.toLowerCase();\n const version = concretePin(r.versionSpec) ?? '*';\n const prev = merged.get(key);\n if (!prev || (prev.version === '*' && version !== '*')) {\n merged.set(key, { name, version, ecosystem: osvEco });\n }\n }\n return [...merged.values()];\n}\n", "utf-8");
8761
+ writeFileSync7(taskActivateIntentScriptPath, STUB_TASK_ACTIVATE_INTENT_TS, "utf-8");
8652
8762
  chmodSync2(bashScriptPath, 493);
8653
8763
  chmodSync2(bashFollowupScriptPath, 493);
8654
8764
  chmodSync2(editPrecheckScriptPath, 493);
@@ -8668,6 +8778,7 @@ function writeHookScripts(mode = "stub") {
8668
8778
  chmodSync2(cursorAgentCapturePath, 493);
8669
8779
  chmodSync2(mcpStdioProxyPath, 493);
8670
8780
  chmodSync2(installExtractCorePath, 493);
8781
+ chmodSync2(taskActivateIntentScriptPath, 493);
8671
8782
  try {
8672
8783
  unlinkSync4(join8(HOOKS_DIR, "_synkro-stub-common.ts"));
8673
8784
  } catch {
@@ -8687,7 +8798,8 @@ function writeHookScripts(mode = "stub") {
8687
8798
  installScanScript: installScanScriptPath,
8688
8799
  cursorBashJudgeScript: cursorBashJudgePath,
8689
8800
  cursorEditCaptureScript: cursorEditCapturePath,
8690
- cursorAgentCaptureScript: cursorAgentCapturePath
8801
+ cursorAgentCaptureScript: cursorAgentCapturePath,
8802
+ taskActivateIntentScript: taskActivateIntentScriptPath
8691
8803
  };
8692
8804
  }
8693
8805
  function sanitizeConfigValue(raw, maxLen = 256) {
@@ -8719,7 +8831,7 @@ function writeConfigEnv(opts) {
8719
8831
  `SYNKRO_CREDENTIALS_PATH=${shellQuoteSingle(credsPath)}`,
8720
8832
  `SYNKRO_TIER=${shellQuoteSingle(safeTier)}`,
8721
8833
  `SYNKRO_INFERENCE=${shellQuoteSingle(safeInference)}`,
8722
- `SYNKRO_VERSION=${shellQuoteSingle("1.6.52")}`
8834
+ `SYNKRO_VERSION=${shellQuoteSingle("1.6.53")}`
8723
8835
  ];
8724
8836
  if (safeSynkroBin) lines.push(`SYNKRO_CLI_BIN=${shellQuoteSingle(safeSynkroBin)}`);
8725
8837
  if (safeUserId) lines.push(`SYNKRO_USER_ID=${shellQuoteSingle(safeUserId)}`);
@@ -8997,6 +9109,7 @@ async function installCommand(opts = {}) {
8997
9109
  transcriptSyncScriptPath: scripts.transcriptSyncScript,
8998
9110
  userPromptSubmitScriptPath: scripts.userPromptSubmitScript,
8999
9111
  installScanScriptPath: scripts.installScanScript,
9112
+ taskActivateIntentScriptPath: scripts.taskActivateIntentScript,
9000
9113
  skipTranscriptSync: !transcriptConsent
9001
9114
  });
9002
9115
  console.log(`Configured ${agent.name} hooks at ${agent.settingsPath}`);
@@ -9175,7 +9288,7 @@ async function installCommand(opts = {}) {
9175
9288
  claudeWorkers = 0;
9176
9289
  cursorWorkers = 8;
9177
9290
  }
9178
- console.log(` .synkro: explicit workers \u2014 ${claudeWorkers} claude + ${cursorWorkers} cursor`);
9291
+ console.log(` synkro.toml: explicit workers \u2014 ${claudeWorkers} claude + ${cursorWorkers} cursor`);
9179
9292
  } else {
9180
9293
  const totalWorkers = parseInt(process.env.SYNKRO_WORKERS_PER_POOL || "8", 10);
9181
9294
  const synkroFilePool = sf?.grader?.pool || "auto";
@@ -9189,7 +9302,7 @@ async function installCommand(opts = {}) {
9189
9302
  if (hasCursor) providers.push("cursor");
9190
9303
  }
9191
9304
  ({ claudeWorkers, cursorWorkers } = splitWorkers(totalWorkers, providers));
9192
- if (synkroFilePool !== "auto") console.log(` .synkro: grader pool set to ${poolLabel(synkroFilePool)}`);
9305
+ if (synkroFilePool !== "auto") console.log(` synkro.toml: grader pool set to ${poolLabel(synkroFilePool)}`);
9193
9306
  }
9194
9307
  console.log(` worker pool: ${claudeWorkers} claude + ${cursorWorkers} cursor`);
9195
9308
  const connectedRepo = detectGitRepo2() || void 0;
@@ -9320,65 +9433,58 @@ async function installCommand(opts = {}) {
9320
9433
  }
9321
9434
  console.log("\u2713 Synkro installed.");
9322
9435
  }
9323
- function parseSynkroYaml2(raw) {
9436
+ function parseTomlValue2(raw) {
9437
+ const v = raw.trim();
9438
+ if (v.startsWith("[") && v.endsWith("]")) {
9439
+ const inner = v.slice(1, -1).trim();
9440
+ if (!inner) return [];
9441
+ return inner.split(",").map((s) => parseTomlValue2(s)).filter((s) => s !== "");
9442
+ }
9443
+ if (v.startsWith('"') && v.endsWith('"') || v.startsWith("'") && v.endsWith("'")) return v.slice(1, -1);
9444
+ if (v === "true") return true;
9445
+ if (v === "false") return false;
9446
+ if (/^-?\d+$/.test(v)) return parseInt(v, 10);
9447
+ return v;
9448
+ }
9449
+ function parseSynkroToml2(raw) {
9324
9450
  const result = {};
9325
- const lines = raw.split("\n");
9326
- let currentKey = "";
9327
- let currentObj = null;
9328
- let currentArr = null;
9329
- for (const line of lines) {
9330
- if (!line.trim() || line.trim().startsWith("#")) continue;
9331
- if (/^\S/.test(line) && line.includes(":")) {
9332
- if (currentObj && currentKey) result[currentKey] = currentObj;
9333
- if (currentArr && currentKey) result[currentKey] = currentArr;
9334
- currentObj = null;
9335
- currentArr = null;
9336
- const colonIdx = line.indexOf(":");
9337
- const key = line.slice(0, colonIdx).trim();
9338
- const val = line.slice(colonIdx + 1).trim();
9339
- currentKey = key;
9340
- if (val) {
9341
- if (val === "[]") result[key] = [];
9342
- else if (val === "true") result[key] = true;
9343
- else if (val === "false") result[key] = false;
9344
- else if (/^\d+$/.test(val)) result[key] = parseInt(val, 10);
9345
- else result[key] = val;
9346
- currentKey = "";
9347
- }
9348
- } else if (/^ - /.test(line)) {
9349
- if (!currentArr) currentArr = [];
9350
- currentArr.push(line.replace(/^ - /, "").trim());
9351
- } else if (/^ \S/.test(line) && line.includes(":")) {
9352
- if (!currentObj) currentObj = {};
9353
- const colonIdx = line.indexOf(":");
9354
- const k = line.slice(0, colonIdx).trim();
9355
- const v = line.slice(colonIdx + 1).trim();
9356
- if (v === "true") currentObj[k] = true;
9357
- else if (v === "false") currentObj[k] = false;
9358
- else if (/^\d+$/.test(v)) currentObj[k] = parseInt(v, 10);
9359
- else currentObj[k] = v;
9360
- }
9361
- }
9362
- if (currentObj && currentKey) result[currentKey] = currentObj;
9363
- if (currentArr && currentKey) result[currentKey] = currentArr;
9451
+ let section = null;
9452
+ for (const rawLine of raw.split("\n")) {
9453
+ const line = rawLine.trim();
9454
+ if (!line || line.startsWith("#")) continue;
9455
+ const sec = line.match(/^\[([^\]]+)\]$/);
9456
+ if (sec) {
9457
+ section = sec[1].trim();
9458
+ if (!result[section]) result[section] = {};
9459
+ continue;
9460
+ }
9461
+ const eq = line.indexOf("=");
9462
+ if (eq === -1) continue;
9463
+ const key = line.slice(0, eq).trim().replace(/^["']|["']$/g, "");
9464
+ let valRaw = line.slice(eq + 1).trim();
9465
+ if (!valRaw.startsWith('"') && !valRaw.startsWith("'") && !valRaw.startsWith("[")) {
9466
+ const h = valRaw.indexOf("#");
9467
+ if (h !== -1) valRaw = valRaw.slice(0, h).trim();
9468
+ }
9469
+ const value = parseTomlValue2(valRaw);
9470
+ if (section) result[section][key] = value;
9471
+ else result[key] = value;
9472
+ }
9364
9473
  return result;
9365
9474
  }
9366
- function parseSynkroFileRaw(content) {
9367
- return content.trimStart().startsWith("{") ? JSON.parse(content) : parseSynkroYaml2(content);
9368
- }
9369
9475
  function writeSynkroFileIfMissing(opts) {
9370
9476
  try {
9371
9477
  const root = execSync6("git rev-parse --show-toplevel", { encoding: "utf-8", timeout: 3e3, stdio: ["pipe", "pipe", "pipe"] }).trim();
9372
9478
  if (!root) return;
9373
9479
  if (root === homedir8()) return;
9374
- const fp = join8(root, ".synkro");
9480
+ const fp = join8(root, "synkro.toml");
9375
9481
  let hasFile = false;
9376
9482
  try {
9377
9483
  hasFile = statSync2(fp).isFile();
9378
9484
  } catch {
9379
9485
  }
9380
9486
  if (hasFile) {
9381
- console.log(` .synkro: ${fp} (existing, respected)`);
9487
+ console.log(` synkro.toml: ${fp} (existing, respected)`);
9382
9488
  return;
9383
9489
  }
9384
9490
  let pool = "auto";
@@ -9392,27 +9498,26 @@ function writeSynkroFileIfMissing(opts) {
9392
9498
  if (!opts.hasClaudeCode) pool = "cursor";
9393
9499
  }
9394
9500
  const mode = opts.gradingMode === "byok" ? "byok" : "local";
9395
- const yaml = [
9396
- "version: 1",
9501
+ const toml = [
9502
+ "version = 1",
9397
9503
  "",
9398
- "harness:",
9399
- ...harness.map((h) => ` - ${h}`),
9504
+ `harness = [${harness.map((h) => `"${h}"`).join(", ")}]`,
9400
9505
  "",
9401
- "grader:",
9402
- ` pool: ${pool}`,
9403
- ` mode: ${mode}`,
9506
+ 'ruleset = "default"',
9404
9507
  "",
9405
- "ruleset: default",
9508
+ "skills = []",
9406
9509
  "",
9407
- "skills: []",
9510
+ "[grader]",
9511
+ `pool = "${pool}"`,
9512
+ `mode = "${mode}"`,
9408
9513
  "",
9409
- "scanning:",
9410
- " cwe: true",
9411
- " cve: true",
9514
+ "[scanning]",
9515
+ "cwe = true",
9516
+ "cve = true",
9412
9517
  ""
9413
9518
  ].join("\n");
9414
- writeFileSync7(fp, yaml, "utf-8");
9415
- console.log(` .synkro: wrote ${fp} (pool=${pool}, mode=${mode})`);
9519
+ writeFileSync7(fp, toml, "utf-8");
9520
+ console.log(` synkro.toml: wrote ${fp} (pool=${pool}, mode=${mode})`);
9416
9521
  } catch {
9417
9522
  }
9418
9523
  }
@@ -9420,9 +9525,9 @@ function readFullSynkroFile() {
9420
9525
  try {
9421
9526
  const root = execSync6("git rev-parse --show-toplevel", { encoding: "utf-8", timeout: 3e3, stdio: ["pipe", "pipe", "pipe"] }).trim();
9422
9527
  if (!root) return null;
9423
- const fp = join8(root, ".synkro");
9528
+ const fp = join8(root, "synkro.toml");
9424
9529
  if (!existsSync10(fp)) return null;
9425
- const parsed = parseSynkroFileRaw(readFileSync8(fp, "utf-8"));
9530
+ const parsed = parseSynkroToml2(readFileSync8(fp, "utf-8"));
9426
9531
  const valid = ["claude-code", "cursor"];
9427
9532
  const harness = Array.isArray(parsed.harness) ? parsed.harness.filter((h) => valid.includes(h)) : ["claude-code", "cursor"];
9428
9533
  const resolved = resolveGraderPool(parsed);
@@ -9455,7 +9560,7 @@ function reconcileHarness() {
9455
9560
  const wantCC = sf.harness.includes("claude-code");
9456
9561
  const wantCursor = sf.harness.includes("cursor");
9457
9562
  for (const w of sf.warnings) console.warn(` \u26A0 ${w}`);
9458
- console.log(`.synkro: harness=[${sf.harness.join(", ")}] pool=${poolLabel(sf.grader.pool)} mode=${sf.grader.mode}`);
9563
+ console.log(`synkro.toml: harness=[${sf.harness.join(", ")}] pool=${poolLabel(sf.grader.pool)} mode=${sf.grader.mode}`);
9459
9564
  const scripts = writeHookScripts(resolvePersistedHookMode());
9460
9565
  console.log("Wrote hook scripts to ~/.synkro/hooks/");
9461
9566
  const ccSettings = join8(homedir8(), ".claude", "settings.json");
@@ -9472,7 +9577,8 @@ function reconcileHarness() {
9472
9577
  sessionStartScriptPath: scripts.sessionStartScript,
9473
9578
  transcriptSyncScriptPath: scripts.transcriptSyncScript,
9474
9579
  userPromptSubmitScriptPath: scripts.userPromptSubmitScript,
9475
- installScanScriptPath: scripts.installScanScript
9580
+ installScanScriptPath: scripts.installScanScript,
9581
+ taskActivateIntentScriptPath: scripts.taskActivateIntentScript
9476
9582
  });
9477
9583
  console.log(" \u2713 Claude Code hooks registered");
9478
9584
  try {
@@ -9542,27 +9648,29 @@ async function syncSkillFiles() {
9542
9648
  const tasks = resolved.map((fp) => {
9543
9649
  const content = readFileSync8(fp, "utf-8");
9544
9650
  const source = `skill:${fp.split("/").pop()}`;
9545
- if (!content.trim()) return null;
9651
+ if (!content.trim()) {
9652
+ console.log(` \u2298 skill ${source}: empty file, skipped`);
9653
+ return null;
9654
+ }
9655
+ const lines = content.split("\n").length;
9656
+ console.log(` \u2192 read ${source} (${lines} lines, ${(content.length / 1024).toFixed(1)} KB)`);
9546
9657
  return { source, content };
9547
9658
  }).filter(Boolean);
9548
9659
  if (tasks.length === 0) return;
9549
- const results = await Promise.allSettled(tasks.map(async ({ source, content }) => {
9550
- const resp = await fetch(`http://127.0.0.1:${mcpPort}/api/local/skills/sync`, {
9551
- method: "POST",
9552
- headers: { "Content-Type": "application/json" },
9553
- body: JSON.stringify({ source, content }),
9554
- signal: AbortSignal.timeout(65e3)
9555
- });
9556
- if (!resp.ok) throw new Error(`${resp.status}`);
9557
- const result = await resp.json();
9558
- return { source, ...result };
9559
- }));
9560
- for (const r of results) {
9561
- if (r.status === "fulfilled") {
9562
- const { source, created, message } = r.value;
9563
- console.log(created > 0 ? ` \u2713 skill ${source}: ${created} new rules added` : ` \u2713 skill ${source}: ${message || "up to date"}`);
9564
- } else {
9565
- console.warn(` \u26A0 skill sync failed: ${r.reason}`);
9660
+ console.log(` indexing ${tasks.length} skill file${tasks.length === 1 ? "" : "s"} \u2014 comparing against existing ruleset (this may take a few seconds)...`);
9661
+ for (const { source, content } of tasks) {
9662
+ try {
9663
+ const resp = await fetch(`http://127.0.0.1:${mcpPort}/api/local/skills/sync`, {
9664
+ method: "POST",
9665
+ headers: { "Content-Type": "application/json" },
9666
+ body: JSON.stringify({ source, content }),
9667
+ signal: AbortSignal.timeout(65e3)
9668
+ });
9669
+ if (!resp.ok) throw new Error(`${resp.status}`);
9670
+ const { created, message } = await resp.json();
9671
+ console.log(created > 0 ? ` \u2713 skill ${source}: ${created} new rule${created === 1 ? "" : "s"} added to index` : ` \u2713 skill ${source}: ${message || "up to date"} \u2014 index unchanged`);
9672
+ } catch (e) {
9673
+ console.warn(` \u26A0 skill ${source}: sync failed (${e instanceof Error ? e.message : String(e)})`);
9566
9674
  }
9567
9675
  }
9568
9676
  }
@@ -9909,37 +10017,101 @@ const TOKEN_PATH = join(HOME, '.synkro', '.mcp-jwt');
9909
10017
  const PORT = parseInt(process.env.SYNKRO_MCP_PORT || '18931', 10);
9910
10018
  const URL = \`http://127.0.0.1:\${PORT}\`;
9911
10019
 
9912
- let token = '';
9913
- try { token = readFileSync(TOKEN_PATH, 'utf-8').trim(); } catch {}
10020
+ // Re-read the JWT on EVERY request, not once at spawn: a \`synkro restart\` rotates
10021
+ // the token, and a proxy that cached the old one would 401 every call until it
10022
+ // was respawned (a manual /mcp).
10023
+ function loadToken() {
10024
+ try { return readFileSync(TOKEN_PATH, 'utf-8').trim(); } catch { return ''; }
10025
+ }
10026
+ function write(obj) { try { process.stdout.write(JSON.stringify(obj) + '\\n'); } catch {} }
10027
+
10028
+ // Answer \`initialize\` LOCALLY without touching the backend. Claude Code latches an
10029
+ // MCP server as failed \u2014 forcing a manual /mcp \u2014 only when the child process exits
10030
+ // or the initialize handshake fails. A \`synkro restart\` takes the backend down for
10031
+ // ~30-60s, so if CC (re)connects in that window a forwarded initialize would fail
10032
+ // and the server would be dead until /mcp. Replying here makes the handshake
10033
+ // succeed unconditionally; tools/list and tools/call still hit the backend.
10034
+ function localInitialize(msg) {
10035
+ return {
10036
+ jsonrpc: '2.0',
10037
+ id: msg.id,
10038
+ result: {
10039
+ protocolVersion: (msg && msg.params && msg.params.protocolVersion) || '2024-11-05',
10040
+ capabilities: { tools: {} },
10041
+ serverInfo: { name: 'synkro-guardrails-local', version: '1.0.0' },
10042
+ },
10043
+ };
10044
+ }
10045
+
10046
+ // Cap a single JSON-RPC line. This proxy only carries small tool-call envelopes;
10047
+ // refusing oversized input keeps a malicious or runaway client from buffering
10048
+ // unbounded memory (CWE-400).
10049
+ const MAX_LINE_BYTES = 4 * 1024 * 1024;
9914
10050
 
9915
10051
  const rl = createInterface({ input: process.stdin, terminal: false });
9916
10052
 
9917
10053
  rl.on('line', async (line) => {
9918
10054
  if (!line.trim()) return;
10055
+ if (line.length > MAX_LINE_BYTES) return;
9919
10056
  let msg;
9920
10057
  try { msg = JSON.parse(line); } catch { return; }
9921
- if (!msg.id && msg.method?.startsWith('notifications/')) return;
10058
+ // Notifications carry no id and need no reply.
10059
+ if (msg.id == null && typeof msg.method === 'string' && msg.method.startsWith('notifications/')) return;
9922
10060
 
9923
- try {
9924
- const resp = await fetch(URL, {
9925
- method: 'POST',
9926
- headers: {
9927
- 'Content-Type': 'application/json',
9928
- 'Authorization': \`Bearer \${token}\`,
9929
- },
9930
- body: line,
9931
- signal: AbortSignal.timeout(30000),
9932
- });
9933
- if (resp.status === 204) return;
9934
- const body = await resp.text();
9935
- process.stdout.write(body + '\\n');
9936
- } catch (err) {
9937
- if (msg.id != null) {
9938
- process.stdout.write(JSON.stringify({
9939
- jsonrpc: '2.0',
9940
- id: msg.id,
9941
- error: { code: -32603, message: 'MCP proxy: HTTP server unreachable' },
9942
- }) + '\\n');
10061
+ // initialize: try the backend briefly for the real instructions, but ALWAYS
10062
+ // fall back to a local reply so the handshake can never block or fail.
10063
+ if (msg.method === 'initialize') {
10064
+ try {
10065
+ const resp = await fetch(URL, {
10066
+ method: 'POST',
10067
+ headers: { 'Content-Type': 'application/json', 'Authorization': \`Bearer \${loadToken()}\` },
10068
+ body: line,
10069
+ signal: AbortSignal.timeout(2500),
10070
+ });
10071
+ if (resp.ok) { process.stdout.write((await resp.text()) + '\\n'); return; }
10072
+ } catch {}
10073
+ write(localInitialize(msg));
10074
+ return;
10075
+ }
10076
+
10077
+ // tool calls: forward, allowing a generous per-attempt window for slow calls
10078
+ // (create + decompose). If the backend is UNREACHABLE (mid-restart), retry only
10079
+ // briefly, then return a CLEAN JSON-RPC error and move on. Never hold the request
10080
+ // open for tens of seconds \u2014 a stdio request held that long wedges CC's MCP
10081
+ // client and every later call queues behind it. A returned error keeps the
10082
+ // client responsive and the server connected; the agent just retries.
10083
+ const REFUSED_BUDGET_MS = 4000;
10084
+ const deadline = Date.now() + REFUSED_BUDGET_MS;
10085
+ let attempt = 0;
10086
+ while (true) {
10087
+ try {
10088
+ const resp = await fetch(URL, {
10089
+ method: 'POST',
10090
+ headers: { 'Content-Type': 'application/json', 'Authorization': \`Bearer \${loadToken()}\` },
10091
+ body: line,
10092
+ signal: AbortSignal.timeout(30000),
10093
+ });
10094
+ if (resp.status === 204) return;
10095
+ process.stdout.write((await resp.text()) + '\\n');
10096
+ return;
10097
+ } catch (err) {
10098
+ // A 30s timeout means the backend was UP but slow \u2014 don't spin. ECONNREFUSED /
10099
+ // fetch failed means it's unreachable \u2192 retry briefly within the budget.
10100
+ const name = err && err.name;
10101
+ const isTimeout = name === 'TimeoutError' || name === 'AbortError';
10102
+ if (!isTimeout && Date.now() < deadline) {
10103
+ attempt++;
10104
+ await new Promise((r) => setTimeout(r, Math.min(500, 150 * attempt)));
10105
+ continue;
10106
+ }
10107
+ if (msg.id != null) {
10108
+ write({
10109
+ jsonrpc: '2.0',
10110
+ id: msg.id,
10111
+ error: { code: -32603, message: 'Synkro MCP backend unavailable (container may be restarting) \u2014 retry in a moment' },
10112
+ });
10113
+ }
10114
+ return;
9943
10115
  }
9944
10116
  }
9945
10117
  });
@@ -11909,7 +12081,7 @@ var args = process.argv.slice(2);
11909
12081
  var cmd = args[0] || "";
11910
12082
  var subArgs = args.slice(1);
11911
12083
  function printVersion() {
11912
- console.log("1.6.52");
12084
+ console.log("1.6.53");
11913
12085
  }
11914
12086
  function printHelp2() {
11915
12087
  console.log(`Synkro CLI \u2014 runtime safety for AI coding agents