@synkro-sh/cli 1.1.6 → 1.1.8

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
@@ -815,9 +815,12 @@ if [ "$SYNKRO_INFERENCE_TIER" = "free" ] && command -v claude >/dev/null 2>&1; t
815
815
  # local CC inference that's unacceptable. Pre-stuffing the rules costs
816
816
  # tokens but keeps grade latency in the 1-3s range. Bounded at 1.5s; on
817
817
  # failure proceed with empty rules (degrades to baseline-only judging).
818
- ORG_RULES=$(curl -sS "\${GATEWAY_URL}/api/v1/cli/pr-rules" \\
818
+ ORG_RULES=$(printf '%s' "$PROPOSED" | head -c 8000 \\
819
+ | jq -Rs '{content: .}' \\
820
+ | curl -sS "\${GATEWAY_URL}/api/v1/cli/pr-rules?top_k=20" \\
821
+ -X POST -H "Content-Type: application/json" \\
819
822
  -H "Authorization: Bearer $JWT" \\
820
- --max-time 1.5 2>/dev/null \\
823
+ -d @- --max-time 2 2>/dev/null \\
821
824
  | jq -c '[.rules[]? | {rule_id, text, severity, category, mode}]' 2>/dev/null || echo "[]")
822
825
  if [ -z "$ORG_RULES" ] || [ "$ORG_RULES" = "null" ]; then ORG_RULES="[]"; fi
823
826
 
@@ -1262,16 +1265,18 @@ if [ -z "$RESP" ]; then
1262
1265
  exit 0
1263
1266
  fi
1264
1267
 
1268
+ PLAN_NUDGE="Before implementing any multi-step plan, call the synkro-guardrails analyze_plan tool with your implementation plan to check for relevant org coding rules."
1269
+
1265
1270
  OPEN=$(echo "$RESP" | jq -r '.open_count // 0' 2>/dev/null)
1266
1271
  if [ "$OPEN" = "0" ] || [ -z "$OPEN" ]; then
1267
- echo '{}'
1272
+ jq -n --arg sys_msg "[synkro] $PLAN_NUDGE" '{ systemMessage: $sys_msg }'
1268
1273
  exit 0
1269
1274
  fi
1270
1275
 
1271
1276
  if [ "$OPEN" = "1" ]; then
1272
- SYS_MSG="[synkro] session start \u2192 1 open finding in this repo from a prior session"
1277
+ SYS_MSG="[synkro] session start \u2192 1 open finding in this repo from a prior session. $PLAN_NUDGE"
1273
1278
  else
1274
- SYS_MSG="[synkro] session start \u2192 \${OPEN} open findings in this repo from prior sessions"
1279
+ SYS_MSG="[synkro] session start \u2192 \${OPEN} open findings in this repo from prior sessions. $PLAN_NUDGE"
1275
1280
  fi
1276
1281
 
1277
1282
  jq -n --arg sys_msg "$SYS_MSG" '{ systemMessage: $sys_msg }'
@@ -1329,15 +1334,14 @@ var init_graderDaemon = __esm({
1329
1334
  "use strict";
1330
1335
  GRADER_DAEMON_PY = `#!/usr/bin/env python3
1331
1336
  """
1332
- Synkro grader daemon \u2014 long-lived \`claude --print\` process with stream-json
1333
- IPC, fronted by a Unix socket. Hook scripts ship grading prompts to it; it
1334
- returns the assistant's response text. ONE CC startup (~3.5s) amortizes
1335
- across N gradings.
1337
+ Synkro warm-pool grader \u2014 pre-warmed \`claude --print --system-prompt\` process
1338
+ pool fronted by a Unix socket. Each grade uses one warm process and kills it;
1339
+ a replacement is pre-warmed in the background.
1336
1340
 
1337
- Session bloat is bounded: the daemon rotates its claude subprocess every
1338
- ROTATION_CALLS (default 30) gradings or ROTATION_AGE_SEC (default 1h),
1339
- whichever comes first. Each rotation eats a one-time ~5s primer cost; calls
1340
- in between target ~2-3s steady-state.
1341
+ Zero context bloat: 1 grade per process, system prompt via --system-prompt flag
1342
+ (single inference call, no primer-as-conversation-turn overhead).
1343
+
1344
+ Warm steady-state: ~2-3s per grade. Cold fallback: ~5-6s if pre-warm not ready.
1341
1345
 
1342
1346
  Commands:
1343
1347
  start [primer-path] - bring up daemon if not running
@@ -1346,13 +1350,10 @@ Commands:
1346
1350
  status - print "running"/"stopped"
1347
1351
  """
1348
1352
 
1349
- import os, sys, json, socket, time, atexit, signal, fcntl, re
1353
+ import os, sys, json, socket, time, signal, fcntl, re
1350
1354
  import subprocess, threading
1351
1355
  from pathlib import Path
1352
1356
 
1353
- # Each "mode" gets its own daemon process: separate socket, pid, log.
1354
- # Modes: "edit" (precheck + post-edit, schema {ok, severity, ...}) and "bash"
1355
- # (schema {verdict, severity, ...}). Selected via --mode <name>; default "edit".
1356
1357
  ALLOWED_MODE_RE = re.compile(r"^[a-z][a-z0-9_-]{0,30}$")
1357
1358
  DAEMON_BASE = Path.home() / ".synkro" / "daemon"
1358
1359
  DAEMON_BASE.mkdir(parents=True, exist_ok=True, mode=0o700)
@@ -1365,11 +1366,10 @@ def mode_paths(mode):
1365
1366
  MODE = "edit"
1366
1367
  PID_FILE, SOCK_PATH, LOG_FILE = mode_paths(MODE)
1367
1368
 
1368
- ROTATION_CALLS = int(os.environ.get("SYNKRO_DAEMON_ROTATE_CALLS", "30"))
1369
- ROTATION_AGE_SEC = int(os.environ.get("SYNKRO_DAEMON_ROTATE_AGE", "3600"))
1370
- GRADE_TIMEOUT_SEC = 60
1369
+ GRADE_TIMEOUT_SEC = int(os.environ.get("SYNKRO_DAEMON_GRADE_TIMEOUT", "45"))
1371
1370
  DEFAULT_MODEL = os.environ.get("SYNKRO_DAEMON_MODEL", "claude-sonnet-4-6")
1372
1371
  MAX_PROMPT_BYTES = 4 * 1024 * 1024
1372
+ IDLE_SHUTDOWN_SEC = int(os.environ.get("SYNKRO_DAEMON_IDLE_TIMEOUT", "600"))
1373
1373
 
1374
1374
 
1375
1375
  def log(msg):
@@ -1380,92 +1380,139 @@ def log(msg):
1380
1380
  pass
1381
1381
 
1382
1382
 
1383
- class GraderDaemon:
1383
+ def _read_response(proc, timeout=45):
1384
+ acc = []
1385
+ deadline = time.time() + timeout
1386
+ while True:
1387
+ if time.time() > deadline:
1388
+ log("read timeout")
1389
+ return ""
1390
+ line = proc.stdout.readline()
1391
+ if not line:
1392
+ return ""
1393
+ try:
1394
+ obj = json.loads(line)
1395
+ except json.JSONDecodeError:
1396
+ continue
1397
+ t = obj.get("type")
1398
+ if t == "assistant":
1399
+ for c in obj.get("message", {}).get("content", []):
1400
+ if c.get("type") == "text":
1401
+ acc.append(c["text"])
1402
+ elif t == "result":
1403
+ return "".join(acc)
1404
+
1405
+
1406
+ def _send_msg(proc, text):
1407
+ msg = json.dumps({
1408
+ "type": "user",
1409
+ "message": {"role": "user", "content": [{"type": "text", "text": text}]},
1410
+ "parent_tool_use_id": None,
1411
+ "session_id": "",
1412
+ })
1413
+ proc.stdin.write(msg + "\\n")
1414
+ proc.stdin.flush()
1415
+
1416
+
1417
+ class WarmGrader:
1418
+ """
1419
+ Keeps one pre-warmed claude process ready. Each grade pulls the warm
1420
+ process, sends one prompt, reads the verdict, kills the process, and
1421
+ starts pre-warming a replacement in the background.
1422
+
1423
+ The warm process has the system prompt loaded via --system-prompt and
1424
+ its KV cache primed by a warmup turn. The actual grade is a single
1425
+ inference call that benefits from the cached system prompt tokens.
1426
+ """
1384
1427
  def __init__(self, primer):
1385
1428
  self.primer = primer or ""
1386
- self.proc = None
1387
- self.calls = 0
1388
- self.start_time = 0.0
1389
- self.lock = threading.Lock()
1390
- self._spawn()
1391
-
1392
- def _spawn(self):
1393
- if self.proc and self.proc.poll() is None:
1394
- try: self.proc.terminate(); self.proc.wait(timeout=3)
1395
- except Exception: self.proc.kill()
1396
- log("spawning claude subprocess")
1397
- self.proc = subprocess.Popen(
1398
- [
1399
- "claude", "--print", "--model", DEFAULT_MODEL,
1400
- "--input-format=stream-json",
1401
- "--output-format=stream-json",
1402
- "--verbose",
1403
- "--no-session-persistence",
1404
- ],
1429
+ self._warm_proc = None
1430
+ self._warm_thread = None
1431
+ self._lock = threading.Lock()
1432
+ self._total_grades = 0
1433
+ self._start_prewarm()
1434
+
1435
+ def _make_proc(self):
1436
+ cmd = [
1437
+ "claude", "--print", "--model", DEFAULT_MODEL,
1438
+ "--input-format=stream-json",
1439
+ "--output-format=stream-json",
1440
+ "--verbose",
1441
+ "--no-session-persistence",
1442
+ ]
1443
+ if self.primer:
1444
+ cmd += ["--system-prompt", self.primer]
1445
+ return subprocess.Popen(
1446
+ cmd,
1405
1447
  stdin=subprocess.PIPE, stdout=subprocess.PIPE,
1406
1448
  stderr=subprocess.DEVNULL, text=True, bufsize=1,
1407
1449
  )
1408
- if self.primer:
1409
- self._send(self.primer)
1410
- primer_resp = self._recv()
1411
- log(f"primer ack: {primer_resp[:80]!r}")
1412
- self.calls = 0
1413
- self.start_time = time.time()
1414
-
1415
- def _send(self, text):
1416
- msg = json.dumps({
1417
- "type": "user",
1418
- "message": {"role": "user", "content": [{"type": "text", "text": text}]},
1419
- "parent_tool_use_id": None,
1420
- "session_id": "",
1421
- })
1450
+
1451
+ def _kill_proc(self, proc):
1452
+ try: proc.stdin.close()
1453
+ except Exception: pass
1454
+ try: proc.kill(); proc.wait(timeout=2)
1455
+ except Exception: pass
1456
+
1457
+ def _prewarm(self):
1422
1458
  try:
1423
- self.proc.stdin.write(msg + "\\n")
1424
- self.proc.stdin.flush()
1425
- except (BrokenPipeError, OSError) as e:
1426
- log(f"send broke: {e}; respawn")
1427
- self._spawn()
1428
- self.proc.stdin.write(msg + "\\n")
1429
- self.proc.stdin.flush()
1430
-
1431
- def _recv(self):
1432
- acc = []
1433
- deadline = time.time() + GRADE_TIMEOUT_SEC
1434
- while True:
1435
- if time.time() > deadline:
1436
- log("recv timeout; respawn")
1437
- self._spawn()
1438
- return ""
1439
- line = self.proc.stdout.readline()
1440
- if not line:
1441
- log("subprocess closed stdout; respawn")
1442
- self._spawn()
1443
- return ""
1444
- try:
1445
- obj = json.loads(line)
1446
- except json.JSONDecodeError:
1447
- continue
1448
- t = obj.get("type")
1449
- if t == "assistant":
1450
- for c in obj.get("message", {}).get("content", []):
1451
- if c.get("type") == "text":
1452
- acc.append(c["text"])
1453
- elif t == "result":
1454
- return "".join(acc)
1459
+ log("pre-warming process")
1460
+ proc = self._make_proc()
1461
+ _send_msg(proc, "Ready")
1462
+ resp = _read_response(proc, timeout=30)
1463
+ if resp:
1464
+ with self._lock:
1465
+ old = self._warm_proc
1466
+ self._warm_proc = proc
1467
+ if old:
1468
+ self._kill_proc(old)
1469
+ log(f"pre-warm ready ({len(resp)} chars)")
1470
+ else:
1471
+ log("pre-warm response empty")
1472
+ self._kill_proc(proc)
1473
+ except Exception as e:
1474
+ log(f"pre-warm failed: {e}")
1475
+
1476
+ def _start_prewarm(self):
1477
+ self._warm_thread = threading.Thread(target=self._prewarm, daemon=True)
1478
+ self._warm_thread.start()
1455
1479
 
1456
1480
  def grade(self, prompt):
1457
- with self.lock:
1458
- age = time.time() - self.start_time
1459
- if self.calls >= ROTATION_CALLS or age >= ROTATION_AGE_SEC:
1460
- log(f"rotating: calls={self.calls} age={age:.0f}s")
1461
- self._spawn()
1462
- t0 = time.time()
1463
- self._send(prompt)
1464
- resp = self._recv()
1465
- elapsed = (time.time() - t0) * 1000
1466
- self.calls += 1
1467
- log(f"grade #{self.calls} elapsed={elapsed:.0f}ms resp_chars={len(resp)}")
1468
- return resp
1481
+ if self._warm_thread:
1482
+ self._warm_thread.join(timeout=60)
1483
+
1484
+ with self._lock:
1485
+ proc = self._warm_proc
1486
+ self._warm_proc = None
1487
+
1488
+ warm = True
1489
+ if not proc or proc.poll() is not None:
1490
+ log("no warm process, cold fallback")
1491
+ proc = self._make_proc()
1492
+ warm = False
1493
+
1494
+ t0 = time.time()
1495
+ try:
1496
+ _send_msg(proc, prompt)
1497
+ resp = _read_response(proc, timeout=GRADE_TIMEOUT_SEC)
1498
+ except Exception as e:
1499
+ log(f"grade error: {e}")
1500
+ resp = ""
1501
+ finally:
1502
+ self._kill_proc(proc)
1503
+
1504
+ elapsed = (time.time() - t0) * 1000
1505
+ self._total_grades += 1
1506
+ log(f"grade #{self._total_grades} {'warm' if warm else 'cold'} elapsed={elapsed:.0f}ms resp={len(resp)}ch")
1507
+
1508
+ self._start_prewarm()
1509
+ return resp
1510
+
1511
+ def shutdown(self):
1512
+ with self._lock:
1513
+ if self._warm_proc:
1514
+ self._kill_proc(self._warm_proc)
1515
+ self._warm_proc = None
1469
1516
 
1470
1517
 
1471
1518
  def serve(primer):
@@ -1479,7 +1526,7 @@ def serve(primer):
1479
1526
  os.write(pid_fd, f"{os.getpid()}\\n".encode())
1480
1527
  os.fsync(pid_fd)
1481
1528
 
1482
- daemon = GraderDaemon(primer)
1529
+ grader = WarmGrader(primer)
1483
1530
 
1484
1531
  if SOCK_PATH.exists():
1485
1532
  SOCK_PATH.unlink()
@@ -1493,24 +1540,30 @@ def serve(primer):
1493
1540
  except Exception: pass
1494
1541
  try: PID_FILE.unlink()
1495
1542
  except Exception: pass
1496
- try: daemon.proc and daemon.proc.terminate()
1497
- except Exception: pass
1543
+ grader.shutdown()
1498
1544
  sys.exit(0)
1499
1545
  signal.signal(signal.SIGTERM, cleanup)
1500
1546
  signal.signal(signal.SIGINT, cleanup)
1501
1547
 
1502
- log(f"daemon ready model={DEFAULT_MODEL} sock={SOCK_PATH}")
1548
+ log(f"daemon ready model={DEFAULT_MODEL} idle_shutdown={IDLE_SHUTDOWN_SEC}s sock={SOCK_PATH}")
1503
1549
 
1550
+ last_activity = time.time()
1551
+ sock.settimeout(30)
1504
1552
  while True:
1505
1553
  try:
1506
1554
  conn, _ = sock.accept()
1507
- threading.Thread(target=_handle_conn, args=(conn, daemon), daemon=True).start()
1555
+ last_activity = time.time()
1556
+ threading.Thread(target=_handle_conn, args=(conn, grader), daemon=True).start()
1557
+ except socket.timeout:
1558
+ if time.time() - last_activity > IDLE_SHUTDOWN_SEC:
1559
+ log(f"idle for {IDLE_SHUTDOWN_SEC}s, shutting down")
1560
+ cleanup()
1508
1561
  except Exception as e:
1509
1562
  log(f"accept error: {e}")
1510
1563
  time.sleep(0.1)
1511
1564
 
1512
1565
 
1513
- def _handle_conn(conn, daemon):
1566
+ def _handle_conn(conn, grader):
1514
1567
  try:
1515
1568
  with conn:
1516
1569
  length_bytes = b""
@@ -1527,7 +1580,7 @@ def _handle_conn(conn, daemon):
1527
1580
  chunk = conn.recv(min(65536, length - len(prompt)))
1528
1581
  if not chunk: break
1529
1582
  prompt += chunk
1530
- response = daemon.grade(prompt.decode("utf-8", errors="replace"))
1583
+ response = grader.grade(prompt.decode("utf-8", errors="replace"))
1531
1584
  resp_bytes = response.encode("utf-8")
1532
1585
  conn.sendall(len(resp_bytes).to_bytes(8, "big"))
1533
1586
  conn.sendall(resp_bytes)
@@ -1654,7 +1707,7 @@ JUDGING PRIORITY:
1654
1707
  2. BASELINE security issues \u2014 hardcoded real-looking secrets, eval/exec on user input, SQL string concat with untrusted input, MD5/SHA1 for security-sensitive purposes, unsafe deserialization, command injection, path traversal, missing auth on routes that mutate user/billing data, weak random for tokens, broken JWT verification, CORS misconfig, env-dump logging. Flag these even if no org rule covers them \u2014 they're universally bad. Use a sensible snake_case rule_id like \`no-hardcoded-secrets\`, \`eval-on-user-input\`, \`sql-string-concat\`.
1655
1708
  3. Stylistic issues, placeholder fixtures, test files (path under /tests/, /__tests__/, *.test.*), and config-only files are NOT security issues \u2014 return ok=true.
1656
1709
 
1657
- INDEPENDENCE: Each grade request is INDEPENDENT. Even if you can see prior turns in your context (the daemon reuses one process across grades), treat them as irrelevant. Judge ONLY the current request's File / User intent / Org rules / Diff. Prior "allows" do NOT authorize the current request \u2014 re-evaluate fresh against the rules in THIS prompt.
1710
+ INDEPENDENCE: Each grade request is INDEPENDENT. Judge ONLY the current request's File / User intent / Org rules / Diff. Prior "allows" do NOT authorize the current request \u2014 re-evaluate fresh against the rules in THIS prompt.
1658
1711
 
1659
1712
  OUTPUT RULES \u2014 strictest possible, no exceptions:
1660
1713
 
@@ -2090,7 +2143,7 @@ function writeConfigEnv(opts) {
2090
2143
  `SYNKRO_GATEWAY_URL=${shellQuoteSingle(safeGateway)}`,
2091
2144
  `SYNKRO_CREDENTIALS_PATH=${shellQuoteSingle(credsPath)}`,
2092
2145
  `SYNKRO_TIER=${shellQuoteSingle(safeTier)}`,
2093
- `SYNKRO_VERSION=${shellQuoteSingle("1.1.6")}`
2146
+ `SYNKRO_VERSION=${shellQuoteSingle("1.1.8")}`
2094
2147
  ];
2095
2148
  if (safeUserId) lines.push(`SYNKRO_USER_ID=${shellQuoteSingle(safeUserId)}`);
2096
2149
  if (safeOrgId) lines.push(`SYNKRO_ORG_ID=${shellQuoteSingle(safeOrgId)}`);
@@ -2215,6 +2268,17 @@ async function installCommand(opts = {}) {
2215
2268
  console.log(` ${scripts.sessionStartScript}
2216
2269
  `);
2217
2270
  writeGraderDaemon();
2271
+ for (const mode of ["edit", "bash"]) {
2272
+ const pidFile = join4(SYNKRO_DIR, "daemon", mode, "daemon.pid");
2273
+ try {
2274
+ const pid = parseInt(readFileSync4(pidFile, "utf-8").trim(), 10);
2275
+ if (pid > 0) {
2276
+ process.kill(pid, "SIGTERM");
2277
+ console.log(`Stopped stale ${mode} daemon (pid ${pid})`);
2278
+ }
2279
+ } catch {
2280
+ }
2281
+ }
2218
2282
  console.log("Wrote local-tier grader daemon:");
2219
2283
  console.log(` ${GRADER_DAEMON_PATH}`);
2220
2284
  console.log(` ${GRADER_PRIMER_EDIT_PATH}`);