@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 +418 -246
- package/dist/bootstrap.js.map +1 -1
- package/package.json +1 -1
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 .
|
|
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
|
-
|
|
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
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
const
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
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 + '
|
|
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 =
|
|
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 .
|
|
1313
|
-
// A repo is "onboarded" to Synkro when it has a .
|
|
1314
|
-
// The hooks are installed globally, so they fire in every repo \u2014
|
|
1315
|
-
// the user never set up. Without a .
|
|
1316
|
-
// rather than attempting a grade that surfaces a confusing
|
|
1317
|
-
// error: the tool call passes through and we show a
|
|
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 .
|
|
1329
|
-
// isFile
|
|
1330
|
-
// .
|
|
1331
|
-
|
|
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
|
-
// .
|
|
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 .
|
|
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 .
|
|
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
|
-
|
|
4208
|
-
|
|
4209
|
-
|
|
4210
|
-
|
|
4211
|
-
|
|
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 .
|
|
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 .
|
|
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 .
|
|
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 .
|
|
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 .
|
|
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)
|
|
6269
|
-
//
|
|
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, '.
|
|
6272
|
-
try { synkroFileText = readFileSync(join(root, '.
|
|
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
|
-
|
|
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
|
|
6309
|
-
|
|
6310
|
-
|
|
6311
|
-
|
|
6312
|
-
|
|
6313
|
-
|
|
6314
|
-
|
|
6315
|
-
|
|
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
|
|
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(
|
|
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(
|
|
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
|
|
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
|
-
|
|
7503
|
-
|
|
7504
|
-
|
|
7505
|
-
|
|
7506
|
-
|
|
7507
|
-
if (
|
|
7508
|
-
|
|
7509
|
-
if (
|
|
7510
|
-
|
|
7511
|
-
|
|
7512
|
-
|
|
7513
|
-
|
|
7514
|
-
|
|
7515
|
-
|
|
7516
|
-
|
|
7517
|
-
|
|
7518
|
-
|
|
7519
|
-
|
|
7520
|
-
|
|
7521
|
-
|
|
7522
|
-
|
|
7523
|
-
|
|
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, ".
|
|
7654
|
+
const fp = join6(root, "synkro.toml");
|
|
7548
7655
|
if (!existsSync8(fp)) return { pool: "auto", warnings: [] };
|
|
7549
|
-
const
|
|
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.
|
|
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(` .
|
|
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(` .
|
|
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
|
|
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
|
-
|
|
9326
|
-
|
|
9327
|
-
|
|
9328
|
-
|
|
9329
|
-
|
|
9330
|
-
if (
|
|
9331
|
-
|
|
9332
|
-
if (
|
|
9333
|
-
|
|
9334
|
-
|
|
9335
|
-
|
|
9336
|
-
|
|
9337
|
-
|
|
9338
|
-
|
|
9339
|
-
|
|
9340
|
-
|
|
9341
|
-
|
|
9342
|
-
|
|
9343
|
-
|
|
9344
|
-
|
|
9345
|
-
|
|
9346
|
-
|
|
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, ".
|
|
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(` .
|
|
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
|
|
9396
|
-
"version
|
|
9501
|
+
const toml = [
|
|
9502
|
+
"version = 1",
|
|
9397
9503
|
"",
|
|
9398
|
-
|
|
9399
|
-
...harness.map((h) => ` - ${h}`),
|
|
9504
|
+
`harness = [${harness.map((h) => `"${h}"`).join(", ")}]`,
|
|
9400
9505
|
"",
|
|
9401
|
-
"
|
|
9402
|
-
` pool: ${pool}`,
|
|
9403
|
-
` mode: ${mode}`,
|
|
9506
|
+
'ruleset = "default"',
|
|
9404
9507
|
"",
|
|
9405
|
-
"
|
|
9508
|
+
"skills = []",
|
|
9406
9509
|
"",
|
|
9407
|
-
"
|
|
9510
|
+
"[grader]",
|
|
9511
|
+
`pool = "${pool}"`,
|
|
9512
|
+
`mode = "${mode}"`,
|
|
9408
9513
|
"",
|
|
9409
|
-
"scanning
|
|
9410
|
-
"
|
|
9411
|
-
"
|
|
9514
|
+
"[scanning]",
|
|
9515
|
+
"cwe = true",
|
|
9516
|
+
"cve = true",
|
|
9412
9517
|
""
|
|
9413
9518
|
].join("\n");
|
|
9414
|
-
writeFileSync7(fp,
|
|
9415
|
-
console.log(` .
|
|
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, ".
|
|
9528
|
+
const fp = join8(root, "synkro.toml");
|
|
9424
9529
|
if (!existsSync10(fp)) return null;
|
|
9425
|
-
const parsed =
|
|
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(
|
|
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())
|
|
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
|
-
|
|
9550
|
-
|
|
9551
|
-
|
|
9552
|
-
|
|
9553
|
-
|
|
9554
|
-
|
|
9555
|
-
|
|
9556
|
-
|
|
9557
|
-
|
|
9558
|
-
|
|
9559
|
-
|
|
9560
|
-
|
|
9561
|
-
|
|
9562
|
-
|
|
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
|
-
|
|
9913
|
-
|
|
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
|
-
|
|
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
|
-
|
|
9925
|
-
|
|
9926
|
-
|
|
9927
|
-
|
|
9928
|
-
'
|
|
9929
|
-
|
|
9930
|
-
|
|
9931
|
-
|
|
9932
|
-
|
|
9933
|
-
|
|
9934
|
-
|
|
9935
|
-
|
|
9936
|
-
|
|
9937
|
-
|
|
9938
|
-
|
|
9939
|
-
|
|
9940
|
-
|
|
9941
|
-
|
|
9942
|
-
|
|
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.
|
|
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
|