@synkro-sh/cli 1.1.7 → 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.
1340
+
1341
+ Zero context bloat: 1 grade per process, system prompt via --system-prompt flag
1342
+ (single inference call, no primer-as-conversation-turn overhead).
1336
1343
 
1337
- Session bloat is bounded: the daemon rotates its claude subprocess every
1338
- ROTATION_CALLS (default 10) 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.
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", "10"))
1369
- ROTATION_AGE_SEC = int(os.environ.get("SYNKRO_DAEMON_ROTATE_AGE", "3600"))
1370
- GRADE_TIMEOUT_SEC = int(os.environ.get("SYNKRO_DAEMON_GRADE_TIMEOUT", "10"))
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,146 +1380,139 @@ def log(msg):
1380
1380
  pass
1381
1381
 
1382
1382
 
1383
- PREWARM_HEADROOM = 4
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
+
1384
1416
 
1385
- class GraderDaemon:
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
+ """
1386
1427
  def __init__(self, primer):
1387
1428
  self.primer = primer or ""
1388
- self.proc = None
1389
- self.calls = 0
1390
- self.start_time = 0.0
1391
- self.lock = threading.Lock()
1392
- self._next_proc = None
1393
- self._prewarm_thread = None
1394
- self._spawn()
1429
+ self._warm_proc = None
1430
+ self._warm_thread = None
1431
+ self._lock = threading.Lock()
1432
+ self._total_grades = 0
1433
+ self._start_prewarm()
1395
1434
 
1396
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]
1397
1445
  return 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
- ],
1446
+ cmd,
1405
1447
  stdin=subprocess.PIPE, stdout=subprocess.PIPE,
1406
1448
  stderr=subprocess.DEVNULL, text=True, bufsize=1,
1407
1449
  )
1408
1450
 
1409
- def _send_to(self, proc, text):
1410
- msg = json.dumps({
1411
- "type": "user",
1412
- "message": {"role": "user", "content": [{"type": "text", "text": text}]},
1413
- "parent_tool_use_id": None,
1414
- "session_id": "",
1415
- })
1416
- proc.stdin.write(msg + "\\n")
1417
- proc.stdin.flush()
1418
-
1419
- def _recv_from(self, proc):
1420
- acc = []
1421
- deadline = time.time() + GRADE_TIMEOUT_SEC
1422
- while True:
1423
- if time.time() > deadline:
1424
- log("recv timeout")
1425
- return ""
1426
- line = proc.stdout.readline()
1427
- if not line:
1428
- return ""
1429
- try:
1430
- obj = json.loads(line)
1431
- except json.JSONDecodeError:
1432
- continue
1433
- t = obj.get("type")
1434
- if t == "assistant":
1435
- for c in obj.get("message", {}).get("content", []):
1436
- if c.get("type") == "text":
1437
- acc.append(c["text"])
1438
- elif t == "result":
1439
- return "".join(acc)
1440
-
1441
- def _spawn(self):
1442
- if self.proc and self.proc.poll() is None:
1443
- try: self.proc.terminate(); self.proc.wait(timeout=3)
1444
- except Exception: self.proc.kill()
1445
- log("spawning claude subprocess")
1446
- self.proc = self._make_proc()
1447
- if self.primer:
1448
- self._send_to(self.proc, self.primer)
1449
- primer_resp = self._recv_from(self.proc)
1450
- log(f"primer ack: {primer_resp[:80]!r}")
1451
- self.calls = 0
1452
- self.start_time = time.time()
1453
- self._next_proc = None
1454
-
1455
- def _prewarm_worker(self):
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):
1456
1458
  try:
1457
- log("pre-warming next subprocess")
1459
+ log("pre-warming process")
1458
1460
  proc = self._make_proc()
1459
- if self.primer:
1460
- self._send_to(proc, self.primer)
1461
- resp = self._recv_from(proc)
1462
- log(f"pre-warm ack: {resp[:80]!r}")
1463
- self._next_proc = proc
1464
- log("pre-warm ready")
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)
1465
1473
  except Exception as e:
1466
1474
  log(f"pre-warm failed: {e}")
1467
- self._next_proc = None
1468
-
1469
- def _maybe_prewarm(self):
1470
- if self._prewarm_thread and self._prewarm_thread.is_alive():
1471
- return
1472
- if self._next_proc is not None:
1473
- return
1474
- if self.calls >= ROTATION_CALLS - PREWARM_HEADROOM:
1475
- self._prewarm_thread = threading.Thread(target=self._prewarm_worker, daemon=True)
1476
- self._prewarm_thread.start()
1477
-
1478
- def _try_rotate(self):
1479
- if self._next_proc and self._next_proc.poll() is None:
1480
- log("hot-swapping to pre-warmed subprocess")
1481
- old = self.proc
1482
- self.proc = self._next_proc
1483
- self._next_proc = None
1484
- self._prewarm_thread = None
1485
- self.calls = 0
1486
- self.start_time = time.time()
1487
- if old and old.poll() is None:
1488
- threading.Thread(target=lambda: (old.terminate(), old.wait()), daemon=True).start()
1489
- return True
1490
- log("pre-warm not ready, deferring rotation")
1491
- return False
1492
1475
 
1493
- def _send(self, text):
1476
+ def _start_prewarm(self):
1477
+ self._warm_thread = threading.Thread(target=self._prewarm, daemon=True)
1478
+ self._warm_thread.start()
1479
+
1480
+ def grade(self, prompt):
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()
1494
1495
  try:
1495
- self._send_to(self.proc, text)
1496
- except (BrokenPipeError, OSError) as e:
1497
- log(f"send broke: {e}; respawn")
1498
- self._spawn()
1499
- self._send_to(self.proc, text)
1500
-
1501
- def _recv(self):
1502
- resp = self._recv_from(self.proc)
1503
- if not resp and (not self.proc or self.proc.poll() is not None):
1504
- log("subprocess died; respawn")
1505
- self._spawn()
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()
1506
1509
  return resp
1507
1510
 
1508
- def grade(self, prompt):
1509
- with self.lock:
1510
- t0 = time.time()
1511
- self._send(prompt)
1512
- resp = self._recv()
1513
- elapsed = (time.time() - t0) * 1000
1514
- self.calls += 1
1515
- log(f"grade #{self.calls} elapsed={elapsed:.0f}ms resp_chars={len(resp)}")
1516
- age = time.time() - self.start_time
1517
- if self.calls >= ROTATION_CALLS or age >= ROTATION_AGE_SEC:
1518
- if not self._try_rotate():
1519
- self._maybe_prewarm()
1520
- else:
1521
- self._maybe_prewarm()
1522
- return resp
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
1523
1516
 
1524
1517
 
1525
1518
  def serve(primer):
@@ -1533,7 +1526,7 @@ def serve(primer):
1533
1526
  os.write(pid_fd, f"{os.getpid()}\\n".encode())
1534
1527
  os.fsync(pid_fd)
1535
1528
 
1536
- daemon = GraderDaemon(primer)
1529
+ grader = WarmGrader(primer)
1537
1530
 
1538
1531
  if SOCK_PATH.exists():
1539
1532
  SOCK_PATH.unlink()
@@ -1547,24 +1540,30 @@ def serve(primer):
1547
1540
  except Exception: pass
1548
1541
  try: PID_FILE.unlink()
1549
1542
  except Exception: pass
1550
- try: daemon.proc and daemon.proc.terminate()
1551
- except Exception: pass
1543
+ grader.shutdown()
1552
1544
  sys.exit(0)
1553
1545
  signal.signal(signal.SIGTERM, cleanup)
1554
1546
  signal.signal(signal.SIGINT, cleanup)
1555
1547
 
1556
- 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}")
1557
1549
 
1550
+ last_activity = time.time()
1551
+ sock.settimeout(30)
1558
1552
  while True:
1559
1553
  try:
1560
1554
  conn, _ = sock.accept()
1561
- 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()
1562
1561
  except Exception as e:
1563
1562
  log(f"accept error: {e}")
1564
1563
  time.sleep(0.1)
1565
1564
 
1566
1565
 
1567
- def _handle_conn(conn, daemon):
1566
+ def _handle_conn(conn, grader):
1568
1567
  try:
1569
1568
  with conn:
1570
1569
  length_bytes = b""
@@ -1581,7 +1580,7 @@ def _handle_conn(conn, daemon):
1581
1580
  chunk = conn.recv(min(65536, length - len(prompt)))
1582
1581
  if not chunk: break
1583
1582
  prompt += chunk
1584
- response = daemon.grade(prompt.decode("utf-8", errors="replace"))
1583
+ response = grader.grade(prompt.decode("utf-8", errors="replace"))
1585
1584
  resp_bytes = response.encode("utf-8")
1586
1585
  conn.sendall(len(resp_bytes).to_bytes(8, "big"))
1587
1586
  conn.sendall(resp_bytes)
@@ -1708,7 +1707,7 @@ JUDGING PRIORITY:
1708
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\`.
1709
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.
1710
1709
 
1711
- 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.
1712
1711
 
1713
1712
  OUTPUT RULES \u2014 strictest possible, no exceptions:
1714
1713
 
@@ -2144,7 +2143,7 @@ function writeConfigEnv(opts) {
2144
2143
  `SYNKRO_GATEWAY_URL=${shellQuoteSingle(safeGateway)}`,
2145
2144
  `SYNKRO_CREDENTIALS_PATH=${shellQuoteSingle(credsPath)}`,
2146
2145
  `SYNKRO_TIER=${shellQuoteSingle(safeTier)}`,
2147
- `SYNKRO_VERSION=${shellQuoteSingle("1.1.7")}`
2146
+ `SYNKRO_VERSION=${shellQuoteSingle("1.1.8")}`
2148
2147
  ];
2149
2148
  if (safeUserId) lines.push(`SYNKRO_USER_ID=${shellQuoteSingle(safeUserId)}`);
2150
2149
  if (safeOrgId) lines.push(`SYNKRO_ORG_ID=${shellQuoteSingle(safeOrgId)}`);
@@ -2269,6 +2268,17 @@ async function installCommand(opts = {}) {
2269
2268
  console.log(` ${scripts.sessionStartScript}
2270
2269
  `);
2271
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
+ }
2272
2282
  console.log("Wrote local-tier grader daemon:");
2273
2283
  console.log(` ${GRADER_DAEMON_PATH}`);
2274
2284
  console.log(` ${GRADER_PRIMER_EDIT_PATH}`);