@synkro-sh/cli 1.1.7 → 1.2.0

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
@@ -301,6 +301,8 @@ var init_hookScripts = __esm({
301
301
  # Auth: reads access_token from ~/.synkro/credentials.json, sends Authorization: Bearer.
302
302
  set -e
303
303
 
304
+ synkro_log() { echo "[synkro] $1" >&2; }
305
+
304
306
  # Load config
305
307
  CONFIG_FILE="$HOME/.synkro/config.env"
306
308
  if [ -f "$CONFIG_FILE" ]; then
@@ -315,11 +317,13 @@ CREDS_PATH="\${SYNKRO_CREDENTIALS_PATH:-$HOME/.synkro/credentials.json}"
315
317
 
316
318
  # Fail open if not authed
317
319
  if [ ! -f "$CREDS_PATH" ]; then
320
+
318
321
  echo '{}'
319
322
  exit 0
320
323
  fi
321
324
  JWT=$(jq -r '.access_token // empty' "$CREDS_PATH" 2>/dev/null)
322
325
  if [ -z "$JWT" ]; then
326
+
323
327
  echo '{}'
324
328
  exit 0
325
329
  fi
@@ -344,6 +348,9 @@ if [ -z "$COMMAND" ]; then
344
348
  exit 0
345
349
  fi
346
350
 
351
+ CMD_SHORT=$(printf '%s' "$COMMAND" | head -c 80)
352
+ synkro_log "bashGuard checking: $CMD_SHORT"
353
+
347
354
  # NO hard regex gate \u2014 server-side bashShapes runs the universal pattern set
348
355
  # (including HTTP-payload destructive shapes like graphql_destructive_mutation
349
356
  # and http_method_delete) and returns a "trivial" fast-path verdict for boring
@@ -357,6 +364,14 @@ SESSION_ID=$(echo "$PAYLOAD" | jq -r '.session_id // empty' 2>/dev/null)
357
364
  TOOL_USE_ID=$(echo "$PAYLOAD" | jq -r '.tool_use_id // empty' 2>/dev/null)
358
365
  CWD=$(echo "$PAYLOAD" | jq -r '.cwd // empty' 2>/dev/null)
359
366
  TOOL_INPUT=$(echo "$PAYLOAD" | jq -c '.tool_input // {}' 2>/dev/null)
367
+ # Detect git remote origin \u2192 repo identity (e.g. "owner/repo")
368
+ GIT_REPO=""
369
+ if command -v git >/dev/null 2>&1; then
370
+ _REMOTE=$(git -C "\${CWD:-.}" remote get-url origin 2>/dev/null || true)
371
+ if [ -n "$_REMOTE" ]; then
372
+ GIT_REPO=$(echo "$_REMOTE" | sed -E 's|^git@[^:]+:||; s|^https?://[^/]+/||; s|\\.git$||')
373
+ fi
374
+ fi
360
375
  # Headless detection \u2014 when no human is in the loop, ASK is a no-op so we
361
376
  # fail-closed by upgrading high-tier findings to deny.
362
377
  PERMISSION_MODE=$(echo "$PAYLOAD" | jq -r '.permission_mode // empty' 2>/dev/null)
@@ -404,6 +419,7 @@ BODY=$(jq -n \\
404
419
  --arg session_id "$SESSION_ID" \\
405
420
  --arg tool_use_id "$TOOL_USE_ID" \\
406
421
  --arg cwd "$CWD" \\
422
+ --arg repo "$GIT_REPO" \\
407
423
  '{
408
424
  kind: "bash_judge",
409
425
  tool_input: $tool_input,
@@ -412,7 +428,8 @@ BODY=$(jq -n \\
412
428
  recent_actions: $recent_actions,
413
429
  session_id: (if ($session_id | length) > 0 then $session_id else null end),
414
430
  tool_use_id: (if ($tool_use_id | length) > 0 then $tool_use_id else null end),
415
- cwd: (if ($cwd | length) > 0 then $cwd else null end)
431
+ cwd: (if ($cwd | length) > 0 then $cwd else null end),
432
+ repo: (if ($repo | length) > 0 then $repo else null end)
416
433
  }')
417
434
 
418
435
  # Helper: refresh JWT via /api/auth/refresh and rewrite credentials.json.
@@ -464,28 +481,45 @@ if [ -z "$SYNKRO_INFERENCE_TIER" ]; then
464
481
  fi
465
482
  SYNKRO_INFERENCE_TIER="\${SYNKRO_INFERENCE_TIER:-free}"
466
483
 
484
+
467
485
  if [ "$SYNKRO_INFERENCE_TIER" = "free" ] && command -v claude >/dev/null 2>&1; then
468
486
  # \u2500\u2500\u2500 FREE TIER: grade via the persistent claude daemon (mode=bash). \u2500\u2500\u2500
487
+
488
+ # Fetch org guardrail rules relevant to this command (same as edit hook).
489
+ ORG_RULES=$(printf '%s' "$COMMAND" | head -c 4000 \\
490
+ | jq -Rs '{content: .}' \\
491
+ | curl -sS "\${GATEWAY_URL}/api/v1/cli/pr-rules?top_k=15" \\
492
+ -X POST -H "Content-Type: application/json" \\
493
+ -H "Authorization: Bearer $JWT" \\
494
+ -d @- --max-time 2 2>/dev/null \\
495
+ | jq -c '[.rules[]? | {rule_id, text, severity, category}]' 2>/dev/null || echo "[]")
496
+ if [ -z "$ORG_RULES" ] || [ "$ORG_RULES" = "null" ]; then ORG_RULES="[]"; fi
497
+
469
498
  GRADER_PROMPT_FILE=$(mktemp -t synkro-bash-prompt.XXXXXX)
470
499
  trap "rm -f \\"$GRADER_PROMPT_FILE\\"" EXIT
471
500
  printf 'Proposed command: %s\\n' "$COMMAND" > "$GRADER_PROMPT_FILE"
472
501
  printf 'User intent: %s\\n' "\${USER_INTENT:-none stated}" >> "$GRADER_PROMPT_FILE"
473
502
  printf 'Recent user messages: %s\\n' "$RECENT_USER_MESSAGES" >> "$GRADER_PROMPT_FILE"
474
503
  printf 'Recent actions: %s\\n' "$RECENT_ACTIONS" >> "$GRADER_PROMPT_FILE"
504
+ printf 'Org rules: %s\\n\\n' "$ORG_RULES" >> "$GRADER_PROMPT_FILE"
475
505
 
476
506
  if [ -x "$HOME/.synkro/bin/grader_daemon.py" ] && [ -f "$HOME/.synkro/grader-primer-bash.txt" ] && command -v python3 >/dev/null 2>&1; then
507
+
477
508
  CC_RESP=$(python3 "$HOME/.synkro/bin/grader_daemon.py" --mode bash grade "$HOME/.synkro/grader-primer-bash.txt" < "$GRADER_PROMPT_FILE" 2>/dev/null || echo "")
478
509
  else
510
+
479
511
  CC_RESP=$(claude --print --model claude-sonnet-4-6 --no-session-persistence < "$GRADER_PROMPT_FILE" 2>/dev/null || echo "")
480
512
  fi
481
513
  V_INNER=$(printf '%s' "$CC_RESP" | tr '\\n' ' ' | grep -oE '<synkro-verdict>[^<]*</synkro-verdict>' | tail -1 | sed -E 's|^<synkro-verdict>||; s|</synkro-verdict>$||')
482
514
  if [ -z "$V_INNER" ] || ! echo "$V_INNER" | jq -e '.severity' >/dev/null 2>&1; then
515
+ synkro_log "bashGuard $CMD_SHORT \u2192 error (no verdict)"
483
516
  echo '{}'
484
517
  exit 0
485
518
  fi
486
519
  VERDICT="$V_INNER"
487
520
  else
488
521
  # \u2500\u2500\u2500 FAST TIER: server-side Cerebras grading. \u2500\u2500\u2500
522
+
489
523
  VERDICT=$(curl -sS -X POST "\${GATEWAY_URL}/api/v1/judge" \\
490
524
  -H "Content-Type: application/json" \\
491
525
  -H "Authorization: Bearer $JWT" \\
@@ -493,6 +527,7 @@ else
493
527
  --max-time 6 2>/dev/null || echo "")
494
528
 
495
529
  if echo "$VERDICT" | grep -qE '"detail":"Token has expired|"detail":"Invalid or expired token'; then
530
+
496
531
  if refresh_jwt; then
497
532
  VERDICT=$(curl -sS -X POST "\${GATEWAY_URL}/api/v1/judge" \\
498
533
  -H "Content-Type: application/json" \\
@@ -504,6 +539,7 @@ else
504
539
  fi
505
540
 
506
541
  if [ -z "$VERDICT" ]; then
542
+ synkro_log "bashGuard $CMD_SHORT \u2192 error (timeout)"
507
543
  echo '{}'
508
544
  exit 0
509
545
  fi
@@ -529,6 +565,7 @@ CATEGORY=$(echo "$VERDICT" | jq -r '.category // "destructive_command"' 2>/dev/n
529
565
 
530
566
  case "$SEVERITY" in
531
567
  critical)
568
+ synkro_log "bashGuard $CMD_SHORT \u2192 BLOCKED ($CATEGORY): $REASONING"
532
569
  SYS_MSG="[synkro] bashGuard \u2192 critical: \${REASONING}"
533
570
  if [ -n "$ALTERNATIVE" ] && [ "$ALTERNATIVE" != "null" ]; then
534
571
  SYS_MSG="\${SYS_MSG}\\n[synkro] suggested \u2192 \${ALTERNATIVE}"
@@ -553,6 +590,7 @@ case "$SEVERITY" in
553
590
  }'
554
591
  ;;
555
592
  high)
593
+ synkro_log "bashGuard $CMD_SHORT \u2192 FLAGGED ($CATEGORY): $REASONING"
556
594
  SYS_MSG="[synkro] bashGuard \u2192 high: \${REASONING}"
557
595
  if [ -n "$ALTERNATIVE" ] && [ "$ALTERNATIVE" != "null" ]; then
558
596
  SYS_MSG="\${SYS_MSG}\\n[synkro] suggested \u2192 \${ALTERNATIVE}"
@@ -584,6 +622,7 @@ case "$SEVERITY" in
584
622
  ;;
585
623
  *)
586
624
  # low / medium / anything else \u2192 silent allow
625
+ synkro_log "bashGuard $CMD_SHORT \u2192 pass"
587
626
  echo '{}'
588
627
  ;;
589
628
  esac
@@ -605,6 +644,8 @@ exit 0
605
644
  # Always exits 0 with valid JSON. Fails open on any error.
606
645
  set -e
607
646
 
647
+ synkro_log() { echo "[synkro] $1" >&2; }
648
+
608
649
  CONFIG_FILE="$HOME/.synkro/config.env"
609
650
  if [ -f "$CONFIG_FILE" ]; then
610
651
  set -a
@@ -643,6 +684,14 @@ SESSION_ID=$(echo "$PAYLOAD" | jq -r '.session_id // empty' 2>/dev/null)
643
684
  TOOL_USE_ID=$(echo "$PAYLOAD" | jq -r '.tool_use_id // empty' 2>/dev/null)
644
685
  CWD=$(echo "$PAYLOAD" | jq -r '.cwd // empty' 2>/dev/null)
645
686
  TRANSCRIPT_PATH=$(echo "$PAYLOAD" | jq -r '.transcript_path // empty' 2>/dev/null)
687
+ # Detect git remote origin \u2192 repo identity (e.g. "owner/repo")
688
+ GIT_REPO=""
689
+ if command -v git >/dev/null 2>&1; then
690
+ _REMOTE=$(git -C "\${CWD:-.}" remote get-url origin 2>/dev/null || true)
691
+ if [ -n "$_REMOTE" ]; then
692
+ GIT_REPO=$(echo "$_REMOTE" | sed -E 's|^git@[^:]+:||; s|^https?://[^/]+/||; s|\\.git$||')
693
+ fi
694
+ fi
646
695
  # Headless / non-interactive detection \u2014 when CC won't actually prompt the
647
696
  # human, our "ask" verdict is a no-op. Server uses these to fall back to
648
697
  # "deny" so we fail-closed instead of silently letting findings through.
@@ -655,6 +704,9 @@ if [ -z "$FILE_PATH" ]; then
655
704
  exit 0
656
705
  fi
657
706
 
707
+ FILE_SHORT=$(basename "$FILE_PATH")
708
+ synkro_log "editGuard checking: $FILE_SHORT"
709
+
658
710
  # Pull conversation context from the transcript file. CC writes one JSON line
659
711
  # per message; we read the tail and extract the most recent user message + the
660
712
  # last 5 tool_use blocks. The server uses these as anchors for cosine ranking
@@ -758,6 +810,7 @@ BODY=$(jq -n \\
758
810
  --arg cwd "$CWD" \\
759
811
  --arg permission_mode "$PERMISSION_MODE" \\
760
812
  --arg headless_flag "$HEADLESS_FLAG" \\
813
+ --arg repo "$GIT_REPO" \\
761
814
  '{
762
815
  file_path: $file_path,
763
816
  tool_name: $tool_name,
@@ -770,7 +823,8 @@ BODY=$(jq -n \\
770
823
  tool_use_id: (if ($tool_use_id | length) > 0 then $tool_use_id else null end),
771
824
  cwd: (if ($cwd | length) > 0 then $cwd else null end),
772
825
  permission_mode: (if ($permission_mode | length) > 0 then $permission_mode else null end),
773
- headless: ($headless_flag == "1")
826
+ headless: ($headless_flag == "1"),
827
+ repo: (if ($repo | length) > 0 then $repo else null end)
774
828
  }')
775
829
 
776
830
  # Refresh JWT on 401 (mirrors the bash judge pattern).
@@ -799,25 +853,15 @@ refresh_jwt() {
799
853
  return 0
800
854
  }
801
855
 
856
+
802
857
  if [ "$SYNKRO_INFERENCE_TIER" = "free" ] && command -v claude >/dev/null 2>&1; then
803
858
  # \u2500\u2500\u2500 FREE TIER: grade via the persistent claude daemon (Python helper).
804
- # The daemon at $HOME/.synkro/bin/grader_daemon.py keeps one
805
- # \`claude --print --input-format=stream-json\` process alive, primed once
806
- # with the role/format. Hook ships a thin grading prompt over Unix socket
807
- # and gets the verdict text back. Steady-state ~1.5\u20133s per grading vs
808
- # ~14s for cold \`claude --print\`. Falls back to direct \`claude --print\`
809
- # if the daemon binary or primer is missing.
810
-
811
- # Fetch the caller's visible org rules and inject into the grader prompt.
812
- # On-demand MCP retrieval inside the grader was the cleaner architecture
813
- # but added 20+ seconds of latency per grade because the model has to
814
- # call get_guardrails, wait for cosine ranking, then reason. For free-tier
815
- # local CC inference that's unacceptable. Pre-stuffing the rules costs
816
- # tokens but keeps grade latency in the 1-3s range. Bounded at 1.5s; on
817
- # failure proceed with empty rules (degrades to baseline-only judging).
818
- ORG_RULES=$(curl -sS "\${GATEWAY_URL}/api/v1/cli/pr-rules" \\
859
+ ORG_RULES=$(printf '%s' "$PROPOSED" | head -c 8000 \\
860
+ | jq -Rs '{content: .}' \\
861
+ | curl -sS "\${GATEWAY_URL}/api/v1/cli/pr-rules?top_k=20" \\
862
+ -X POST -H "Content-Type: application/json" \\
819
863
  -H "Authorization: Bearer $JWT" \\
820
- --max-time 1.5 2>/dev/null \\
864
+ -d @- --max-time 2 2>/dev/null \\
821
865
  | jq -c '[.rules[]? | {rule_id, text, severity, category, mode}]' 2>/dev/null || echo "[]")
822
866
  if [ -z "$ORG_RULES" ] || [ "$ORG_RULES" = "null" ]; then ORG_RULES="[]"; fi
823
867
 
@@ -864,6 +908,7 @@ if [ "$SYNKRO_INFERENCE_TIER" = "free" ] && command -v claude >/dev/null 2>&1; t
864
908
  --arg cwd "$CWD" \\
865
909
  --arg permission_mode "$PERMISSION_MODE" \\
866
910
  --arg headless_flag "$HEADLESS_FLAG" \\
911
+ --arg repo "$GIT_REPO" \\
867
912
  '{
868
913
  verdict: $verdict,
869
914
  file_path: $file_path,
@@ -877,7 +922,8 @@ if [ "$SYNKRO_INFERENCE_TIER" = "free" ] && command -v claude >/dev/null 2>&1; t
877
922
  tool_use_id: (if ($tool_use_id | length) > 0 then $tool_use_id else null end),
878
923
  cwd: (if ($cwd | length) > 0 then $cwd else null end),
879
924
  permission_mode: (if ($permission_mode | length) > 0 then $permission_mode else null end),
880
- headless: ($headless_flag == "1")
925
+ headless: ($headless_flag == "1"),
926
+ repo: (if ($repo | length) > 0 then $repo else null end)
881
927
  }')
882
928
 
883
929
  RESP=$(curl -sS -X POST "\${GATEWAY_URL}/api/v1/precheck-edit/local-verdict" \\
@@ -914,20 +960,26 @@ else
914
960
  fi
915
961
  fi
916
962
 
917
- # Server returns the literal CC hook JSON shape ({} for allow, or
918
- # hookSpecificOutput.permissionDecision: "deny" + reason). If the call failed
919
- # entirely or returned non-JSON, fail open with empty {}.
920
963
  if [ -z "$RESP" ]; then
964
+ synkro_log "editGuard $FILE_SHORT \u2192 error (timeout)"
921
965
  echo '{}'
922
966
  exit 0
923
967
  fi
924
968
 
925
- # Cheap validation \u2014 only forward if it parses as a JSON object.
926
969
  if ! echo "$RESP" | jq -e 'type == "object"' >/dev/null 2>&1; then
970
+ synkro_log "editGuard $FILE_SHORT \u2192 error (bad response)"
927
971
  echo '{}'
928
972
  exit 0
929
973
  fi
930
974
 
975
+ DECISION=$(echo "$RESP" | jq -r '.hookSpecificOutput.permissionDecision // "allow"' 2>/dev/null)
976
+ if [ "$DECISION" = "deny" ]; then
977
+ DENY_REASON=$(echo "$RESP" | jq -r '.hookSpecificOutput.permissionDecisionReason // ""' 2>/dev/null)
978
+ synkro_log "editGuard $FILE_SHORT \u2192 BLOCKED: $DENY_REASON"
979
+ else
980
+ synkro_log "editGuard $FILE_SHORT \u2192 pass"
981
+ fi
982
+
931
983
  echo "$RESP"
932
984
  exit 0
933
985
  `;
@@ -947,6 +999,8 @@ exit 0
947
999
  # Always exits 0 with valid JSON \u2014 never breaks CC's flow.
948
1000
  set -e
949
1001
 
1002
+ synkro_log() { echo "[synkro] $1" >&2; }
1003
+
950
1004
  CONFIG_FILE="$HOME/.synkro/config.env"
951
1005
  if [ -f "$CONFIG_FILE" ]; then
952
1006
  set -a
@@ -984,6 +1038,14 @@ TOOL_INPUT=$(echo "$PAYLOAD" | jq -c '.tool_input // {}' 2>/dev/null)
984
1038
  SESSION_ID=$(echo "$PAYLOAD" | jq -r '.session_id // empty' 2>/dev/null)
985
1039
  TOOL_USE_ID=$(echo "$PAYLOAD" | jq -r '.tool_use_id // empty' 2>/dev/null)
986
1040
  CWD=$(echo "$PAYLOAD" | jq -r '.cwd // empty' 2>/dev/null)
1041
+ # Detect git remote origin \u2192 repo identity (e.g. "owner/repo")
1042
+ GIT_REPO=""
1043
+ if command -v git >/dev/null 2>&1; then
1044
+ _REMOTE=$(git -C "\${CWD:-.}" remote get-url origin 2>/dev/null || true)
1045
+ if [ -n "$_REMOTE" ]; then
1046
+ GIT_REPO=$(echo "$_REMOTE" | sed -E 's|^git@[^:]+:||; s|^https?://[^/]+/||; s|\\.git$||')
1047
+ fi
1048
+ fi
987
1049
 
988
1050
  FILE_PATH=$(echo "$TOOL_INPUT" | jq -r '.file_path // .notebook_path // .path // empty' 2>/dev/null)
989
1051
  if [ -z "$FILE_PATH" ] || [ ! -f "$FILE_PATH" ]; then
@@ -992,6 +1054,7 @@ if [ -z "$FILE_PATH" ] || [ ! -f "$FILE_PATH" ]; then
992
1054
  fi
993
1055
 
994
1056
  BASENAME=$(basename "$FILE_PATH")
1057
+ synkro_log "editScan checking: $BASENAME"
995
1058
 
996
1059
  # Read post-edit file content (cap 64KB).
997
1060
  FILE_CONTENT=$(head -c 65536 "$FILE_PATH" 2>/dev/null || echo "")
@@ -1012,13 +1075,15 @@ BODY=$(jq -n \\
1012
1075
  --arg session_id "$SESSION_ID" \\
1013
1076
  --arg tool_use_id "$TOOL_USE_ID" \\
1014
1077
  --arg cwd "$CWD" \\
1078
+ --arg repo "$GIT_REPO" \\
1015
1079
  '{
1016
1080
  file_path: $file_path,
1017
1081
  content: $content,
1018
1082
  diff: $diff,
1019
1083
  session_id: (if ($session_id | length) > 0 then $session_id else null end),
1020
1084
  tool_use_id: (if ($tool_use_id | length) > 0 then $tool_use_id else null end),
1021
- cwd: (if ($cwd | length) > 0 then $cwd else null end)
1085
+ cwd: (if ($cwd | length) > 0 then $cwd else null end),
1086
+ repo: (if ($repo | length) > 0 then $repo else null end)
1022
1087
  }')
1023
1088
 
1024
1089
  refresh_jwt() {
@@ -1078,9 +1143,22 @@ SYNKRO_INFERENCE_TIER="\${SYNKRO_INFERENCE_TIER:-free}"
1078
1143
 
1079
1144
  if [ "$SYNKRO_INFERENCE_TIER" = "free" ] && command -v claude >/dev/null 2>&1; then
1080
1145
  # \u2500\u2500\u2500 FREE TIER: grade via the persistent claude daemon (mode=edit). \u2500\u2500\u2500
1146
+
1147
+ # Fetch org guardrail rules relevant to this file content.
1148
+ ORG_RULES=$(printf '%s' "$FILE_CONTENT" | head -c 8000 \\
1149
+ | jq -Rs '{content: .}' \\
1150
+ | curl -sS "\${GATEWAY_URL}/api/v1/cli/pr-rules?top_k=20" \\
1151
+ -X POST -H "Content-Type: application/json" \\
1152
+ -H "Authorization: Bearer $JWT" \\
1153
+ -d @- --max-time 2 2>/dev/null \\
1154
+ | jq -c '[.rules[]? | {rule_id, text, severity, category}]' 2>/dev/null || echo "[]")
1155
+ if [ -z "$ORG_RULES" ] || [ "$ORG_RULES" = "null" ]; then ORG_RULES="[]"; fi
1156
+
1081
1157
  GRADER_PROMPT_FILE=$(mktemp -t synkro-edit-capture.XXXXXX)
1082
1158
  trap "rm -f \\"$GRADER_PROMPT_FILE\\"" EXIT
1083
- printf 'File: %s\\n\\nContent:\\n' "$FILE_PATH" > "$GRADER_PROMPT_FILE"
1159
+ printf 'File: %s\\n' "$FILE_PATH" > "$GRADER_PROMPT_FILE"
1160
+ printf 'Org rules: %s\\n\\n' "$ORG_RULES" >> "$GRADER_PROMPT_FILE"
1161
+ printf 'Content:\\n' >> "$GRADER_PROMPT_FILE"
1084
1162
  printf '%s\\n' "$FILE_CONTENT" >> "$GRADER_PROMPT_FILE"
1085
1163
 
1086
1164
  if [ -x "$HOME/.synkro/bin/grader_daemon.py" ] && [ -f "$HOME/.synkro/grader-primer-edit.txt" ] && command -v python3 >/dev/null 2>&1; then
@@ -1114,6 +1192,7 @@ else
1114
1192
  fi
1115
1193
 
1116
1194
  if [ -z "$RESP" ] || ! echo "$RESP" | jq -e 'type == "object"' >/dev/null 2>&1; then
1195
+ synkro_log "editScan $BASENAME \u2192 error (no response)"
1117
1196
  if [ "\${SYNKRO_VERBOSE:-0}" = "1" ]; then
1118
1197
  SYS_MSG="[synkro] afterFileEdit \u2192 scanned \${BASENAME} (grader unavailable)"
1119
1198
  jq -n --arg sys_msg "$SYS_MSG" '{ systemMessage: $sys_msg }'
@@ -1129,6 +1208,7 @@ CATEGORY=$(echo "$RESP" | jq -r '.category // "unspecified"' 2>/dev/null)
1129
1208
  REASON=$(echo "$RESP" | jq -r '.reason // ""' 2>/dev/null)
1130
1209
 
1131
1210
  if [ "$OK" = "false" ] && [ -n "$REASON" ]; then
1211
+ synkro_log "editScan $BASENAME \u2192 FAIL ($CATEGORY): $REASON"
1132
1212
  SYS_MSG="[synkro] afterFileEdit \u2192 Finding in \${BASENAME}: \${REASON}"
1133
1213
  ADDITIONAL_CTX="Synkro post-edit grader flagged \${BASENAME} (severity: \${SEVERITY}, category: \${CATEGORY}). Re-edit the file applying the retry guidance: \${REASON}"
1134
1214
  jq -n \\
@@ -1144,6 +1224,8 @@ if [ "$OK" = "false" ] && [ -n "$REASON" ]; then
1144
1224
  exit 0
1145
1225
  fi
1146
1226
 
1227
+ synkro_log "editScan $BASENAME \u2192 pass"
1228
+
1147
1229
  if [ "\${SYNKRO_VERBOSE:-0}" = "1" ]; then
1148
1230
  SYS_MSG="[synkro] afterFileEdit \u2192 no issues in \${BASENAME}"
1149
1231
  jq -n --arg sys_msg "$SYS_MSG" '{ systemMessage: $sys_msg }'
@@ -1262,16 +1344,18 @@ if [ -z "$RESP" ]; then
1262
1344
  exit 0
1263
1345
  fi
1264
1346
 
1347
+ 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."
1348
+
1265
1349
  OPEN=$(echo "$RESP" | jq -r '.open_count // 0' 2>/dev/null)
1266
1350
  if [ "$OPEN" = "0" ] || [ -z "$OPEN" ]; then
1267
- echo '{}'
1351
+ jq -n --arg sys_msg "[synkro] $PLAN_NUDGE" '{ systemMessage: $sys_msg }'
1268
1352
  exit 0
1269
1353
  fi
1270
1354
 
1271
1355
  if [ "$OPEN" = "1" ]; then
1272
- SYS_MSG="[synkro] session start \u2192 1 open finding in this repo from a prior session"
1356
+ SYS_MSG="[synkro] session start \u2192 1 open finding in this repo from a prior session. $PLAN_NUDGE"
1273
1357
  else
1274
- SYS_MSG="[synkro] session start \u2192 \${OPEN} open findings in this repo from prior sessions"
1358
+ SYS_MSG="[synkro] session start \u2192 \${OPEN} open findings in this repo from prior sessions. $PLAN_NUDGE"
1275
1359
  fi
1276
1360
 
1277
1361
  jq -n --arg sys_msg "$SYS_MSG" '{ systemMessage: $sys_msg }'
@@ -1329,15 +1413,14 @@ var init_graderDaemon = __esm({
1329
1413
  "use strict";
1330
1414
  GRADER_DAEMON_PY = `#!/usr/bin/env python3
1331
1415
  """
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.
1416
+ Synkro warm-pool grader \u2014 pre-warmed \`claude --print --system-prompt\` process
1417
+ pool fronted by a Unix socket. Each grade uses one warm process and kills it;
1418
+ a replacement is pre-warmed in the background.
1336
1419
 
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.
1420
+ Zero context bloat: 1 grade per process, system prompt via --system-prompt flag
1421
+ (single inference call, no primer-as-conversation-turn overhead).
1422
+
1423
+ Warm steady-state: ~2-3s per grade. Cold fallback: ~5-6s if pre-warm not ready.
1341
1424
 
1342
1425
  Commands:
1343
1426
  start [primer-path] - bring up daemon if not running
@@ -1346,13 +1429,10 @@ Commands:
1346
1429
  status - print "running"/"stopped"
1347
1430
  """
1348
1431
 
1349
- import os, sys, json, socket, time, atexit, signal, fcntl, re
1432
+ import os, sys, json, socket, time, signal, fcntl, re
1350
1433
  import subprocess, threading
1351
1434
  from pathlib import Path
1352
1435
 
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
1436
  ALLOWED_MODE_RE = re.compile(r"^[a-z][a-z0-9_-]{0,30}$")
1357
1437
  DAEMON_BASE = Path.home() / ".synkro" / "daemon"
1358
1438
  DAEMON_BASE.mkdir(parents=True, exist_ok=True, mode=0o700)
@@ -1365,11 +1445,10 @@ def mode_paths(mode):
1365
1445
  MODE = "edit"
1366
1446
  PID_FILE, SOCK_PATH, LOG_FILE = mode_paths(MODE)
1367
1447
 
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"))
1448
+ GRADE_TIMEOUT_SEC = int(os.environ.get("SYNKRO_DAEMON_GRADE_TIMEOUT", "45"))
1371
1449
  DEFAULT_MODEL = os.environ.get("SYNKRO_DAEMON_MODEL", "claude-sonnet-4-6")
1372
1450
  MAX_PROMPT_BYTES = 4 * 1024 * 1024
1451
+ IDLE_SHUTDOWN_SEC = int(os.environ.get("SYNKRO_DAEMON_IDLE_TIMEOUT", "600"))
1373
1452
 
1374
1453
 
1375
1454
  def log(msg):
@@ -1380,146 +1459,139 @@ def log(msg):
1380
1459
  pass
1381
1460
 
1382
1461
 
1383
- PREWARM_HEADROOM = 4
1462
+ def _read_response(proc, timeout=45):
1463
+ acc = []
1464
+ deadline = time.time() + timeout
1465
+ while True:
1466
+ if time.time() > deadline:
1467
+ log("read timeout")
1468
+ return ""
1469
+ line = proc.stdout.readline()
1470
+ if not line:
1471
+ return ""
1472
+ try:
1473
+ obj = json.loads(line)
1474
+ except json.JSONDecodeError:
1475
+ continue
1476
+ t = obj.get("type")
1477
+ if t == "assistant":
1478
+ for c in obj.get("message", {}).get("content", []):
1479
+ if c.get("type") == "text":
1480
+ acc.append(c["text"])
1481
+ elif t == "result":
1482
+ return "".join(acc)
1483
+
1484
+
1485
+ def _send_msg(proc, text):
1486
+ msg = json.dumps({
1487
+ "type": "user",
1488
+ "message": {"role": "user", "content": [{"type": "text", "text": text}]},
1489
+ "parent_tool_use_id": None,
1490
+ "session_id": "",
1491
+ })
1492
+ proc.stdin.write(msg + "\\n")
1493
+ proc.stdin.flush()
1494
+
1495
+
1496
+ class WarmGrader:
1497
+ """
1498
+ Keeps one pre-warmed claude process ready. Each grade pulls the warm
1499
+ process, sends one prompt, reads the verdict, kills the process, and
1500
+ starts pre-warming a replacement in the background.
1384
1501
 
1385
- class GraderDaemon:
1502
+ The warm process has the system prompt loaded via --system-prompt and
1503
+ its KV cache primed by a warmup turn. The actual grade is a single
1504
+ inference call that benefits from the cached system prompt tokens.
1505
+ """
1386
1506
  def __init__(self, primer):
1387
1507
  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()
1508
+ self._warm_proc = None
1509
+ self._warm_thread = None
1510
+ self._lock = threading.Lock()
1511
+ self._total_grades = 0
1512
+ self._start_prewarm()
1395
1513
 
1396
1514
  def _make_proc(self):
1515
+ cmd = [
1516
+ "claude", "--print", "--model", DEFAULT_MODEL,
1517
+ "--input-format=stream-json",
1518
+ "--output-format=stream-json",
1519
+ "--verbose",
1520
+ "--no-session-persistence",
1521
+ ]
1522
+ if self.primer:
1523
+ cmd += ["--system-prompt", self.primer]
1397
1524
  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
- ],
1525
+ cmd,
1405
1526
  stdin=subprocess.PIPE, stdout=subprocess.PIPE,
1406
1527
  stderr=subprocess.DEVNULL, text=True, bufsize=1,
1407
1528
  )
1408
1529
 
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):
1530
+ def _kill_proc(self, proc):
1531
+ try: proc.stdin.close()
1532
+ except Exception: pass
1533
+ try: proc.kill(); proc.wait(timeout=2)
1534
+ except Exception: pass
1535
+
1536
+ def _prewarm(self):
1456
1537
  try:
1457
- log("pre-warming next subprocess")
1538
+ log("pre-warming process")
1458
1539
  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")
1540
+ _send_msg(proc, "Ready")
1541
+ resp = _read_response(proc, timeout=30)
1542
+ if resp:
1543
+ with self._lock:
1544
+ old = self._warm_proc
1545
+ self._warm_proc = proc
1546
+ if old:
1547
+ self._kill_proc(old)
1548
+ log(f"pre-warm ready ({len(resp)} chars)")
1549
+ else:
1550
+ log("pre-warm response empty")
1551
+ self._kill_proc(proc)
1465
1552
  except Exception as e:
1466
1553
  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
1554
 
1493
- def _send(self, text):
1555
+ def _start_prewarm(self):
1556
+ self._warm_thread = threading.Thread(target=self._prewarm, daemon=True)
1557
+ self._warm_thread.start()
1558
+
1559
+ def grade(self, prompt):
1560
+ if self._warm_thread:
1561
+ self._warm_thread.join(timeout=60)
1562
+
1563
+ with self._lock:
1564
+ proc = self._warm_proc
1565
+ self._warm_proc = None
1566
+
1567
+ warm = True
1568
+ if not proc or proc.poll() is not None:
1569
+ log("no warm process, cold fallback")
1570
+ proc = self._make_proc()
1571
+ warm = False
1572
+
1573
+ t0 = time.time()
1494
1574
  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()
1575
+ _send_msg(proc, prompt)
1576
+ resp = _read_response(proc, timeout=GRADE_TIMEOUT_SEC)
1577
+ except Exception as e:
1578
+ log(f"grade error: {e}")
1579
+ resp = ""
1580
+ finally:
1581
+ self._kill_proc(proc)
1582
+
1583
+ elapsed = (time.time() - t0) * 1000
1584
+ self._total_grades += 1
1585
+ log(f"grade #{self._total_grades} {'warm' if warm else 'cold'} elapsed={elapsed:.0f}ms resp={len(resp)}ch")
1586
+
1587
+ self._start_prewarm()
1506
1588
  return resp
1507
1589
 
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
1590
+ def shutdown(self):
1591
+ with self._lock:
1592
+ if self._warm_proc:
1593
+ self._kill_proc(self._warm_proc)
1594
+ self._warm_proc = None
1523
1595
 
1524
1596
 
1525
1597
  def serve(primer):
@@ -1533,7 +1605,7 @@ def serve(primer):
1533
1605
  os.write(pid_fd, f"{os.getpid()}\\n".encode())
1534
1606
  os.fsync(pid_fd)
1535
1607
 
1536
- daemon = GraderDaemon(primer)
1608
+ grader = WarmGrader(primer)
1537
1609
 
1538
1610
  if SOCK_PATH.exists():
1539
1611
  SOCK_PATH.unlink()
@@ -1547,24 +1619,30 @@ def serve(primer):
1547
1619
  except Exception: pass
1548
1620
  try: PID_FILE.unlink()
1549
1621
  except Exception: pass
1550
- try: daemon.proc and daemon.proc.terminate()
1551
- except Exception: pass
1622
+ grader.shutdown()
1552
1623
  sys.exit(0)
1553
1624
  signal.signal(signal.SIGTERM, cleanup)
1554
1625
  signal.signal(signal.SIGINT, cleanup)
1555
1626
 
1556
- log(f"daemon ready model={DEFAULT_MODEL} sock={SOCK_PATH}")
1627
+ log(f"daemon ready model={DEFAULT_MODEL} idle_shutdown={IDLE_SHUTDOWN_SEC}s sock={SOCK_PATH}")
1557
1628
 
1629
+ last_activity = time.time()
1630
+ sock.settimeout(30)
1558
1631
  while True:
1559
1632
  try:
1560
1633
  conn, _ = sock.accept()
1561
- threading.Thread(target=_handle_conn, args=(conn, daemon), daemon=True).start()
1634
+ last_activity = time.time()
1635
+ threading.Thread(target=_handle_conn, args=(conn, grader), daemon=True).start()
1636
+ except socket.timeout:
1637
+ if time.time() - last_activity > IDLE_SHUTDOWN_SEC:
1638
+ log(f"idle for {IDLE_SHUTDOWN_SEC}s, shutting down")
1639
+ cleanup()
1562
1640
  except Exception as e:
1563
1641
  log(f"accept error: {e}")
1564
1642
  time.sleep(0.1)
1565
1643
 
1566
1644
 
1567
- def _handle_conn(conn, daemon):
1645
+ def _handle_conn(conn, grader):
1568
1646
  try:
1569
1647
  with conn:
1570
1648
  length_bytes = b""
@@ -1581,7 +1659,7 @@ def _handle_conn(conn, daemon):
1581
1659
  chunk = conn.recv(min(65536, length - len(prompt)))
1582
1660
  if not chunk: break
1583
1661
  prompt += chunk
1584
- response = daemon.grade(prompt.decode("utf-8", errors="replace"))
1662
+ response = grader.grade(prompt.decode("utf-8", errors="replace"))
1585
1663
  resp_bytes = response.encode("utf-8")
1586
1664
  conn.sendall(len(resp_bytes).to_bytes(8, "big"))
1587
1665
  conn.sendall(resp_bytes)
@@ -1708,7 +1786,7 @@ JUDGING PRIORITY:
1708
1786
  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
1787
  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
1788
 
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.
1789
+ 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
1790
 
1713
1791
  OUTPUT RULES \u2014 strictest possible, no exceptions:
1714
1792
 
@@ -2144,7 +2222,7 @@ function writeConfigEnv(opts) {
2144
2222
  `SYNKRO_GATEWAY_URL=${shellQuoteSingle(safeGateway)}`,
2145
2223
  `SYNKRO_CREDENTIALS_PATH=${shellQuoteSingle(credsPath)}`,
2146
2224
  `SYNKRO_TIER=${shellQuoteSingle(safeTier)}`,
2147
- `SYNKRO_VERSION=${shellQuoteSingle("1.1.7")}`
2225
+ `SYNKRO_VERSION=${shellQuoteSingle("1.2.0")}`
2148
2226
  ];
2149
2227
  if (safeUserId) lines.push(`SYNKRO_USER_ID=${shellQuoteSingle(safeUserId)}`);
2150
2228
  if (safeOrgId) lines.push(`SYNKRO_ORG_ID=${shellQuoteSingle(safeOrgId)}`);
@@ -2269,6 +2347,17 @@ async function installCommand(opts = {}) {
2269
2347
  console.log(` ${scripts.sessionStartScript}
2270
2348
  `);
2271
2349
  writeGraderDaemon();
2350
+ for (const mode of ["edit", "bash"]) {
2351
+ const pidFile = join4(SYNKRO_DIR, "daemon", mode, "daemon.pid");
2352
+ try {
2353
+ const pid = parseInt(readFileSync4(pidFile, "utf-8").trim(), 10);
2354
+ if (pid > 0) {
2355
+ process.kill(pid, "SIGTERM");
2356
+ console.log(`Stopped stale ${mode} daemon (pid ${pid})`);
2357
+ }
2358
+ } catch {
2359
+ }
2360
+ }
2272
2361
  console.log("Wrote local-tier grader daemon:");
2273
2362
  console.log(` ${GRADER_DAEMON_PATH}`);
2274
2363
  console.log(` ${GRADER_PRIMER_EDIT_PATH}`);
@@ -2463,6 +2552,15 @@ function statusCommand() {
2463
2552
  console.log(` gateway: ${config.SYNKRO_GATEWAY_URL ?? "(unset)"}`);
2464
2553
  console.log(` credentials: ${config.SYNKRO_CREDENTIALS_PATH ?? "(unset)"}`);
2465
2554
  console.log(` tier: ${config.SYNKRO_TIER ?? "(unset)"}`);
2555
+ const info2 = getUserInfo();
2556
+ const userId = info2?.id ?? config.SYNKRO_USER_ID ?? "default";
2557
+ const tierCacheFile = join5(SYNKRO_DIR2, `.tier-cache-${userId}`);
2558
+ let inferenceTier = config.SYNKRO_INFERENCE_TIER || null;
2559
+ if (!inferenceTier && existsSync6(tierCacheFile)) {
2560
+ inferenceTier = readFileSync5(tierCacheFile, "utf-8").trim() || null;
2561
+ }
2562
+ const tierLabel = inferenceTier === "fast" ? "'fast' (server-side grading)" : inferenceTier === "free" ? "'free' (local daemon grading)" : "(unknown \u2014 fires on next hook)";
2563
+ console.log(` inference: ${tierLabel}`);
2466
2564
  console.log(` version: ${config.SYNKRO_VERSION ?? "(unset)"}`);
2467
2565
  console.log();
2468
2566
  const agents = detectAgents();