@synkro-sh/cli 1.6.51 → 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,10 +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 at its git root.
6342
+ // Dormancy: a repo is onboarded only if it has a synkro.toml FILE at its git
6343
+ // root. Guard root !== HOME — ~/.synkro is the config DIRECTORY, so without
6344
+ // this a home-rooted cwd (dotfiles in git, non-git dir under home) could
6345
+ // look onboarded.
6267
6346
  let synkroFileText = '';
6268
- if (root && existsSync(join(root, '.synkro'))) {
6269
- 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 {}
6270
6349
  }
6271
6350
  if (!synkroFileText) {
6272
6351
  if (opts.telemetry) { out(failOpen(harness)); return; }
@@ -6300,16 +6379,32 @@ export async function runStub(surface: string, opts: StubOpts = {}): Promise<voi
6300
6379
  envelope.planText = String(ti.plan || ti.content || payload.plan || '');
6301
6380
  }
6302
6381
 
6303
- 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);
6304
6387
  const url = 'http://127.0.0.1:' + PORT + '/api/scan/' + surface + (harness === 'cursor' ? '?harness=cursor' : '');
6305
- const resp = await fetch(url, {
6306
- method: 'POST',
6307
- headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + loadMcpJwt() },
6308
- body: JSON.stringify(envelope),
6309
- signal: AbortSignal.timeout(timeoutMs),
6310
- });
6311
- const text = await resp.text();
6312
- 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));
6313
6408
  } catch {
6314
6409
  out(failOpen(harness));
6315
6410
  }
@@ -6322,11 +6417,30 @@ export async function runStub(surface: string, opts: StubOpts = {}): Promise<voi
6322
6417
  STUB_INSTALL_SCAN_TS = stubHook("install-scan", "{ needsTranscript: true }");
6323
6418
  STUB_AGENT_JUDGE_TS = stubHook("agent-judge", "{ needsTranscript: true }");
6324
6419
  STUB_PLAN_JUDGE_TS = stubHook("plan-judge", "{ needsPlan: true }");
6325
- 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 }");
6326
6421
  STUB_SESSION_START_TS = stubHook("session-start", "{ telemetry: true }");
6327
6422
  STUB_TRANSCRIPT_SYNC_TS = stubHook("transcript-sync", "{ needsTranscript: true, fullTranscript: true, telemetry: true }");
6328
6423
  STUB_USER_PROMPT_SUBMIT_TS = stubHook("prompt-submit", "{ telemetry: true }");
6329
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
+ `;
6330
6444
  STUB_CURSOR_BASH_JUDGE_TS = stubHook("bash-judge", "{ needsTranscript: true, harness: 'cursor' }");
6331
6445
  STUB_CURSOR_EDIT_CAPTURE_TS = stubHook("edit-precheck", "{ needsFile: true, needsTranscript: true, harness: 'cursor' }");
6332
6446
  STUB_CURSOR_AGENT_CAPTURE_TS = stubHook("agent-judge", "{ needsTranscript: true, harness: 'cursor' }");
@@ -7473,7 +7587,7 @@ function resolveGraderPool(parsed) {
7473
7587
  if (np === "claude_code") counts.claude_code = Math.max(0, Math.floor(v));
7474
7588
  else if (np === "cursor") counts.cursor = Math.max(0, Math.floor(v));
7475
7589
  else if (flagUnknown) {
7476
- 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`);
7477
7591
  }
7478
7592
  }
7479
7593
  };
@@ -7484,7 +7598,7 @@ function resolveGraderPool(parsed) {
7484
7598
  if (rawPool != null && rawPool !== "auto") {
7485
7599
  const np = normalizeProvider(String(rawPool));
7486
7600
  if (np) pool = np;
7487
- 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`);
7488
7602
  }
7489
7603
  if (counts.claude_code != null || counts.cursor != null) {
7490
7604
  return { claudeWorkers: counts.claude_code, cursorWorkers: counts.cursor, pool, warnings };
@@ -7494,57 +7608,52 @@ function resolveGraderPool(parsed) {
7494
7608
  function poolLabel(pool) {
7495
7609
  return pool === "claude_code" ? "claude-code" : pool;
7496
7610
  }
7497
- 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) {
7498
7625
  const result = {};
7499
- const lines = raw.split("\n");
7500
- let currentKey = "";
7501
- let currentObj = null;
7502
- let currentArr = null;
7503
- for (const line of lines) {
7504
- if (!line.trim() || line.trim().startsWith("#")) continue;
7505
- if (/^\S/.test(line) && line.includes(":")) {
7506
- if (currentObj && currentKey) result[currentKey] = currentObj;
7507
- if (currentArr && currentKey) result[currentKey] = currentArr;
7508
- currentObj = null;
7509
- currentArr = null;
7510
- const ci = line.indexOf(":");
7511
- const key = line.slice(0, ci).trim();
7512
- const val = line.slice(ci + 1).trim();
7513
- currentKey = key;
7514
- if (val) {
7515
- if (val === "[]") result[key] = [];
7516
- else if (val === "true") result[key] = true;
7517
- else if (val === "false") result[key] = false;
7518
- else if (/^\d+$/.test(val)) result[key] = parseInt(val, 10);
7519
- else result[key] = val;
7520
- currentKey = "";
7521
- }
7522
- } else if (/^ - /.test(line)) {
7523
- if (!currentArr) currentArr = [];
7524
- currentArr.push(line.replace(/^ - /, "").trim());
7525
- } else if (/^ \S/.test(line) && line.includes(":")) {
7526
- if (!currentObj) currentObj = {};
7527
- const ci = line.indexOf(":");
7528
- const k = line.slice(0, ci).trim();
7529
- const v = line.slice(ci + 1).trim();
7530
- if (v === "true") currentObj[k] = true;
7531
- else if (v === "false") currentObj[k] = false;
7532
- else if (/^\d+$/.test(v)) currentObj[k] = parseInt(v, 10);
7533
- else currentObj[k] = v;
7534
- }
7535
- }
7536
- if (currentObj && currentKey) result[currentKey] = currentObj;
7537
- 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
+ }
7538
7648
  return result;
7539
7649
  }
7540
7650
  function readSynkroFileConfig() {
7541
7651
  try {
7542
7652
  const root = execSync4("git rev-parse --show-toplevel 2>/dev/null", { encoding: "utf-8", timeout: 3e3, stdio: ["pipe", "pipe", "pipe"] }).trim();
7543
7653
  if (!root) return { pool: "auto", warnings: [] };
7544
- const fp = join6(root, ".synkro");
7654
+ const fp = join6(root, "synkro.toml");
7545
7655
  if (!existsSync8(fp)) return { pool: "auto", warnings: [] };
7546
- const raw = readFileSync6(fp, "utf-8");
7547
- const parsed = raw.trimStart().startsWith("{") ? JSON.parse(raw) : parseSynkroYaml(raw);
7656
+ const parsed = parseSynkroToml(readFileSync6(fp, "utf-8"));
7548
7657
  return resolveGraderPool(parsed);
7549
7658
  } catch {
7550
7659
  }
@@ -8393,7 +8502,7 @@ __export(install_exports, {
8393
8502
  syncSkillFiles: () => syncSkillFiles,
8394
8503
  writeHookScripts: () => writeHookScripts
8395
8504
  });
8396
- import { existsSync as existsSync10, mkdirSync as mkdirSync8, writeFileSync as writeFileSync7, chmodSync as chmodSync2, readFileSync as readFileSync8, readdirSync as readdirSync3, unlinkSync as unlinkSync4 } from "fs";
8505
+ import { existsSync as existsSync10, mkdirSync as mkdirSync8, writeFileSync as writeFileSync7, chmodSync as chmodSync2, readFileSync as readFileSync8, readdirSync as readdirSync3, unlinkSync as unlinkSync4, statSync as statSync2 } from "fs";
8397
8506
  import { homedir as homedir8 } from "os";
8398
8507
  import { join as join8 } from "path";
8399
8508
  import { execSync as execSync6 } from "child_process";
@@ -8577,6 +8686,7 @@ function writeHookScripts(mode = "stub") {
8577
8686
  const cursorEditCapturePath = join8(HOOKS_DIR, "cursor-edit-capture.ts");
8578
8687
  const cursorAgentCapturePath = join8(HOOKS_DIR, "cursor-agent-capture.ts");
8579
8688
  const mcpStdioProxyPath = join8(HOOKS_DIR, "mcp-stdio-proxy.ts");
8689
+ const taskActivateIntentScriptPath = join8(HOOKS_DIR, "cc-task-activate-intent.ts");
8580
8690
  if (mode === "stub") {
8581
8691
  const stubCommonPath = join8(HOOKS_DIR, "_synkro-stub-common.ts");
8582
8692
  const stubFiles = [
@@ -8593,6 +8703,7 @@ function writeHookScripts(mode = "stub") {
8593
8703
  [transcriptSyncScriptPath, STUB_TRANSCRIPT_SYNC_TS],
8594
8704
  [userPromptSubmitScriptPath, STUB_USER_PROMPT_SUBMIT_TS],
8595
8705
  [installScanScriptPath, STUB_INSTALL_SCAN_TS],
8706
+ [taskActivateIntentScriptPath, STUB_TASK_ACTIVATE_INTENT_TS],
8596
8707
  [cursorBashJudgePath, STUB_CURSOR_BASH_JUDGE_TS],
8597
8708
  [cursorEditCapturePath, STUB_CURSOR_EDIT_CAPTURE_TS],
8598
8709
  [cursorAgentCapturePath, STUB_CURSOR_AGENT_CAPTURE_TS]
@@ -8624,7 +8735,8 @@ function writeHookScripts(mode = "stub") {
8624
8735
  installScanScript: installScanScriptPath,
8625
8736
  cursorBashJudgeScript: cursorBashJudgePath,
8626
8737
  cursorEditCaptureScript: cursorEditCapturePath,
8627
- cursorAgentCaptureScript: cursorAgentCapturePath
8738
+ cursorAgentCaptureScript: cursorAgentCapturePath,
8739
+ taskActivateIntentScript: taskActivateIntentScriptPath
8628
8740
  };
8629
8741
  }
8630
8742
  writeFileSync7(bashScriptPath, BASH_JUDGE_TS, "utf-8");
@@ -8646,6 +8758,7 @@ function writeHookScripts(mode = "stub") {
8646
8758
  writeFileSync7(cursorAgentCapturePath, CURSOR_AGENT_CAPTURE_TS, "utf-8");
8647
8759
  writeFileSync7(mcpStdioProxyPath, MCP_STDIO_PROXY_SRC, "utf-8");
8648
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");
8649
8762
  chmodSync2(bashScriptPath, 493);
8650
8763
  chmodSync2(bashFollowupScriptPath, 493);
8651
8764
  chmodSync2(editPrecheckScriptPath, 493);
@@ -8665,6 +8778,7 @@ function writeHookScripts(mode = "stub") {
8665
8778
  chmodSync2(cursorAgentCapturePath, 493);
8666
8779
  chmodSync2(mcpStdioProxyPath, 493);
8667
8780
  chmodSync2(installExtractCorePath, 493);
8781
+ chmodSync2(taskActivateIntentScriptPath, 493);
8668
8782
  try {
8669
8783
  unlinkSync4(join8(HOOKS_DIR, "_synkro-stub-common.ts"));
8670
8784
  } catch {
@@ -8684,7 +8798,8 @@ function writeHookScripts(mode = "stub") {
8684
8798
  installScanScript: installScanScriptPath,
8685
8799
  cursorBashJudgeScript: cursorBashJudgePath,
8686
8800
  cursorEditCaptureScript: cursorEditCapturePath,
8687
- cursorAgentCaptureScript: cursorAgentCapturePath
8801
+ cursorAgentCaptureScript: cursorAgentCapturePath,
8802
+ taskActivateIntentScript: taskActivateIntentScriptPath
8688
8803
  };
8689
8804
  }
8690
8805
  function sanitizeConfigValue(raw, maxLen = 256) {
@@ -8716,7 +8831,7 @@ function writeConfigEnv(opts) {
8716
8831
  `SYNKRO_CREDENTIALS_PATH=${shellQuoteSingle(credsPath)}`,
8717
8832
  `SYNKRO_TIER=${shellQuoteSingle(safeTier)}`,
8718
8833
  `SYNKRO_INFERENCE=${shellQuoteSingle(safeInference)}`,
8719
- `SYNKRO_VERSION=${shellQuoteSingle("1.6.51")}`
8834
+ `SYNKRO_VERSION=${shellQuoteSingle("1.6.53")}`
8720
8835
  ];
8721
8836
  if (safeSynkroBin) lines.push(`SYNKRO_CLI_BIN=${shellQuoteSingle(safeSynkroBin)}`);
8722
8837
  if (safeUserId) lines.push(`SYNKRO_USER_ID=${shellQuoteSingle(safeUserId)}`);
@@ -8994,6 +9109,7 @@ async function installCommand(opts = {}) {
8994
9109
  transcriptSyncScriptPath: scripts.transcriptSyncScript,
8995
9110
  userPromptSubmitScriptPath: scripts.userPromptSubmitScript,
8996
9111
  installScanScriptPath: scripts.installScanScript,
9112
+ taskActivateIntentScriptPath: scripts.taskActivateIntentScript,
8997
9113
  skipTranscriptSync: !transcriptConsent
8998
9114
  });
8999
9115
  console.log(`Configured ${agent.name} hooks at ${agent.settingsPath}`);
@@ -9172,7 +9288,7 @@ async function installCommand(opts = {}) {
9172
9288
  claudeWorkers = 0;
9173
9289
  cursorWorkers = 8;
9174
9290
  }
9175
- console.log(` .synkro: explicit workers \u2014 ${claudeWorkers} claude + ${cursorWorkers} cursor`);
9291
+ console.log(` synkro.toml: explicit workers \u2014 ${claudeWorkers} claude + ${cursorWorkers} cursor`);
9176
9292
  } else {
9177
9293
  const totalWorkers = parseInt(process.env.SYNKRO_WORKERS_PER_POOL || "8", 10);
9178
9294
  const synkroFilePool = sf?.grader?.pool || "auto";
@@ -9186,7 +9302,7 @@ async function installCommand(opts = {}) {
9186
9302
  if (hasCursor) providers.push("cursor");
9187
9303
  }
9188
9304
  ({ claudeWorkers, cursorWorkers } = splitWorkers(totalWorkers, providers));
9189
- 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)}`);
9190
9306
  }
9191
9307
  console.log(` worker pool: ${claudeWorkers} claude + ${cursorWorkers} cursor`);
9192
9308
  const connectedRepo = detectGitRepo2() || void 0;
@@ -9317,59 +9433,58 @@ async function installCommand(opts = {}) {
9317
9433
  }
9318
9434
  console.log("\u2713 Synkro installed.");
9319
9435
  }
9320
- 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) {
9321
9450
  const result = {};
9322
- const lines = raw.split("\n");
9323
- let currentKey = "";
9324
- let currentObj = null;
9325
- let currentArr = null;
9326
- for (const line of lines) {
9327
- if (!line.trim() || line.trim().startsWith("#")) continue;
9328
- if (/^\S/.test(line) && line.includes(":")) {
9329
- if (currentObj && currentKey) result[currentKey] = currentObj;
9330
- if (currentArr && currentKey) result[currentKey] = currentArr;
9331
- currentObj = null;
9332
- currentArr = null;
9333
- const colonIdx = line.indexOf(":");
9334
- const key = line.slice(0, colonIdx).trim();
9335
- const val = line.slice(colonIdx + 1).trim();
9336
- currentKey = key;
9337
- if (val) {
9338
- if (val === "[]") result[key] = [];
9339
- else if (val === "true") result[key] = true;
9340
- else if (val === "false") result[key] = false;
9341
- else if (/^\d+$/.test(val)) result[key] = parseInt(val, 10);
9342
- else result[key] = val;
9343
- currentKey = "";
9344
- }
9345
- } else if (/^ - /.test(line)) {
9346
- if (!currentArr) currentArr = [];
9347
- currentArr.push(line.replace(/^ - /, "").trim());
9348
- } else if (/^ \S/.test(line) && line.includes(":")) {
9349
- if (!currentObj) currentObj = {};
9350
- const colonIdx = line.indexOf(":");
9351
- const k = line.slice(0, colonIdx).trim();
9352
- const v = line.slice(colonIdx + 1).trim();
9353
- if (v === "true") currentObj[k] = true;
9354
- else if (v === "false") currentObj[k] = false;
9355
- else if (/^\d+$/.test(v)) currentObj[k] = parseInt(v, 10);
9356
- else currentObj[k] = v;
9357
- }
9358
- }
9359
- if (currentObj && currentKey) result[currentKey] = currentObj;
9360
- 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
+ }
9361
9473
  return result;
9362
9474
  }
9363
- function parseSynkroFileRaw(content) {
9364
- return content.trimStart().startsWith("{") ? JSON.parse(content) : parseSynkroYaml2(content);
9365
- }
9366
9475
  function writeSynkroFileIfMissing(opts) {
9367
9476
  try {
9368
9477
  const root = execSync6("git rev-parse --show-toplevel", { encoding: "utf-8", timeout: 3e3, stdio: ["pipe", "pipe", "pipe"] }).trim();
9369
9478
  if (!root) return;
9370
- const fp = join8(root, ".synkro");
9371
- if (existsSync10(fp)) {
9372
- console.log(` .synkro: ${fp} (existing, respected)`);
9479
+ if (root === homedir8()) return;
9480
+ const fp = join8(root, "synkro.toml");
9481
+ let hasFile = false;
9482
+ try {
9483
+ hasFile = statSync2(fp).isFile();
9484
+ } catch {
9485
+ }
9486
+ if (hasFile) {
9487
+ console.log(` synkro.toml: ${fp} (existing, respected)`);
9373
9488
  return;
9374
9489
  }
9375
9490
  let pool = "auto";
@@ -9383,27 +9498,26 @@ function writeSynkroFileIfMissing(opts) {
9383
9498
  if (!opts.hasClaudeCode) pool = "cursor";
9384
9499
  }
9385
9500
  const mode = opts.gradingMode === "byok" ? "byok" : "local";
9386
- const yaml = [
9387
- "version: 1",
9501
+ const toml = [
9502
+ "version = 1",
9388
9503
  "",
9389
- "harness:",
9390
- ...harness.map((h) => ` - ${h}`),
9504
+ `harness = [${harness.map((h) => `"${h}"`).join(", ")}]`,
9391
9505
  "",
9392
- "grader:",
9393
- ` pool: ${pool}`,
9394
- ` mode: ${mode}`,
9506
+ 'ruleset = "default"',
9395
9507
  "",
9396
- "ruleset: default",
9508
+ "skills = []",
9397
9509
  "",
9398
- "skills: []",
9510
+ "[grader]",
9511
+ `pool = "${pool}"`,
9512
+ `mode = "${mode}"`,
9399
9513
  "",
9400
- "scanning:",
9401
- " cwe: true",
9402
- " cve: true",
9514
+ "[scanning]",
9515
+ "cwe = true",
9516
+ "cve = true",
9403
9517
  ""
9404
9518
  ].join("\n");
9405
- writeFileSync7(fp, yaml, "utf-8");
9406
- 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})`);
9407
9521
  } catch {
9408
9522
  }
9409
9523
  }
@@ -9411,9 +9525,9 @@ function readFullSynkroFile() {
9411
9525
  try {
9412
9526
  const root = execSync6("git rev-parse --show-toplevel", { encoding: "utf-8", timeout: 3e3, stdio: ["pipe", "pipe", "pipe"] }).trim();
9413
9527
  if (!root) return null;
9414
- const fp = join8(root, ".synkro");
9528
+ const fp = join8(root, "synkro.toml");
9415
9529
  if (!existsSync10(fp)) return null;
9416
- const parsed = parseSynkroFileRaw(readFileSync8(fp, "utf-8"));
9530
+ const parsed = parseSynkroToml2(readFileSync8(fp, "utf-8"));
9417
9531
  const valid = ["claude-code", "cursor"];
9418
9532
  const harness = Array.isArray(parsed.harness) ? parsed.harness.filter((h) => valid.includes(h)) : ["claude-code", "cursor"];
9419
9533
  const resolved = resolveGraderPool(parsed);
@@ -9446,7 +9560,7 @@ function reconcileHarness() {
9446
9560
  const wantCC = sf.harness.includes("claude-code");
9447
9561
  const wantCursor = sf.harness.includes("cursor");
9448
9562
  for (const w of sf.warnings) console.warn(` \u26A0 ${w}`);
9449
- 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}`);
9450
9564
  const scripts = writeHookScripts(resolvePersistedHookMode());
9451
9565
  console.log("Wrote hook scripts to ~/.synkro/hooks/");
9452
9566
  const ccSettings = join8(homedir8(), ".claude", "settings.json");
@@ -9463,7 +9577,8 @@ function reconcileHarness() {
9463
9577
  sessionStartScriptPath: scripts.sessionStartScript,
9464
9578
  transcriptSyncScriptPath: scripts.transcriptSyncScript,
9465
9579
  userPromptSubmitScriptPath: scripts.userPromptSubmitScript,
9466
- installScanScriptPath: scripts.installScanScript
9580
+ installScanScriptPath: scripts.installScanScript,
9581
+ taskActivateIntentScriptPath: scripts.taskActivateIntentScript
9467
9582
  });
9468
9583
  console.log(" \u2713 Claude Code hooks registered");
9469
9584
  try {
@@ -9533,27 +9648,29 @@ async function syncSkillFiles() {
9533
9648
  const tasks = resolved.map((fp) => {
9534
9649
  const content = readFileSync8(fp, "utf-8");
9535
9650
  const source = `skill:${fp.split("/").pop()}`;
9536
- 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)`);
9537
9657
  return { source, content };
9538
9658
  }).filter(Boolean);
9539
9659
  if (tasks.length === 0) return;
9540
- const results = await Promise.allSettled(tasks.map(async ({ source, content }) => {
9541
- const resp = await fetch(`http://127.0.0.1:${mcpPort}/api/local/skills/sync`, {
9542
- method: "POST",
9543
- headers: { "Content-Type": "application/json" },
9544
- body: JSON.stringify({ source, content }),
9545
- signal: AbortSignal.timeout(65e3)
9546
- });
9547
- if (!resp.ok) throw new Error(`${resp.status}`);
9548
- const result = await resp.json();
9549
- return { source, ...result };
9550
- }));
9551
- for (const r of results) {
9552
- if (r.status === "fulfilled") {
9553
- const { source, created, message } = r.value;
9554
- console.log(created > 0 ? ` \u2713 skill ${source}: ${created} new rules added` : ` \u2713 skill ${source}: ${message || "up to date"}`);
9555
- } else {
9556
- 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)})`);
9557
9674
  }
9558
9675
  }
9559
9676
  }
@@ -9900,37 +10017,101 @@ const TOKEN_PATH = join(HOME, '.synkro', '.mcp-jwt');
9900
10017
  const PORT = parseInt(process.env.SYNKRO_MCP_PORT || '18931', 10);
9901
10018
  const URL = \`http://127.0.0.1:\${PORT}\`;
9902
10019
 
9903
- let token = '';
9904
- 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;
9905
10050
 
9906
10051
  const rl = createInterface({ input: process.stdin, terminal: false });
9907
10052
 
9908
10053
  rl.on('line', async (line) => {
9909
10054
  if (!line.trim()) return;
10055
+ if (line.length > MAX_LINE_BYTES) return;
9910
10056
  let msg;
9911
10057
  try { msg = JSON.parse(line); } catch { return; }
9912
- 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;
9913
10060
 
9914
- try {
9915
- const resp = await fetch(URL, {
9916
- method: 'POST',
9917
- headers: {
9918
- 'Content-Type': 'application/json',
9919
- 'Authorization': \`Bearer \${token}\`,
9920
- },
9921
- body: line,
9922
- signal: AbortSignal.timeout(30000),
9923
- });
9924
- if (resp.status === 204) return;
9925
- const body = await resp.text();
9926
- process.stdout.write(body + '\\n');
9927
- } catch (err) {
9928
- if (msg.id != null) {
9929
- process.stdout.write(JSON.stringify({
9930
- jsonrpc: '2.0',
9931
- id: msg.id,
9932
- error: { code: -32603, message: 'MCP proxy: HTTP server unreachable' },
9933
- }) + '\\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;
9934
10115
  }
9935
10116
  }
9936
10117
  });
@@ -10549,7 +10730,7 @@ var init_disconnect = __esm({
10549
10730
  });
10550
10731
 
10551
10732
  // cli/local-cc/turnLog.ts
10552
- import { appendFileSync, existsSync as existsSync13, mkdirSync as mkdirSync10, openSync as openSync2, readFileSync as readFileSync10, readSync, closeSync as closeSync2, statSync as statSync2, watchFile, unwatchFile } from "fs";
10733
+ import { appendFileSync, existsSync as existsSync13, mkdirSync as mkdirSync10, openSync as openSync2, readFileSync as readFileSync10, readSync, closeSync as closeSync2, statSync as statSync3, watchFile, unwatchFile } from "fs";
10553
10734
  import { dirname as dirname6, join as join11 } from "path";
10554
10735
  import { homedir as homedir11 } from "os";
10555
10736
  function truncate(s, max = PREVIEW_MAX) {
@@ -10589,7 +10770,7 @@ function appendTurn(args2) {
10589
10770
  function readRecentTurns(n = 20) {
10590
10771
  if (!existsSync13(TURN_LOG_PATH)) return [];
10591
10772
  try {
10592
- const size = statSync2(TURN_LOG_PATH).size;
10773
+ const size = statSync3(TURN_LOG_PATH).size;
10593
10774
  if (size === 0) return [];
10594
10775
  const text = readFileSync10(TURN_LOG_PATH, "utf-8");
10595
10776
  const lines = text.split("\n").filter(Boolean);
@@ -10615,7 +10796,7 @@ function followTurns(onEntry) {
10615
10796
  }
10616
10797
  let lastSize = (() => {
10617
10798
  try {
10618
- return statSync2(TURN_LOG_PATH).size;
10799
+ return statSync3(TURN_LOG_PATH).size;
10619
10800
  } catch {
10620
10801
  return 0;
10621
10802
  }
@@ -11900,7 +12081,7 @@ var args = process.argv.slice(2);
11900
12081
  var cmd = args[0] || "";
11901
12082
  var subArgs = args.slice(1);
11902
12083
  function printVersion() {
11903
- console.log("1.6.51");
12084
+ console.log("1.6.53");
11904
12085
  }
11905
12086
  function printHelp2() {
11906
12087
  console.log(`Synkro CLI \u2014 runtime safety for AI coding agents