@synkro-sh/cli 1.4.45 → 1.4.48

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
@@ -145,51 +145,40 @@ function installCCHooks(settingsPath, config) {
145
145
  {
146
146
  type: "command",
147
147
  command: config.editPrecheckScriptPath,
148
- timeout: 15
148
+ timeout: 30
149
149
  }
150
150
  ],
151
151
  [SYNKRO_MARKER]: true
152
152
  });
153
153
  settings.hooks.PreToolUse.push({
154
- matcher: "ExitPlanMode",
155
- hooks: [
156
- {
157
- type: "command",
158
- command: config.planJudgeScriptPath,
159
- timeout: 45
160
- }
161
- ],
162
- [SYNKRO_MARKER]: true
163
- });
164
- settings.hooks.PostToolUse.push({
165
154
  matcher: "Edit|Write|MultiEdit|NotebookEdit",
166
155
  hooks: [
167
156
  {
168
157
  type: "command",
169
- command: config.editCaptureScriptPath,
170
- timeout: 20
158
+ command: config.cwePrecheckScriptPath,
159
+ timeout: 30
171
160
  }
172
161
  ],
173
162
  [SYNKRO_MARKER]: true
174
163
  });
175
- settings.hooks.PostToolUse.push({
164
+ settings.hooks.PreToolUse.push({
176
165
  matcher: "Edit|Write|MultiEdit|NotebookEdit",
177
166
  hooks: [
178
167
  {
179
168
  type: "command",
180
- command: config.cveScanScriptPath,
169
+ command: config.cvePrecheckScriptPath,
181
170
  timeout: 10
182
171
  }
183
172
  ],
184
173
  [SYNKRO_MARKER]: true
185
174
  });
186
- settings.hooks.PostToolUse.push({
187
- matcher: "Edit|Write|MultiEdit|NotebookEdit",
175
+ settings.hooks.PreToolUse.push({
176
+ matcher: "ExitPlanMode",
188
177
  hooks: [
189
178
  {
190
179
  type: "command",
191
- command: config.cweScanScriptPath,
192
- timeout: 15
180
+ command: config.planJudgeScriptPath,
181
+ timeout: 45
193
182
  }
194
183
  ],
195
184
  [SYNKRO_MARKER]: true
@@ -250,7 +239,7 @@ function uninstallCCHooks(settingsPath) {
250
239
  if (!existsSync2(settingsPath)) return false;
251
240
  const settings = readSettings(settingsPath);
252
241
  if (!settings.hooks) return false;
253
- const events = ["PreToolUse", "PostToolUse", "SessionEnd", "SessionStart", "Stop"];
242
+ const events = ["PreToolUse", "PostToolUse", "SessionEnd", "SessionStart", "Stop", "UserPromptSubmit"];
254
243
  for (const evt of events) {
255
244
  removeSynkroEntries(settings.hooks, evt);
256
245
  }
@@ -475,7 +464,7 @@ var init_mcpConfig = __esm({
475
464
  });
476
465
 
477
466
  // cli/installer/hookScripts.ts
478
- var SYNKRO_COMMON_SCRIPT, CC_BASH_JUDGE_SCRIPT, CC_EDIT_PRECHECK_SCRIPT, CC_EDIT_CAPTURE_SCRIPT, CC_PLAN_JUDGE_SCRIPT, CC_STOP_SUMMARY_SCRIPT, CC_SESSION_START_SCRIPT, CC_BASH_FOLLOWUP_SCRIPT, CC_CVE_SCAN_SCRIPT, CC_CWE_SCAN_SCRIPT, CC_TRANSCRIPT_SYNC_SCRIPT, CURSOR_BASH_JUDGE_SCRIPT, CURSOR_EDIT_PRECHECK_SCRIPT, CURSOR_EDIT_CAPTURE_SCRIPT, CURSOR_BASH_FOLLOWUP_SCRIPT, CC_USER_PROMPT_SUBMIT_SCRIPT;
467
+ var SYNKRO_COMMON_SCRIPT, CURSOR_BASH_JUDGE_SCRIPT, CURSOR_EDIT_PRECHECK_SCRIPT, CURSOR_EDIT_CAPTURE_SCRIPT, CURSOR_BASH_FOLLOWUP_SCRIPT;
479
468
  var init_hookScripts = __esm({
480
469
  "cli/installer/hookScripts.ts"() {
481
470
  "use strict";
@@ -499,6 +488,25 @@ synkro_load_jwt() {
499
488
  }
500
489
 
501
490
  synkro_refresh_jwt() {
491
+ # Lock via mkdir (atomic on all Unix including macOS \u2014 no flock needed)
492
+ local lockdir="\${CREDS_PATH}.lockdir"
493
+ if ! mkdir "$lockdir" 2>/dev/null; then
494
+ # Another hook is refreshing \u2014 wait and re-read
495
+ local _w=0
496
+ while [ -d "$lockdir" ] && [ $_w -lt 5 ]; do sleep 0.5; _w=$((_w+1)); done
497
+ JWT=$(jq -r '.access_token // empty' "$CREDS_PATH" 2>/dev/null)
498
+ return 0
499
+ fi
500
+ trap "rmdir \\"$lockdir\\" 2>/dev/null" RETURN
501
+
502
+ # Re-check expiry \u2014 another hook may have just refreshed
503
+ local p2 exp2 now2
504
+ p2=$(printf '%s' "$JWT" | cut -d. -f2)
505
+ case $((\${#p2} % 4)) in 2) p2="\${p2}==";; 3) p2="\${p2}=";; esac
506
+ exp2=$(printf '%s' "$p2" | tr '_-' '/+' | base64 -D 2>/dev/null | jq -r '.exp // 0' 2>/dev/null)
507
+ now2=$(date -u +%s)
508
+ if [ $((exp2 - now2)) -ge 60 ]; then return 0; fi
509
+
502
510
  local rt
503
511
  rt=$(jq -r '.refresh_token // empty' "$CREDS_PATH" 2>/dev/null)
504
512
  if [ -z "$rt" ]; then return 1; fi
@@ -514,7 +522,12 @@ synkro_refresh_jwt() {
514
522
  new_rt=$(echo "$resp" | jq -r '.refresh_token // empty' 2>/dev/null)
515
523
  [ -z "$new_rt" ] && new_rt="$rt"
516
524
  local tmp="\${CREDS_PATH}.synkro.tmp"
517
- jq --arg at "$new_at" --arg rt "$new_rt" '. + {access_token:$at,refresh_token:$rt}' "$CREDS_PATH" > "$tmp" 2>/dev/null && mv "$tmp" "$CREDS_PATH"
525
+ local existing
526
+ existing=$(cat "$CREDS_PATH" 2>/dev/null)
527
+ if [ -z "$existing" ] || ! echo "$existing" | jq -e '.' >/dev/null 2>&1; then
528
+ existing='{}'
529
+ fi
530
+ echo "$existing" | jq --arg at "$new_at" --arg rt "$new_rt" '. + {access_token:$at,refresh_token:$rt}' > "$tmp" 2>/dev/null && mv "$tmp" "$CREDS_PATH"
518
531
  JWT="$new_at"
519
532
  }
520
533
 
@@ -542,10 +555,6 @@ synkro_channel_up() {
542
555
  (exec 3<>/dev/tcp/127.0.0.1/\${SYNKRO_CHANNEL_PORT:-8929}) 2>/dev/null && exec 3<&- 3>&-
543
556
  }
544
557
 
545
- synkro_cwe_channel_up() {
546
- (exec 3<>/dev/tcp/127.0.0.1/8930) 2>/dev/null && exec 3<&- 3>&-
547
- }
548
-
549
558
  # Fetch hook config. Sets SYNKRO_CAPTURE_DEPTH, SYNKRO_TIER, SYNKRO_RULES, SYNKRO_SILENT, SYNKRO_POLICY_NAME.
550
559
  synkro_load_config() {
551
560
  local resp
@@ -558,8 +567,6 @@ synkro_load_config() {
558
567
  SYNKRO_RULES=$(echo "$resp" | jq -c '[.rules[]? | select(.hook_stage == "pre" or .hook_stage == "both" or .hook_stage == null) | {rule_id,text,severity,category,mode}]' 2>/dev/null || echo "[]")
559
568
  }
560
569
 
561
- # Build the tag prefix: [synkro:route:ruleset] or [synkro:silent]
562
- # Accepts optional $1 = route override; otherwise calls synkro_route().
563
570
  synkro_tag() {
564
571
  if [ "$SYNKRO_SILENT" = "true" ]; then echo "[synkro:silent]"; return; fi
565
572
  local route="\${1:-$(synkro_route)}"
@@ -567,166 +574,12 @@ synkro_tag() {
567
574
  echo "[synkro:\${route}:\${rs}]"
568
575
  }
569
576
 
570
- # Decide routing: "local" (grade on device) or "cloud" (POST to server)
571
577
  synkro_route() {
572
578
  [ "$SYNKRO_CAPTURE_DEPTH" = "local_only" ] && echo "local" && return
573
579
  synkro_channel_up && echo "local" && return
574
580
  echo "cloud"
575
581
  }
576
582
 
577
- # Routing for CWE channel (port 8930).
578
- synkro_cwe_route() {
579
- [ "$SYNKRO_CAPTURE_DEPTH" = "local_only" ] && echo "local" && return
580
- synkro_cwe_channel_up && echo "local" && return
581
- echo "cloud"
582
- }
583
-
584
- # Grade locally via synkro CLI channel. Reads prompt from stdin.
585
- synkro_local_grade() {
586
- local surface="$1"
587
- if ! synkro_channel_up; then
588
- echo "SYNKRO_CHANNEL_DOWN" >&2
589
- return 1
590
- fi
591
- if [ -n "\${SYNKRO_CLI_BIN:-}" ] && [ -f "$SYNKRO_CLI_BIN" ] && command -v node >/dev/null 2>&1; then
592
- node "$SYNKRO_CLI_BIN" grade "$surface" 2>/dev/null
593
- elif command -v synkro >/dev/null 2>&1; then
594
- synkro grade "$surface" 2>/dev/null
595
- else
596
- echo "SYNKRO_CLI_NOT_FOUND" >&2
597
- return 1
598
- fi
599
- }
600
-
601
- # Grade CWE locally via channel 2 (port 8930). Reads prompt from stdin.
602
- synkro_local_grade_cwe() {
603
- if ! synkro_cwe_channel_up; then
604
- echo "SYNKRO_CHANNEL_DOWN" >&2
605
- return 1
606
- fi
607
- if [ -n "\${SYNKRO_CLI_BIN:-}" ] && [ -f "$SYNKRO_CLI_BIN" ] && command -v node >/dev/null 2>&1; then
608
- SYNKRO_CHANNEL_PORT=8930 node "$SYNKRO_CLI_BIN" grade cwe 2>/dev/null
609
- elif command -v synkro >/dev/null 2>&1; then
610
- SYNKRO_CHANNEL_PORT=8930 synkro grade cwe 2>/dev/null
611
- else
612
- echo "SYNKRO_CLI_NOT_FOUND" >&2
613
- return 1
614
- fi
615
- }
616
-
617
- # Extract the CC model from the current turn's transcript. Call after reading PAYLOAD.
618
- # Sets CC_MODEL to the model used in the most recent assistant message.
619
- synkro_detect_cc_model() {
620
- CC_MODEL=""
621
- local tp
622
- tp=$(echo "\${1:-$PAYLOAD}" | jq -r '.transcript_path // empty' 2>/dev/null)
623
- [ -z "$tp" ] || [ ! -f "$tp" ] && return
624
- CC_MODEL=$(grep '"type":"assistant"' "$tp" 2>/dev/null | tail -1 | jq -r '.message.model // empty' 2>/dev/null)
625
- }
626
-
627
- # Parse <synkro-verdict>...</synkro-verdict> XML from local grader output.
628
- # Sets LOCAL_OK, LOCAL_REASON, LOCAL_RULE_ID, LOCAL_RULE_MODE, LOCAL_SEV, LOCAL_CAT.
629
- synkro_parse_local_verdict() {
630
- local resp="$1"
631
- LOCAL_OK="true"; LOCAL_REASON=""; LOCAL_RULE_ID=""; LOCAL_RULE_MODE=""; LOCAL_SEV="low"; LOCAL_CAT="clean"
632
- local inner
633
- inner=$(printf '%s' "$resp" | tr '\\n' ' ' | sed -nE 's|.*<synkro-verdict>(.*)</synkro-verdict>.*|\\1|p' | tail -1)
634
- [ -z "$inner" ] && return
635
- local ok_tag
636
- ok_tag=$(printf '%s' "$inner" | sed -nE 's|.*<ok>(.*)</ok>.*|\\1|p' | head -1)
637
- [ -n "$ok_tag" ] && LOCAL_OK="$ok_tag"
638
- LOCAL_REASON=$(printf '%s' "$inner" | sed -nE 's|.*<reason>(.*)</reason>.*|\\1|p' | head -1)
639
- [ -z "$LOCAL_REASON" ] && LOCAL_REASON=$(printf '%s' "$inner" | sed -nE 's|.*<reasoning>(.*)</reasoning>.*|\\1|p' | head -1)
640
- if [ "$LOCAL_OK" = "false" ]; then
641
- LOCAL_RULE_ID=$(printf '%s' "$inner" | sed -nE 's|.*<rule_id>(.*)</rule_id>.*|\\1|p' | head -1)
642
- LOCAL_RULE_MODE=$(printf '%s' "$inner" | sed -nE 's|.*<rule_mode>(.*)</rule_mode>.*|\\1|p' | head -1)
643
- LOCAL_SEV=$(printf '%s' "$inner" | sed -nE 's|.*<risk_level>(.*)</risk_level>.*|\\1|p' | head -1)
644
- if [ -z "$LOCAL_RULE_ID" ]; then
645
- local fv
646
- fv=$(printf '%s' "$inner" | awk -v RS='</violation>' '/<violation>/{print; exit}')
647
- LOCAL_RULE_ID=$(printf '%s' "$fv" | sed -nE 's|.*<rule_id>(.*)</rule_id>.*|\\1|p' | head -1)
648
- [ -z "$LOCAL_REASON" ] && LOCAL_REASON=$(printf '%s' "$fv" | sed -nE 's|.*<reason>(.*)</reason>.*|\\1|p' | head -1)
649
- [ -z "$LOCAL_SEV" ] && LOCAL_SEV=$(printf '%s' "$fv" | sed -nE 's|.*<severity>(.*)</severity>.*|\\1|p' | head -1)
650
- fi
651
- LOCAL_SEV="\${LOCAL_SEV:-high}"
652
- LOCAL_CAT=$(printf '%s' "$inner" | sed -nE 's|.*<category>(.*)</category>.*|\\1|p' | head -1)
653
- LOCAL_CAT="\${LOCAL_CAT:-uncategorized}"
654
- [ -z "$LOCAL_RULE_ID" ] && LOCAL_RULE_ID=$(printf '%s' "$LOCAL_REASON" | grep -oE '[Rr][0-9]{3}' | head -1)
655
- fi
656
- }
657
-
658
- # Fire anonymized telemetry for local verdicts. All args positional.
659
- synkro_capture_local() {
660
- local hook_type="$1" verdict="$2" severity="$3" category="$4" tool_name="$5" repo="$6" session_id="$7"
661
- local cc_model="\${CC_MODEL:-unknown}"
662
- (
663
- BODY=$(jq -n \\
664
- --arg eid "$(uuidgen 2>/dev/null || echo "evt_$(date +%s)_$$")" \\
665
- --arg ht "$hook_type" --arg v "$verdict" --arg s "$severity" --arg c "$category" \\
666
- --arg tn "$tool_name" --arg r "$repo" --arg sid "$session_id" --arg mdl "$cc_model" \\
667
- '{capture_type:"local_verdict",event_id:$eid,hook_type:$ht,verdict:$v,severity:$s,category:$c,cc_model:$mdl,model:$mdl,tool_name:$tn}
668
- + (if $r != "" then {repo:$r} else {} end)
669
- + (if $sid != "" then {session_id:$sid} else {} end)')
670
- curl -sS -X POST "\${GATEWAY_URL}/api/v1/hook/capture" \\
671
- -H "Content-Type: application/json" -H "Authorization: Bearer $JWT" \\
672
- -d "$BODY" --max-time 2 >/dev/null 2>&1
673
- ) &
674
- }
675
-
676
- # Fire full-content telemetry for local verdicts (used when capture_depth is full or evidence_on_violation).
677
- synkro_capture_local_full() {
678
- local hook_type="$1" verdict="$2" severity="$3" category="$4" tool_name="$5" repo="$6" session_id="$7"
679
- local command="$8" reasoning="$9" rules_checked="\${10:-[]}" violated_rules="\${11:-[]}" recent_user_messages="\${12:-[]}"
680
- local cc_model="\${CC_MODEL:-unknown}"
681
- (
682
- BODY=$(jq -n \\
683
- --arg eid "$(uuidgen 2>/dev/null || echo "evt_$(date +%s)_$$")" \\
684
- --arg ht "$hook_type" --arg v "$verdict" --arg s "$severity" --arg c "$category" \\
685
- --arg tn "$tool_name" --arg r "$repo" --arg sid "$session_id" --arg mdl "$cc_model" \\
686
- --arg cmd "$command" --arg rsn "$reasoning" --arg cd "$SYNKRO_CAPTURE_DEPTH" \\
687
- --argjson rc "$rules_checked" --argjson vr "$violated_rules" --argjson rum "$recent_user_messages" \\
688
- '{capture_type:"local_verdict",event_id:$eid,hook_type:$ht,verdict:$v,severity:$s,category:$c,
689
- cc_model:$mdl,model:$mdl,tool_name:$tn,capture_depth:$cd,
690
- command:(if ($cmd|length) > 0 then $cmd else null end),
691
- reasoning:(if ($rsn|length) > 0 then $rsn else null end),
692
- rules_checked:$rc, violated_rules:$vr, recent_user_messages:$rum}
693
- + (if $r != "" then {repo:$r} else {} end)
694
- + (if $sid != "" then {session_id:$sid} else {} end)')
695
- curl -sS -X POST "\${GATEWAY_URL}/api/v1/hook/capture" \\
696
- -H "Content-Type: application/json" -H "Authorization: Bearer $JWT" \\
697
- -d "$BODY" --max-time 3 >/dev/null 2>&1
698
- ) &
699
- }
700
-
701
- # Dispatch local verdict capture based on capture_depth privacy setting.
702
- # For full: always send full content. For evidence_on_violation: full only on violations. For local_only: anonymized only.
703
- synkro_dispatch_capture() {
704
- local hook_type="$1" verdict="$2" severity="$3" category="$4" tool_name="$5" repo="$6" session_id="$7"
705
- local command="$8" reasoning="$9" rules_checked="\${10:-[]}" violated_rules="\${11:-[]}" recent_user_messages="\${12:-[]}"
706
- local send_full=false
707
- case "\${SYNKRO_CAPTURE_DEPTH:-local_only}" in
708
- full) send_full=true ;;
709
- evidence_on_violation)
710
- case "$verdict" in block|warning|deny) send_full=true ;; esac ;;
711
- esac
712
- if [ "$send_full" = "true" ]; then
713
- synkro_capture_local_full "$hook_type" "$verdict" "$severity" "$category" "$tool_name" "$repo" "$session_id" \\
714
- "$command" "$reasoning" "$rules_checked" "$violated_rules" "$recent_user_messages"
715
- else
716
- synkro_capture_local "$hook_type" "$verdict" "$severity" "$category" "$tool_name" "$repo" "$session_id"
717
- fi
718
- }
719
-
720
- # Look up a rule's mode from cached rules. Returns "blocking" or "audit".
721
- synkro_rule_mode() {
722
- local rid="$1"
723
- [ -z "$rid" ] || [ -z "\${SYNKRO_RULES:-}" ] && echo "blocking" && return
724
- local m
725
- m=$(printf '%s' "$SYNKRO_RULES" | jq -r --arg r "$rid" '[.[] | select(.rule_id == $r) | .mode] | if any(. == "blocking") then "blocking" else .[0] // "blocking" end' 2>/dev/null)
726
- [ -z "$m" ] || [ "$m" = "null" ] && m="blocking"
727
- echo "$m"
728
- }
729
-
730
583
  SYNKRO_CONSENT_FILE="$HOME/.synkro/.local-consent"
731
584
 
732
585
  _TAB=$(printf '\\t')
@@ -750,25 +603,6 @@ synkro_consent_consume() {
750
603
  awk -v p="$pat" -v r="$rep" '{if($0==p)print r;else print}' "$SYNKRO_CONSENT_FILE" > "$tmp" 2>/dev/null && mv "$tmp" "$SYNKRO_CONSENT_FILE" 2>/dev/null || true
751
604
  }
752
605
 
753
- synkro_consent_has_consumed() {
754
- local sid="$1" hash="$2"
755
- grep -q "^\${sid}\${_TAB}\${hash}\${_TAB}consumed$" "$SYNKRO_CONSENT_FILE" 2>/dev/null
756
- }
757
-
758
- synkro_consent_clear_consumed() {
759
- local sid="$1" hash="$2"
760
- [ ! -f "$SYNKRO_CONSENT_FILE" ] && return
761
- local tmp="\${SYNKRO_CONSENT_FILE}.tmp"
762
- grep -v "^\${sid}\${_TAB}\${hash}\${_TAB}consumed$" "$SYNKRO_CONSENT_FILE" > "$tmp" 2>/dev/null && mv "$tmp" "$SYNKRO_CONSENT_FILE" 2>/dev/null || true
763
- }
764
-
765
- SYNKRO_LAST_PROMPT_FILE="$HOME/.synkro/.last-prompt"
766
-
767
- synkro_read_last_prompt() {
768
- [ -f "$SYNKRO_LAST_PROMPT_FILE" ] || return
769
- cat "$SYNKRO_LAST_PROMPT_FILE" 2>/dev/null
770
- }
771
-
772
606
  synkro_post_with_retry() {
773
607
  local url="$1" body="$2" timeout="\${3:-8}"
774
608
  local resp
@@ -787,7 +621,7 @@ synkro_post_with_retry() {
787
621
  echo "$resp"
788
622
  }
789
623
  `;
790
- CC_BASH_JUDGE_SCRIPT = `#!/bin/bash
624
+ CURSOR_BASH_JUDGE_SCRIPT = `#!/bin/bash
791
625
  SCRIPT_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
792
626
  . "$SCRIPT_DIR/_synkro-common.sh"
793
627
 
@@ -798,159 +632,52 @@ synkro_ensure_fresh_jwt
798
632
  PAYLOAD=$(cat)
799
633
  if [ -z "$PAYLOAD" ]; then echo '{}'; exit 0; fi
800
634
 
801
- TOOL_NAME=$(echo "$PAYLOAD" | jq -r '.tool_name // empty' 2>/dev/null)
802
- case "$TOOL_NAME" in Bash|Read|Grep|Glob) ;; *) echo '{}'; exit 0 ;; esac
635
+ COMMAND=$(echo "$PAYLOAD" | jq -r '.command // empty' 2>/dev/null)
636
+ if [ -z "$COMMAND" ]; then echo '{}'; exit 0; fi
803
637
 
804
- SESSION_ID=$(echo "$PAYLOAD" | jq -r '.session_id // empty' 2>/dev/null)
805
- TOOL_USE_ID=$(echo "$PAYLOAD" | jq -r '.tool_use_id // empty' 2>/dev/null)
806
638
  CWD=$(echo "$PAYLOAD" | jq -r '.cwd // empty' 2>/dev/null)
639
+ SESSION_ID=$(echo "$PAYLOAD" | jq -r '.conversation_id // empty' 2>/dev/null)
807
640
  GIT_REPO=$(synkro_detect_repo "\${CWD:-.}")
808
- PERMISSION_MODE=$(echo "$PAYLOAD" | jq -r '.permission_mode // empty' 2>/dev/null)
809
- synkro_detect_cc_model
810
-
811
- # Translate tool calls to command string for logging
812
- case "$TOOL_NAME" in
813
- Bash) COMMAND=$(echo "$PAYLOAD" | jq -r '.tool_input.command // empty' 2>/dev/null) ;;
814
- Read) COMMAND="cat $(echo "$PAYLOAD" | jq -r '.tool_input.file_path // empty' 2>/dev/null)" ;;
815
- Grep) COMMAND="grep -r '$(echo "$PAYLOAD" | jq -r '.tool_input.pattern // empty' 2>/dev/null)' $(echo "$PAYLOAD" | jq -r '.tool_input.path // "."' 2>/dev/null)" ;;
816
- Glob) COMMAND="find . -name '$(echo "$PAYLOAD" | jq -r '.tool_input.pattern // empty' 2>/dev/null)'" ;;
817
- esac
818
- if [ -z "$COMMAND" ]; then echo '{}'; exit 0; fi
819
641
 
820
642
  CMD_SHORT=$(printf '%s' "$COMMAND" | head -c 80)
821
643
  synkro_log "bashGuard checking: $CMD_SHORT"
822
644
 
823
- TRANSCRIPT_PATH=$(echo "$PAYLOAD" | jq -r '.transcript_path // empty' 2>/dev/null)
824
- USER_INTENT=""
825
- RECENT_USER_MESSAGES="[]"
826
- if [ -n "$TRANSCRIPT_PATH" ] && [ -f "$TRANSCRIPT_PATH" ]; then
827
- RECENT_USER_MESSAGES=$(tail -400 "$TRANSCRIPT_PATH" | jq -c -s '[.[] | select(.type == "user") | (.message.content | if type == "string" then . else (map(.text? // "") | join(" ")) end) | select(. != null and . != "")] | .[-5:]' 2>/dev/null || echo "[]")
828
- USER_INTENT=$(echo "$RECENT_USER_MESSAGES" | jq -r '.[-1] // ""' 2>/dev/null || echo "")
829
- fi
830
-
831
- # Headless detection
832
- IS_HEADLESS="\${SYNKRO_HEADLESS:-0}"
833
- case "$PERMISSION_MODE" in acceptEdits|bypassPermissions|plan|auto) IS_HEADLESS="1" ;; esac
834
-
835
- LAST_PROMPT=$(synkro_read_last_prompt)
836
-
837
645
  synkro_load_config
838
- ROUTE=$(synkro_route)
839
- TAG=$(synkro_tag "$ROUTE")
840
-
841
646
  if [ "$SYNKRO_SILENT" = "true" ]; then
842
- jq -n --arg m "$TAG bashGuard \u2192 skipped (silent mode)" '{systemMessage: $m}'
843
- exit 0
844
- fi
845
-
846
- if [ "$ROUTE" = "local" ]; then
847
- # \u2500\u2500\u2500 Local grading (local_only privacy or local-cc channel) \u2500\u2500\u2500
848
- GRADER_FILE=$(mktemp -t synkro-bash.XXXXXX)
849
- trap "rm -f \\"$GRADER_FILE\\"" EXIT
850
- printf 'Working directory: %s\\nRepo: %s\\nCommand: %s\\nUser intent (last human message): %s\\nLast user prompt: %s\\nOrg rules: %s\\n' "\${CWD:-.}" "\${GIT_REPO:-unknown}" "$COMMAND" "\${USER_INTENT:-none stated}" "\${LAST_PROMPT:-none}" "\${SYNKRO_RULES:-[]}" > "$GRADER_FILE"
851
-
852
- CC_RESP=$(synkro_local_grade bash < "$GRADER_FILE" 2>&1)
853
- if [ $? -ne 0 ]; then
854
- jq -n --arg m "$TAG bashGuard \u2192 pass: local grader unavailable (run synkro local-cc start)" '{systemMessage: $m}'
855
- exit 0
856
- fi
857
- synkro_parse_local_verdict "$CC_RESP"
858
-
859
- # Build violated rules JSON for full-content capture
860
- VIOLATED_JSON="[]"
861
- [ -n "$LOCAL_RULE_ID" ] && VIOLATED_JSON=$(jq -n --arg r "$LOCAL_RULE_ID" '[$r]')
862
-
863
- if [ "$LOCAL_OK" = "false" ]; then
864
- RULE_MODE="\${LOCAL_RULE_MODE:-$(synkro_rule_mode "\${LOCAL_RULE_ID}")}"
865
- if [ "$RULE_MODE" = "audit" ]; then
866
- REASON="$TAG bashGuard \u2192 warning\${LOCAL_RULE_ID:+ ($LOCAL_RULE_ID)}: \${LOCAL_REASON:-policy violation}"
867
- jq -n --arg m "$REASON" '{systemMessage: $m}'
868
- synkro_dispatch_capture "bash" "warning" "\${LOCAL_SEV}" "\${LOCAL_CAT}" "$TOOL_NAME" "$GIT_REPO" "$SESSION_ID" \\
869
- "$COMMAND" "$LOCAL_REASON" "\${SYNKRO_RULES:-[]}" "$VIOLATED_JSON" "\${RECENT_USER_MESSAGES:-[]}"
870
- else
871
- REASON="$TAG bashGuard \u2192 blocked\${LOCAL_RULE_ID:+ ($LOCAL_RULE_ID)}: \${LOCAL_REASON:-policy violation}. Ask the user for explicit consent before retrying."
872
- jq -n --arg reason "$REASON" \\
873
- '{systemMessage:$reason,hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"deny",permissionDecisionReason:$reason,additionalContext:$reason}}'
874
- synkro_dispatch_capture "bash" "block" "\${LOCAL_SEV}" "\${LOCAL_CAT}" "$TOOL_NAME" "$GIT_REPO" "$SESSION_ID" \\
875
- "$COMMAND" "$LOCAL_REASON" "\${SYNKRO_RULES:-[]}" "$VIOLATED_JSON" "\${RECENT_USER_MESSAGES:-[]}"
876
- fi
877
- else
878
- jq -n --arg m "$TAG bashGuard \u2192 pass: \${LOCAL_REASON:-no policy violations detected}" '{systemMessage: $m}'
879
- synkro_dispatch_capture "bash" "pass" "audit" "\${LOCAL_CAT:-trivial_utility}" "$TOOL_NAME" "$GIT_REPO" "$SESSION_ID" \\
880
- "$COMMAND" "$LOCAL_REASON" "\${SYNKRO_RULES:-[]}" "[]" "\${RECENT_USER_MESSAGES:-[]}"
881
- fi
882
- exit 0
883
- fi
884
-
885
- # \u2500\u2500\u2500 Cloud grading \u2500\u2500\u2500
886
- CC_USAGE="{}"
887
- RECENT_MESSAGES="[]"
888
- RECENT_ACTIONS="[]"
889
- SESSION_SUMMARY=""
890
- if [ -n "$TRANSCRIPT_PATH" ] && [ -f "$TRANSCRIPT_PATH" ]; then
891
- _LAST=$(grep '"type":"assistant"' "$TRANSCRIPT_PATH" 2>/dev/null | tail -1)
892
- if [ -n "$_LAST" ]; then
893
- CC_USAGE=$(echo "$_LAST" | jq -c '{input_tokens:.message.usage.input_tokens,output_tokens:.message.usage.output_tokens,cache_creation_input_tokens:.message.usage.cache_creation_input_tokens,cache_read_input_tokens:.message.usage.cache_read_input_tokens}' 2>/dev/null || echo "{}")
894
- fi
895
- RECENT_MESSAGES=$(tail -400 "$TRANSCRIPT_PATH" | jq -c -s '[.[] | select(.type == "user" or .type == "assistant") | {type, text: (.message.content | if type == "string" then .[0:500] else ([.[]? | (.text? // "") | .[0:300]] | join(" ")) end)}] | .[-10:]' 2>/dev/null || echo "[]")
896
- RECENT_ACTIONS=$(tail -400 "$TRANSCRIPT_PATH" | jq -c -s '[.[] | select(.type == "assistant") | .message.content[]? | select(.type == "tool_use") | {tool: .name, input: (.input // {} | tostring | .[0:200])}] | .[-5:]' 2>/dev/null || echo "[]")
897
- SESSION_SUMMARY=$(grep '"type":"summary"' "$TRANSCRIPT_PATH" 2>/dev/null | tail -1 | jq -r '.summary // empty' 2>/dev/null || echo "")
647
+ echo '{}'; exit 0
898
648
  fi
899
649
 
900
650
  BODY=$(jq -n \\
901
- --arg hook_event "PreToolUse" \\
902
- --arg tool_name "$TOOL_NAME" \\
903
- --argjson tool_input "$(echo "$PAYLOAD" | jq -c '.tool_input // {}')" \\
904
- --arg user_intent "$USER_INTENT" \\
905
- --argjson recent_user_messages "$RECENT_USER_MESSAGES" \\
906
- --argjson recent_messages "$RECENT_MESSAGES" \\
907
- --argjson recent_actions "$RECENT_ACTIONS" \\
651
+ --arg cmd "$COMMAND" \\
908
652
  --arg session_id "$SESSION_ID" \\
909
- --arg tool_use_id "$TOOL_USE_ID" \\
910
653
  --arg cwd "$CWD" \\
911
654
  --arg repo "$GIT_REPO" \\
912
- --arg permission_mode "$PERMISSION_MODE" \\
913
- --arg cc_model "$CC_MODEL" \\
914
- --argjson cc_usage "$CC_USAGE" \\
915
- --arg session_summary "$SESSION_SUMMARY" \\
916
- --arg last_prompt "\${LAST_PROMPT:-}" \\
917
655
  '{
918
- hook_event: $hook_event,
919
- tool_name: $tool_name,
920
- tool_input: $tool_input,
921
- user_intent: (if ($user_intent | length) > 0 then $user_intent else null end),
922
- last_user_message: (if ($last_prompt | length) > 0 then $last_prompt else null end),
923
- recent_user_messages: $recent_user_messages,
924
- recent_messages: $recent_messages,
925
- recent_actions: $recent_actions,
656
+ hook_event: "PreToolUse",
657
+ tool_name: "Bash",
658
+ tool_input: {command: $cmd},
659
+ response_format: "cursor",
926
660
  session_id: (if ($session_id | length) > 0 then $session_id else null end),
927
- tool_use_id: (if ($tool_use_id | length) > 0 then $tool_use_id else null end),
928
661
  cwd: (if ($cwd | length) > 0 then $cwd else null end),
929
- repo: (if ($repo | length) > 0 then $repo else null end),
930
- permission_mode: (if ($permission_mode | length) > 0 then $permission_mode else null end),
931
- cc_model: (if ($cc_model | length) > 0 then $cc_model else null end),
932
- cc_usage: $cc_usage,
933
- session_summary: (if ($session_summary | length) > 0 then $session_summary else null end)
662
+ repo: (if ($repo | length) > 0 then $repo else null end)
934
663
  }')
935
664
 
936
- RESP=$(synkro_post_with_retry "\${GATEWAY_URL}/api/v1/hook/judge" "$BODY" 8)
665
+ RESP=$(synkro_post_with_retry "\${GATEWAY_URL}/api/v1/hook/judge" "$BODY" 6)
937
666
 
938
667
  if [ -z "$RESP" ]; then
939
668
  synkro_log "bashGuard $CMD_SHORT \u2192 error (timeout)"
940
- echo '{}'
941
- exit 0
669
+ echo '{}'; exit 0
942
670
  fi
943
671
 
944
- if ! echo "$RESP" | jq -e '.hook_response' >/dev/null 2>&1; then
945
- synkro_log "bashGuard $CMD_SHORT \u2192 pass (no hook_response)"
672
+ # Server returns cursor-format directly in hook_response
673
+ if echo "$RESP" | jq -e '.hook_response' >/dev/null 2>&1; then
674
+ echo "$RESP" | jq -c '.hook_response'
675
+ else
946
676
  echo '{}'
947
- exit 0
948
677
  fi
949
-
950
- echo "$RESP" | jq -c '.hook_response'
951
678
  exit 0
952
679
  `;
953
- CC_EDIT_PRECHECK_SCRIPT = `#!/bin/bash
680
+ CURSOR_EDIT_PRECHECK_SCRIPT = `#!/bin/bash
954
681
  SCRIPT_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
955
682
  . "$SCRIPT_DIR/_synkro-common.sh"
956
683
 
@@ -962,245 +689,79 @@ PAYLOAD=$(cat)
962
689
  if [ -z "$PAYLOAD" ]; then echo '{}'; exit 0; fi
963
690
 
964
691
  TOOL_NAME=$(echo "$PAYLOAD" | jq -r '.tool_name // empty' 2>/dev/null)
965
- case "$TOOL_NAME" in Edit|Write|MultiEdit|NotebookEdit) ;; *) echo '{}'; exit 0 ;; esac
966
-
967
- TOOL_INPUT=$(echo "$PAYLOAD" | jq -c '.tool_input // {}' 2>/dev/null)
968
- SESSION_ID=$(echo "$PAYLOAD" | jq -r '.session_id // empty' 2>/dev/null)
969
- TOOL_USE_ID=$(echo "$PAYLOAD" | jq -r '.tool_use_id // empty' 2>/dev/null)
970
692
  CWD=$(echo "$PAYLOAD" | jq -r '.cwd // empty' 2>/dev/null)
693
+ SESSION_ID=$(echo "$PAYLOAD" | jq -r '.conversation_id // empty' 2>/dev/null)
971
694
  GIT_REPO=$(synkro_detect_repo "\${CWD:-.}")
972
- PERMISSION_MODE=$(echo "$PAYLOAD" | jq -r '.permission_mode // empty' 2>/dev/null)
973
- synkro_detect_cc_model
974
695
 
975
- FILE_PATH=$(echo "$TOOL_INPUT" | jq -r '.file_path // .notebook_path // .path // empty' 2>/dev/null)
696
+ FILE_PATH=$(echo "$PAYLOAD" | jq -r '.tool_input.file_path // .tool_input.path // .tool_input.target_file // empty' 2>/dev/null)
697
+ CONTENT=$(echo "$PAYLOAD" | jq -r '.tool_input.content // .tool_input.new_string // .tool_input.code_edit // empty' 2>/dev/null)
976
698
  if [ -z "$FILE_PATH" ]; then echo '{}'; exit 0; fi
977
699
 
978
- FILE_SHORT=$(basename "$FILE_PATH")
979
- synkro_log "editGuard checking: $FILE_SHORT"
980
-
981
- IS_HEADLESS="\${SYNKRO_HEADLESS:-0}"
982
- case "$PERMISSION_MODE" in acceptEdits|bypassPermissions|plan|auto) IS_HEADLESS="1" ;; esac
983
-
984
- # Read file before edit for reconstruction
985
- FILE_BEFORE=""
986
- if [ "$TOOL_NAME" != "Write" ] && [ -n "$FILE_PATH" ] && [ -f "$FILE_PATH" ]; then
987
- FILE_BEFORE=$(head -c 65536 "$FILE_PATH" 2>/dev/null || echo "")
988
- fi
989
-
990
- # Reconstruct proposed content
991
- case "$TOOL_NAME" in
992
- Write) PROPOSED=$(echo "$TOOL_INPUT" | jq -r '.content // ""' 2>/dev/null) ;;
993
- Edit|MultiEdit)
994
- if [ -n "$FILE_BEFORE" ] && command -v python3 >/dev/null 2>&1; then
995
- PROPOSED=$(FILE_BEFORE_LITERAL="$FILE_BEFORE" TOOL_INPUT_LITERAL="$TOOL_INPUT" python3 -c '
996
- import os, json, sys
997
- fb = os.environ.get("FILE_BEFORE_LITERAL", "")
998
- ti = json.loads(os.environ.get("TOOL_INPUT_LITERAL", "{}"))
999
- result = fb
1000
- if "old_string" in ti and "new_string" in ti:
1001
- if ti["old_string"]: result = result.replace(ti["old_string"], ti["new_string"], 1)
1002
- elif "edits" in ti and isinstance(ti["edits"], list):
1003
- for e in ti["edits"]:
1004
- old = e.get("old_string", "") if isinstance(e, dict) else ""
1005
- new = e.get("new_string", "") if isinstance(e, dict) else ""
1006
- if old: result = result.replace(old, new, 1)
1007
- sys.stdout.write(result)
1008
- ' 2>/dev/null)
1009
- fi
1010
- if [ -z "$PROPOSED" ]; then
1011
- if [ "$TOOL_NAME" = "MultiEdit" ]; then
1012
- PROPOSED=$(echo "$TOOL_INPUT" | jq -r '[.edits[]?.new_string // ""] | join("\\n\\n--- chunk ---\\n\\n")' 2>/dev/null)
1013
- else
1014
- PROPOSED=$(echo "$TOOL_INPUT" | jq -r '.new_string // ""' 2>/dev/null)
1015
- fi
1016
- fi ;;
1017
- NotebookEdit) PROPOSED=$(echo "$TOOL_INPUT" | jq -r '.new_source // ""' 2>/dev/null) ;;
1018
- esac
1019
- if [ -z "$PROPOSED" ]; then echo '{}'; exit 0; fi
1020
-
1021
- DIFF_FIELD=$(echo "$TOOL_INPUT" | jq -c '{old_string, new_string, edits} | with_entries(select(.value != null))' 2>/dev/null)
1022
- [ -z "$DIFF_FIELD" ] || [ "$DIFF_FIELD" = "null" ] || [ "$DIFF_FIELD" = "{}" ] && DIFF_FIELD="null"
1023
-
1024
- # Extract user intent from transcript
1025
- USER_INTENT=""
1026
- RECENT_USER_MESSAGES="[]"
1027
- RECENT_MESSAGES="[]"
1028
- RECENT_ACTIONS="[]"
1029
- TRANSCRIPT_PATH=$(echo "$PAYLOAD" | jq -r '.transcript_path // empty' 2>/dev/null)
1030
- if [ -n "$TRANSCRIPT_PATH" ] && [ -f "$TRANSCRIPT_PATH" ]; then
1031
- RECENT_USER_MESSAGES=$(tail -400 "$TRANSCRIPT_PATH" | jq -c -s '[.[] | select(.type == "user") | (.message.content | if type == "string" then . else (map(.text? // "") | join(" ")) end) | select(. != null and . != "")] | .[-5:]' 2>/dev/null || echo "[]")
1032
- USER_INTENT=$(echo "$RECENT_USER_MESSAGES" | jq -r '.[-1] // ""' 2>/dev/null || echo "")
1033
- RECENT_MESSAGES=$(tail -400 "$TRANSCRIPT_PATH" | jq -c -s '[.[] | select(.type == "user" or .type == "assistant") | {type, text: (.message.content | if type == "string" then .[0:500] else ([.[]? | (.text? // "") | .[0:300]] | join(" ")) end)}] | .[-10:]' 2>/dev/null || echo "[]")
1034
- RECENT_ACTIONS=$(tail -200 "$TRANSCRIPT_PATH" | jq -c -s '[.[] | select(.type == "assistant") | .message.content[]? | select(.type == "tool_use") | {tool: .name, input: (.input // {} | tostring | .[0:200])}] | .[-5:]' 2>/dev/null || echo "[]")
1035
- fi
1036
-
1037
- LAST_PROMPT=$(synkro_read_last_prompt)
700
+ BASENAME=$(basename "$FILE_PATH" 2>/dev/null || echo "$FILE_PATH")
701
+ synkro_log "editGuard checking: $BASENAME"
1038
702
 
1039
703
  synkro_load_config
1040
- ROUTE=$(synkro_route)
1041
- TAG=$(synkro_tag "$ROUTE")
1042
-
1043
704
  if [ "$SYNKRO_SILENT" = "true" ]; then
1044
- jq -n --arg m "$TAG editGuard \u2192 skipped (silent mode)" '{systemMessage: $m}'
1045
- exit 0
1046
- fi
1047
-
1048
- if [ "$ROUTE" = "local" ]; then
1049
- # \u2500\u2500\u2500 Local grading (local_only privacy or local-cc channel) \u2500\u2500\u2500
1050
- GRADER_FILE=$(mktemp -t synkro-edit.XXXXXX)
1051
- trap "rm -f \\"$GRADER_FILE\\"" EXIT
1052
- printf 'Working directory: %s\\nRepo: %s\\nFile: %s\\nProposed content (first 4000 chars):\\n%s\\nUser intent (last human message): %s\\nLast user prompt: %s\\nOrg rules: %s\\n' "\${CWD:-.}" "\${GIT_REPO:-unknown}" "$FILE_PATH" "$(printf '%s' "$PROPOSED" | head -c 4000)" "\${USER_INTENT:-none stated}" "\${LAST_PROMPT:-none}" "\${SYNKRO_RULES:-[]}" > "$GRADER_FILE"
1053
-
1054
- CC_RESP=$(synkro_local_grade edit < "$GRADER_FILE" 2>&1)
1055
- if [ $? -ne 0 ]; then
1056
- jq -n --arg m "$TAG editGuard \u2192 pass: local grader unavailable (run synkro local-cc start)" '{systemMessage: $m}'
1057
- exit 0
1058
- fi
1059
- synkro_parse_local_verdict "$CC_RESP"
1060
-
1061
- # Build edit content description and violated rules for full-content capture
1062
- EDIT_CONTENT="file=$FILE_PATH content=$(printf '%s' "$PROPOSED" | head -c 2000)"
1063
- VIOLATED_JSON="[]"
1064
- [ -n "$LOCAL_RULE_ID" ] && VIOLATED_JSON=$(jq -n --arg r "$LOCAL_RULE_ID" '[$r]')
1065
-
1066
- if [ "$LOCAL_OK" = "false" ]; then
1067
- RULE_MODE="\${LOCAL_RULE_MODE:-$(synkro_rule_mode "\${LOCAL_RULE_ID}")}"
1068
- if [ "$RULE_MODE" = "audit" ]; then
1069
- REASON="$TAG editGuard $FILE_SHORT \u2192 warning\${LOCAL_RULE_ID:+ ($LOCAL_RULE_ID)}: \${LOCAL_REASON:-policy violation}"
1070
- jq -n --arg m "$REASON" '{systemMessage: $m}'
1071
- synkro_dispatch_capture "edit" "warning" "\${LOCAL_SEV}" "\${LOCAL_CAT}" "$TOOL_NAME" "$GIT_REPO" "$SESSION_ID" \\
1072
- "$EDIT_CONTENT" "$LOCAL_REASON" "\${SYNKRO_RULES:-[]}" "$VIOLATED_JSON" "[]"
1073
- else
1074
- REASON="$TAG editGuard $FILE_SHORT \u2192 blocked\${LOCAL_RULE_ID:+ ($LOCAL_RULE_ID)}: \${LOCAL_REASON:-policy violation}. Ask the user for explicit consent before retrying."
1075
- jq -n --arg reason "$REASON" \\
1076
- '{systemMessage:$reason,hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"deny",permissionDecisionReason:$reason,additionalContext:$reason}}'
1077
- synkro_dispatch_capture "edit" "block" "\${LOCAL_SEV}" "\${LOCAL_CAT}" "$TOOL_NAME" "$GIT_REPO" "$SESSION_ID" \\
1078
- "$EDIT_CONTENT" "$LOCAL_REASON" "\${SYNKRO_RULES:-[]}" "$VIOLATED_JSON" "[]"
1079
- fi
1080
- else
1081
- jq -n --arg m "$TAG editGuard $FILE_SHORT \u2192 pass: \${LOCAL_REASON:-no policy violations detected}" '{systemMessage: $m}'
1082
- synkro_dispatch_capture "edit" "pass" "audit" "\${LOCAL_CAT:-trivial_edit}" "$TOOL_NAME" "$GIT_REPO" "$SESSION_ID" \\
1083
- "$EDIT_CONTENT" "$LOCAL_REASON" "\${SYNKRO_RULES:-[]}" "[]" "[]"
1084
- fi
1085
- exit 0
705
+ echo '{}'; exit 0
1086
706
  fi
1087
707
 
1088
- # \u2500\u2500\u2500 Cloud grading \u2500\u2500\u2500
1089
708
  BODY=$(jq -n \\
1090
- --arg hook_event "PreToolUse" \\
1091
- --arg tool_name "$TOOL_NAME" \\
1092
- --argjson tool_input "$TOOL_INPUT" \\
1093
709
  --arg file_path "$FILE_PATH" \\
1094
- --arg content "$PROPOSED" \\
1095
- --arg file_before "$FILE_BEFORE" \\
1096
- --argjson diff "$DIFF_FIELD" \\
1097
- --arg user_intent "$USER_INTENT" \\
1098
- --argjson recent_user_messages "$RECENT_USER_MESSAGES" \\
1099
- --argjson recent_messages "$RECENT_MESSAGES" \\
1100
- --argjson recent_actions "$RECENT_ACTIONS" \\
710
+ --arg content "$CONTENT" \\
1101
711
  --arg session_id "$SESSION_ID" \\
1102
- --arg tool_use_id "$TOOL_USE_ID" \\
1103
712
  --arg cwd "$CWD" \\
1104
713
  --arg repo "$GIT_REPO" \\
1105
- --arg permission_mode "$PERMISSION_MODE" \\
1106
- --arg headless_flag "\${SYNKRO_HEADLESS:-0}" \\
1107
- --arg last_prompt "\${LAST_PROMPT:-}" \\
1108
714
  '{
1109
- hook_event: $hook_event,
1110
- tool_name: $tool_name,
1111
- tool_input: $tool_input,
715
+ hook_event: "PreToolUse",
716
+ tool_name: "Edit",
717
+ tool_input: {file_path: $file_path, content: $content},
1112
718
  file_path: $file_path,
1113
719
  content: $content,
1114
- file_before: (if ($file_before | length) > 0 then $file_before else null end),
1115
- diff: $diff,
1116
- user_intent: (if ($user_intent | length) > 0 then $user_intent else null end),
1117
- last_user_message: (if ($last_prompt | length) > 0 then $last_prompt else null end),
1118
- recent_user_messages: $recent_user_messages,
1119
- recent_messages: $recent_messages,
1120
- recent_actions: $recent_actions,
720
+ response_format: "cursor",
1121
721
  session_id: (if ($session_id | length) > 0 then $session_id else null end),
1122
- tool_use_id: (if ($tool_use_id | length) > 0 then $tool_use_id else null end),
1123
722
  cwd: (if ($cwd | length) > 0 then $cwd else null end),
1124
- repo: (if ($repo | length) > 0 then $repo else null end),
1125
- permission_mode: (if ($permission_mode | length) > 0 then $permission_mode else null end),
1126
- headless: ($headless_flag == "1")
723
+ repo: (if ($repo | length) > 0 then $repo else null end)
1127
724
  }')
1128
725
 
1129
726
  RESP=$(synkro_post_with_retry "\${GATEWAY_URL}/api/v1/hook/judge" "$BODY" 8)
1130
727
 
1131
728
  if [ -z "$RESP" ]; then
1132
- synkro_log "editGuard $FILE_SHORT \u2192 error (timeout)"
1133
- echo '{}'
1134
- exit 0
1135
- fi
1136
-
1137
- if ! echo "$RESP" | jq -e 'type == "object"' >/dev/null 2>&1; then
1138
- synkro_log "editGuard $FILE_SHORT \u2192 error (bad response)"
1139
- echo '{}'
1140
- exit 0
729
+ synkro_log "editGuard $BASENAME \u2192 error (timeout)"
730
+ echo '{}'; exit 0
1141
731
  fi
1142
732
 
1143
- DECISION=$(echo "$RESP" | jq -r '.hook_response.hookSpecificOutput.permissionDecision // "allow"' 2>/dev/null)
1144
- if [ "$DECISION" = "deny" ] || [ "$DECISION" = "ask" ]; then
1145
- synkro_log "editGuard $FILE_SHORT \u2192 BLOCKED"
733
+ if echo "$RESP" | jq -e '.hook_response' >/dev/null 2>&1; then
1146
734
  echo "$RESP" | jq -c '.hook_response'
1147
735
  else
1148
- REASON=$(echo "$RESP" | jq -r '.hook_response.reason // empty' 2>/dev/null)
1149
- if [ -n "$REASON" ]; then
1150
- synkro_log "editGuard $FILE_SHORT \u2192 pass: $REASON"
1151
- else
1152
- synkro_log "editGuard $FILE_SHORT \u2192 pass"
1153
- fi
1154
- echo "$RESP" | jq -c '.hook_response // {}'
736
+ echo '{}'
1155
737
  fi
1156
738
  exit 0
1157
739
  `;
1158
- CC_EDIT_CAPTURE_SCRIPT = `#!/bin/bash
740
+ CURSOR_EDIT_CAPTURE_SCRIPT = `#!/bin/bash
1159
741
  SCRIPT_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
1160
742
  . "$SCRIPT_DIR/_synkro-common.sh"
1161
743
 
1162
744
  JWT=$(synkro_load_jwt)
1163
745
  if [ -z "$JWT" ]; then echo '{}'; exit 0; fi
1164
- synkro_ensure_fresh_jwt
1165
746
 
1166
747
  PAYLOAD=$(cat)
1167
748
  if [ -z "$PAYLOAD" ]; then echo '{}'; exit 0; fi
1168
749
 
1169
- TOOL_NAME=$(echo "$PAYLOAD" | jq -r '.tool_name // empty' 2>/dev/null)
1170
- case "$TOOL_NAME" in Edit|Write|MultiEdit|NotebookEdit) ;; *) echo '{}'; exit 0 ;; esac
750
+ FILE_PATH=$(echo "$PAYLOAD" | jq -r '.file_path // empty' 2>/dev/null)
751
+ if [ -z "$FILE_PATH" ]; then echo '{}'; exit 0; fi
1171
752
 
1172
- TOOL_INPUT=$(echo "$PAYLOAD" | jq -c '.tool_input // {}' 2>/dev/null)
1173
- SESSION_ID=$(echo "$PAYLOAD" | jq -r '.session_id // empty' 2>/dev/null)
1174
- TOOL_USE_ID=$(echo "$PAYLOAD" | jq -r '.tool_use_id // empty' 2>/dev/null)
1175
- CWD=$(echo "$PAYLOAD" | jq -r '.cwd // empty' 2>/dev/null)
753
+ CWD=$(echo "$PAYLOAD" | jq -r '.cwd // .workspace_roots[0] // empty' 2>/dev/null)
754
+ SESSION_ID=$(echo "$PAYLOAD" | jq -r '.conversation_id // empty' 2>/dev/null)
1176
755
  GIT_REPO=$(synkro_detect_repo "\${CWD:-.}")
1177
- synkro_detect_cc_model
1178
-
1179
- # Correction followup (backgrounded)
1180
- if [ -n "$SESSION_ID" ] && [ -n "$TOOL_USE_ID" ]; then
1181
- (
1182
- BODY=$(jq -n --arg tid "$TOOL_USE_ID" --arg sid "$SESSION_ID" '{capture_type:"correction_followup",tool_use_id:$tid,session_id:$sid,decision:"allow"}')
1183
- curl -sS -X POST "\${GATEWAY_URL}/api/v1/hook/capture" \\
1184
- -H "Content-Type: application/json" -H "Authorization: Bearer $JWT" \\
1185
- -d "$BODY" --max-time 2 >/dev/null 2>&1
1186
- ) &
1187
- fi
1188
-
1189
- # Fire-and-forget: POST edit scan to /v1/hook/judge (PostToolUse)
1190
- FILE_PATH=$(echo "$TOOL_INPUT" | jq -r '.file_path // .notebook_path // .path // empty' 2>/dev/null)
1191
- if [ -z "$FILE_PATH" ] || [ ! -f "$FILE_PATH" ]; then echo '{}'; exit 0; fi
1192
-
1193
- BASENAME=$(basename "$FILE_PATH")
1194
- synkro_log "editScan: $BASENAME"
1195
-
1196
- FILE_CONTENT=$(head -c 65536 "$FILE_PATH" 2>/dev/null || echo "")
1197
- if [ -z "$FILE_CONTENT" ]; then echo '{}'; exit 0; fi
756
+ BASENAME=$(basename "$FILE_PATH" 2>/dev/null || echo "$FILE_PATH")
1198
757
 
1199
- DIFF_FIELD=$(echo "$TOOL_INPUT" | jq -c '{old_string, new_string, edits} | with_entries(select(.value != null))' 2>/dev/null || echo "null")
1200
- [ -z "$DIFF_FIELD" ] || [ "$DIFF_FIELD" = "null" ] || [ "$DIFF_FIELD" = "{}" ] && DIFF_FIELD="null"
758
+ FULL_PATH="$FILE_PATH"
759
+ [ -n "$CWD" ] && FULL_PATH="$CWD/$FILE_PATH"
760
+ FULL_CONTENT=""
761
+ [ -f "$FULL_PATH" ] && FULL_CONTENT=$(head -c 50000 "$FULL_PATH" 2>/dev/null || true)
1201
762
 
1202
763
  DEPS_JSON="{}"
1203
- _PKG_DIR=$(dirname "$FILE_PATH")
764
+ _PKG_DIR="\${CWD:-.}"
1204
765
  while [ "$_PKG_DIR" != "/" ]; do
1205
766
  if [ -f "$_PKG_DIR/package.json" ]; then
1206
767
  DEPS_JSON=$(jq -c '(.dependencies // {}) + (.devDependencies // {})' "$_PKG_DIR/package.json" 2>/dev/null || echo "{}")
@@ -1209,907 +770,1961 @@ while [ "$_PKG_DIR" != "/" ]; do
1209
770
  _PKG_DIR=$(dirname "$_PKG_DIR")
1210
771
  done
1211
772
 
1212
- synkro_load_config
1213
- ROUTE=$(synkro_route)
1214
- TAG=$(synkro_tag "$ROUTE")
1215
-
1216
- if [ "$SYNKRO_SILENT" = "true" ]; then
1217
- echo '{}'; exit 0
1218
- fi
1219
-
1220
- if [ "$ROUTE" = "local" ]; then
1221
- # \u2500\u2500\u2500 Local edit scan (local_only privacy or local-cc channel) \u2500\u2500\u2500
1222
- GRADER_FILE=$(mktemp -t synkro-escan.XXXXXX)
1223
- trap "rm -f \\"$GRADER_FILE\\"" EXIT
1224
- printf 'Working directory: %s\\nRepo: %s\\nFile: %s\\nContent (first 4000 chars):\\n%s\\nOrg rules: %s\\n' "\${CWD:-.}" "\${GIT_REPO:-unknown}" "$FILE_PATH" "$(printf '%s' "$FILE_CONTENT" | head -c 4000)" "\${SYNKRO_RULES:-[]}" > "$GRADER_FILE"
1225
-
1226
- CC_RESP=$(synkro_local_grade edit < "$GRADER_FILE" 2>&1)
1227
- if [ $? -ne 0 ]; then
1228
- echo '{}'; exit 0
1229
- fi
1230
- synkro_parse_local_verdict "$CC_RESP"
1231
-
1232
- SCAN_CONTENT="file=$FILE_PATH"
1233
- SCAN_VIOLATED="[]"
1234
- [ -n "$LOCAL_RULE_ID" ] && SCAN_VIOLATED=$(jq -n --arg r "$LOCAL_RULE_ID" '[$r]')
1235
-
1236
- if [ "$LOCAL_OK" = "false" ]; then
1237
- RULE_MODE="\${LOCAL_RULE_MODE:-$(synkro_rule_mode "\${LOCAL_RULE_ID}")}"
1238
- if [ "$RULE_MODE" = "audit" ]; then
1239
- REASON="$TAG editScan $BASENAME \u2192 warning\${LOCAL_RULE_ID:+ ($LOCAL_RULE_ID)}: \${LOCAL_REASON:-policy violation}"
1240
- jq -n --arg m "$REASON" '{systemMessage: $m}'
1241
- synkro_dispatch_capture "edit_scan" "warning" "\${LOCAL_SEV}" "\${LOCAL_CAT}" "$TOOL_NAME" "$GIT_REPO" "$SESSION_ID" \\
1242
- "$SCAN_CONTENT" "$LOCAL_REASON" "\${SYNKRO_RULES:-[]}" "$SCAN_VIOLATED" "[]"
1243
- else
1244
- REASON="$TAG editScan $BASENAME \u2192 block: \${LOCAL_REASON:-policy violation}"
1245
- jq -n --arg m "$REASON" '{systemMessage: $m, additionalContext: $m}'
1246
- synkro_dispatch_capture "edit_scan" "block" "\${LOCAL_SEV}" "\${LOCAL_CAT}" "$TOOL_NAME" "$GIT_REPO" "$SESSION_ID" \\
1247
- "$SCAN_CONTENT" "$LOCAL_REASON" "\${SYNKRO_RULES:-[]}" "$SCAN_VIOLATED" "[]"
1248
- fi
1249
- else
1250
- jq -n --arg m "$TAG editScan $BASENAME \u2192 pass: \${LOCAL_REASON:-no policy violations detected}" '{systemMessage: $m}'
1251
- synkro_dispatch_capture "edit_scan" "pass" "audit" "\${LOCAL_CAT:-trivial_edit}" "$TOOL_NAME" "$GIT_REPO" "$SESSION_ID" \\
1252
- "$SCAN_CONTENT" "$LOCAL_REASON" "\${SYNKRO_RULES:-[]}" "[]" "[]"
1253
- fi
1254
- exit 0
1255
- fi
1256
-
1257
- # \u2500\u2500\u2500 Cloud edit scan \u2500\u2500\u2500
1258
- BODY=$(jq -n \\
1259
- --arg hook_event "PostToolUse" \\
1260
- --arg tool_name "$TOOL_NAME" \\
1261
- --argjson tool_input "$TOOL_INPUT" \\
1262
- --arg file_path "$FILE_PATH" \\
1263
- --arg content "$FILE_CONTENT" \\
1264
- --argjson diff "$DIFF_FIELD" \\
1265
- --argjson dependencies "$DEPS_JSON" \\
1266
- --arg session_id "$SESSION_ID" \\
1267
- --arg tool_use_id "$TOOL_USE_ID" \\
1268
- --arg cwd "$CWD" \\
1269
- --arg repo "$GIT_REPO" \\
1270
- '{
1271
- hook_event: $hook_event,
1272
- tool_name: $tool_name,
1273
- tool_input: $tool_input,
1274
- file_path: $file_path,
1275
- content: $content,
1276
- diff: $diff,
1277
- dependencies: $dependencies,
1278
- session_id: (if ($session_id | length) > 0 then $session_id else null end),
1279
- tool_use_id: (if ($tool_use_id | length) > 0 then $tool_use_id else null end),
1280
- cwd: (if ($cwd | length) > 0 then $cwd else null end),
1281
- repo: (if ($repo | length) > 0 then $repo else null end)
1282
- }')
1283
-
1284
- RESP=$(synkro_post_with_retry "\${GATEWAY_URL}/api/v1/hook/judge" "$BODY" 12)
773
+ synkro_log "editScan $BASENAME"
1285
774
 
1286
- if [ -z "$RESP" ] || ! echo "$RESP" | jq -e 'type == "object"' >/dev/null 2>&1; then
1287
- synkro_log "editScan $BASENAME \u2192 error (no response)"
1288
- echo '{}'
1289
- exit 0
1290
- fi
775
+ (
776
+ BODY=$(jq -n \\
777
+ --arg file_path "$FILE_PATH" --arg content "$FULL_CONTENT" \\
778
+ --arg session_id "$SESSION_ID" --arg cwd "$CWD" --arg repo "$GIT_REPO" \\
779
+ --argjson deps "$DEPS_JSON" \\
780
+ '{capture_type:"edit_scan",tool_input:{file_path:$file_path,content:$content},edit_verdict:{ok:true},dependencies:$deps}
781
+ + (if ($session_id | length) > 0 then {session_id:$session_id} else {} end)
782
+ + (if ($cwd | length) > 0 then {cwd:$cwd} else {} end)
783
+ + (if ($repo | length) > 0 then {repo:$repo} else {} end)')
784
+ curl -sS -X POST "\${GATEWAY_URL}/api/v1/hook/capture" \\
785
+ -H "Content-Type: application/json" -H "Authorization: Bearer $JWT" \\
786
+ -d "$BODY" --max-time 10 >/dev/null 2>&1 || true
787
+ ) &
788
+ disown 2>/dev/null || true
1291
789
 
1292
- if echo "$RESP" | jq -e '.hook_response' >/dev/null 2>&1; then
1293
- echo "$RESP" | jq -c '.hook_response'
1294
- else
1295
- echo '{}'
1296
- fi
790
+ echo '{}'
1297
791
  exit 0
1298
792
  `;
1299
- CC_PLAN_JUDGE_SCRIPT = `#!/bin/bash
793
+ CURSOR_BASH_FOLLOWUP_SCRIPT = `#!/bin/bash
1300
794
  SCRIPT_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
1301
795
  . "$SCRIPT_DIR/_synkro-common.sh"
1302
796
 
1303
797
  JWT=$(synkro_load_jwt)
1304
798
  if [ -z "$JWT" ]; then echo '{}'; exit 0; fi
1305
- synkro_ensure_fresh_jwt
1306
799
 
1307
800
  PAYLOAD=$(cat)
1308
- if [ -z "$PAYLOAD" ]; then echo '{}'; exit 0; fi
1309
-
1310
801
  TOOL_NAME=$(echo "$PAYLOAD" | jq -r '.tool_name // empty' 2>/dev/null)
1311
- if [ "$TOOL_NAME" != "ExitPlanMode" ]; then echo '{}'; exit 0; fi
1312
-
1313
- # ExitPlanMode's tool_input contains {allowedPrompts:[...]}, not plan content.
1314
- # Read from the most recently modified plan file instead.
1315
- PLANS_DIR="$HOME/.claude/plans"
1316
- PLAN_FILE=$(ls -t "$PLANS_DIR"/*.md 2>/dev/null | head -1)
1317
- if [ -z "$PLAN_FILE" ] || [ ! -f "$PLAN_FILE" ]; then echo '{}'; exit 0; fi
1318
- PLAN=$(cat "$PLAN_FILE" 2>/dev/null)
1319
- if [ -z "$PLAN" ] || [ \${#PLAN} -lt 20 ]; then echo '{}'; exit 0; fi
1320
-
1321
- SESSION_ID=$(echo "$PAYLOAD" | jq -r '.session_id // empty' 2>/dev/null)
1322
- CWD=$(echo "$PAYLOAD" | jq -r '.cwd // empty' 2>/dev/null)
1323
- GIT_REPO=$(synkro_detect_repo "\${CWD:-.}")
1324
- synkro_detect_cc_model
1325
-
1326
- PLAN_SHORT=$(printf '%s' "$PLAN" | head -c 80)
1327
- synkro_log "planReview checking: $PLAN_SHORT..."
1328
-
1329
- # Write review verdict into the plan file so it survives ExitPlanMode rejection
1330
- append_review_to_plan() {
1331
- local verdict="$1"
1332
- local tmp="\${PLAN_FILE}.synkro.tmp"
1333
- sed '/^<!-- synkro-plan-review -->$/,/^<!-- \\/synkro-plan-review -->$/d' "$PLAN_FILE" | sed -e :a -e '/^\\n*$/{$d;N;ba' -e '}' > "$tmp" 2>/dev/null
1334
- printf '\\n\\n<!-- synkro-plan-review -->\\n\\n---\\n\\n**Synkro Plan Review** \u2014 %s\\n\\n%s\\n\\n<!-- /synkro-plan-review -->\\n' "$(date '+%Y-%m-%d %H:%M')" "$verdict" >> "$tmp"
1335
- mv "$tmp" "$PLAN_FILE" 2>/dev/null
1336
- }
802
+ case "$TOOL_NAME" in Shell|Bash|terminal|run_terminal_cmd|execute_command) ;; *) echo '{}'; exit 0 ;; esac
1337
803
 
1338
- synkro_load_config
1339
- ROUTE=$(synkro_route)
1340
- TAG=$(synkro_tag "$ROUTE")
804
+ SESSION_ID=$(echo "$PAYLOAD" | jq -r '.conversation_id // empty' 2>/dev/null)
805
+ TOOL_USE_ID=$(echo "$PAYLOAD" | jq -r '.tool_use_id // empty' 2>/dev/null)
1341
806
 
1342
- if [ "$SYNKRO_SILENT" = "true" ]; then
1343
- jq -n --arg m "$TAG planReview \u2192 skipped (silent mode)" '{systemMessage: $m}'
1344
- exit 0
807
+ IS_ERROR=$(echo "$PAYLOAD" | jq -r '.tool_result.is_error // false' 2>/dev/null)
808
+ CMD=$(echo "$PAYLOAD" | jq -r '.tool_input.command // empty' 2>/dev/null)
809
+ CMD_HASH=""
810
+ if [ -n "$CMD" ]; then
811
+ CMD_HASH=$(printf '%s' "$CMD" | shasum -a 256 | cut -c1-16)
1345
812
  fi
1346
813
 
1347
- if [ "$ROUTE" = "local" ]; then
1348
- GRADER_FILE=$(mktemp -t synkro-plan.XXXXXX)
1349
- trap "rm -f \\"$GRADER_FILE\\"" EXIT
1350
- printf 'Working directory: %s\\nRepo: %s\\nPlan:\\n%s\\nOrg rules: %s\\n' "\${CWD:-.}" "\${GIT_REPO:-unknown}" "$(printf '%s' "$PLAN" | head -c 8000)" "\${SYNKRO_RULES:-[]}" > "$GRADER_FILE"
1351
-
1352
- CC_RESP=$(synkro_local_grade plan < "$GRADER_FILE" 2>&1)
1353
- if [ $? -ne 0 ]; then
1354
- echo '{}'; exit 0
1355
- fi
1356
- synkro_parse_local_verdict "$CC_RESP"
1357
-
1358
- PLAN_CONTENT=$(printf '%s' "$PLAN" | head -c 2000)
1359
- PLAN_VIOLATED="[]"
1360
- [ -n "$LOCAL_RULE_ID" ] && PLAN_VIOLATED=$(jq -n --arg r "$LOCAL_RULE_ID" '[$r]')
1361
-
1362
- if [ "$LOCAL_OK" = "false" ]; then
1363
- VCOUNT=$(printf '%s' "$CC_RESP" | grep -c '<violation>' 2>/dev/null || echo "0")
1364
- [ "$VCOUNT" = "0" ] && VCOUNT="1"
1365
- REVIEW_MSG="\${VCOUNT} rule(s) relevant\${LOCAL_RULE_ID:+ (first: $LOCAL_RULE_ID)}: \${LOCAL_REASON:-check org rules during implementation}"
1366
- append_review_to_plan "\u26A0\uFE0F Advisory \u2014 $REVIEW_MSG"
1367
- MSG="$TAG planReview \u2192 $REVIEW_MSG"
1368
- jq -n --arg m "$MSG" '{systemMessage: $m}'
1369
- synkro_dispatch_capture "plan_review" "advisory" "\${LOCAL_SEV}" "\${LOCAL_CAT}" "ExitPlanMode" "$GIT_REPO" "$SESSION_ID" \\
1370
- "$PLAN_CONTENT" "$LOCAL_REASON" "\${SYNKRO_RULES:-[]}" "$PLAN_VIOLATED" "[]"
814
+ if [ -n "$CMD_HASH" ] && [ -n "$SESSION_ID" ]; then
815
+ if [ "$IS_ERROR" = "false" ]; then
816
+ synkro_consent_consume "$SESSION_ID" "$CMD_HASH"
1371
817
  else
1372
- REVIEW_MSG="Clean \u2014 \${LOCAL_REASON:-no relevant org rules for this plan}"
1373
- append_review_to_plan "\u2705 $REVIEW_MSG"
1374
- jq -n --arg m "$TAG planReview \u2192 clean: \${LOCAL_REASON:-no relevant org rules for this plan}" '{systemMessage: $m}'
1375
- synkro_dispatch_capture "plan_review" "clean" "audit" "\${LOCAL_CAT:-general}" "ExitPlanMode" "$GIT_REPO" "$SESSION_ID" \\
1376
- "$PLAN_CONTENT" "$LOCAL_REASON" "\${SYNKRO_RULES:-[]}" "[]" "[]"
818
+ if ! synkro_consent_has_active "$SESSION_ID" "$CMD_HASH"; then
819
+ synkro_consent_grant "$SESSION_ID" "$CMD_HASH"
820
+ fi
1377
821
  fi
1378
- exit 0
1379
822
  fi
1380
823
 
1381
- # \u2500\u2500\u2500 Cloud grading \u2500\u2500\u2500
1382
- BODY=$(jq -n \\
1383
- --arg hook_event "PreToolUse" \\
1384
- --arg tool_name "ExitPlanMode" \\
1385
- --arg plan "$(printf '%s' "$PLAN" | head -c 16000)" \\
1386
- --arg session_id "$SESSION_ID" \\
1387
- --arg cwd "$CWD" \\
1388
- --arg repo "$GIT_REPO" \\
1389
- '{
1390
- hook_event: $hook_event,
1391
- tool_name: $tool_name,
1392
- tool_input: {plan: $plan},
1393
- session_id: (if ($session_id | length) > 0 then $session_id else null end),
1394
- cwd: (if ($cwd | length) > 0 then $cwd else null end),
1395
- repo: (if ($repo | length) > 0 then $repo else null end)
1396
- }')
824
+ if [ -n "$SESSION_ID" ] && [ -n "$TOOL_USE_ID" ]; then
825
+ (
826
+ BODY=$(jq -n --arg sid "$SESSION_ID" --arg tid "$TOOL_USE_ID" \\
827
+ --argjson err "$IS_ERROR" --arg ch "$CMD_HASH" \\
828
+ '{capture_type:"bash_followup",session_id:$sid,tool_use_id:$tid,is_error:$err,command_hash:$ch}')
829
+ curl -sS -X POST "\${GATEWAY_URL}/api/v1/hook/capture" \\
830
+ -H "Content-Type: application/json" -H "Authorization: Bearer $JWT" \\
831
+ -d "$BODY" --max-time 3 >/dev/null 2>&1 || true
832
+ ) &
833
+ disown 2>/dev/null || true
834
+ fi
835
+
836
+ echo '{}'
837
+ exit 0
838
+ `;
839
+ }
840
+ });
1397
841
 
1398
- RESP=$(synkro_post_with_retry "\${GATEWAY_URL}/api/v1/hook/judge" "$BODY" 12)
842
+ // cli/installer/hookScriptsTs.ts
843
+ var SYNKRO_COMMON_TS, EDIT_PRECHECK_TS, CWE_PRECHECK_TS, CVE_PRECHECK_TS, BASH_JUDGE_TS, PLAN_JUDGE_TS, STOP_SUMMARY_TS, SESSION_START_TS, BASH_FOLLOWUP_TS, TRANSCRIPT_SYNC_TS, USER_PROMPT_SUBMIT_TS;
844
+ var init_hookScriptsTs = __esm({
845
+ "cli/installer/hookScriptsTs.ts"() {
846
+ "use strict";
847
+ SYNKRO_COMMON_TS = `
848
+ // Shared Synkro hook utilities \u2014 imported by all hook scripts.
849
+ import { readFileSync, writeFileSync, mkdirSync, rmdirSync, existsSync, renameSync } from 'node:fs';
850
+ import { join, dirname, basename, extname } from 'node:path';
851
+ import { homedir } from 'node:os';
852
+ import { execSync, spawn } from 'node:child_process';
1399
853
 
1400
- if [ -z "$RESP" ]; then
1401
- synkro_log "planReview \u2192 error (timeout)"
1402
- echo '{}'
1403
- exit 0
1404
- fi
854
+ // \u2500\u2500\u2500 Config \u2500\u2500\u2500
1405
855
 
1406
- if ! echo "$RESP" | jq -e '.hook_response' >/dev/null 2>&1; then
1407
- echo '{}'
1408
- exit 0
1409
- fi
856
+ const HOME = homedir();
857
+ const CONFIG_PATH = join(HOME, '.synkro', 'config.env');
1410
858
 
1411
- # Force advisory: convert any blocking decision to systemMessage
1412
- HR=$(echo "$RESP" | jq -c '.hook_response')
1413
- if echo "$HR" | jq -e '.hookSpecificOutput.permissionDecision' >/dev/null 2>&1; then
1414
- REASON=$(echo "$HR" | jq -r '.hookSpecificOutput.permissionDecisionReason // "check org rules"' 2>/dev/null)
1415
- append_review_to_plan "\u26A0\uFE0F Advisory \u2014 $REASON"
1416
- jq -n --arg m "$TAG planReview \u2192 advisory: $REASON" '{systemMessage: $m}'
1417
- else
1418
- CLOUD_MSG=$(echo "$HR" | jq -r '.systemMessage // empty' 2>/dev/null)
1419
- [ -n "$CLOUD_MSG" ] && append_review_to_plan "\u2705 $CLOUD_MSG"
1420
- echo "$HR"
1421
- fi
1422
- exit 0
859
+ // Load config.env into process.env
860
+ if (existsSync(CONFIG_PATH)) {
861
+ try {
862
+ const lines = readFileSync(CONFIG_PATH, 'utf-8').split('\\n');
863
+ for (const line of lines) {
864
+ const trimmed = line.trim();
865
+ if (!trimmed || trimmed.startsWith('#')) continue;
866
+ const eqIdx = trimmed.indexOf('=');
867
+ if (eqIdx < 1) continue;
868
+ const key = trimmed.slice(0, eqIdx).trim();
869
+ let val = trimmed.slice(eqIdx + 1).trim();
870
+ // Strip surrounding quotes
871
+ if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
872
+ val = val.slice(1, -1);
873
+ }
874
+ process.env[key] = val;
875
+ }
876
+ } catch {}
877
+ }
878
+
879
+ export const GATEWAY_URL = process.env.SYNKRO_GATEWAY_URL || 'https://api.synkro.sh';
880
+ export const CREDS_PATH = process.env.SYNKRO_CREDENTIALS_PATH || join(HOME, '.synkro', 'credentials.json');
881
+ const LAST_PROMPT_FILE = join(HOME, '.synkro', '.last-prompt');
882
+
883
+ // \u2500\u2500\u2500 Logging \u2500\u2500\u2500
884
+
885
+ export function log(msg: string): void {
886
+ process.stderr.write('[synkro] ' + msg + '\\n');
887
+ }
888
+
889
+ // \u2500\u2500\u2500 JWT Management \u2500\u2500\u2500
890
+
891
+ export function loadJwt(): string | null {
892
+ try {
893
+ if (!existsSync(CREDS_PATH)) return null;
894
+ const creds = JSON.parse(readFileSync(CREDS_PATH, 'utf-8'));
895
+ return creds.access_token || null;
896
+ } catch {
897
+ return null;
898
+ }
899
+ }
900
+
901
+ function decodeJwtExp(jwt: string): number {
902
+ try {
903
+ const parts = jwt.split('.');
904
+ if (parts.length < 2) return 0;
905
+ let payload = parts[1].replace(/-/g, '+').replace(/_/g, '/');
906
+ while (payload.length % 4) payload += '=';
907
+ const decoded = Buffer.from(payload, 'base64').toString('utf-8');
908
+ const obj = JSON.parse(decoded);
909
+ return obj.exp || 0;
910
+ } catch {
911
+ return 0;
912
+ }
913
+ }
914
+
915
+ export async function refreshJwt(jwt: string): Promise<string> {
916
+ try {
917
+ const creds = JSON.parse(readFileSync(CREDS_PATH, 'utf-8'));
918
+ const rt = creds.refresh_token;
919
+ if (!rt) return jwt;
920
+
921
+ const resp = await fetch(GATEWAY_URL + '/api/auth/refresh', {
922
+ method: 'POST',
923
+ headers: { 'Content-Type': 'application/json' },
924
+ body: JSON.stringify({ refresh_token: rt }),
925
+ signal: AbortSignal.timeout(4000),
926
+ });
927
+ const data = await resp.json() as any;
928
+ const newAt = data.access_token;
929
+ if (!newAt) return jwt;
930
+
931
+ const newRt = data.refresh_token || rt;
932
+ const existing = (() => {
933
+ try { return JSON.parse(readFileSync(CREDS_PATH, 'utf-8')); } catch { return {}; }
934
+ })();
935
+ const updated = { ...existing, access_token: newAt, refresh_token: newRt };
936
+ const tmp = CREDS_PATH + '.synkro.tmp';
937
+ writeFileSync(tmp, JSON.stringify(updated, null, 2));
938
+ renameSync(tmp, CREDS_PATH);
939
+ return newAt;
940
+ } catch {
941
+ return jwt;
942
+ }
943
+ }
944
+
945
+ export async function ensureFreshJwt(jwt: string): Promise<string> {
946
+ if (!jwt) return jwt;
947
+ const exp = decodeJwtExp(jwt);
948
+ const now = Math.floor(Date.now() / 1000);
949
+ if (exp - now >= 60) return jwt;
950
+
951
+ const lockdir = CREDS_PATH + '.lockdir';
952
+ let acquired = false;
953
+ try {
954
+ mkdirSync(lockdir);
955
+ acquired = true;
956
+ } catch {
957
+ // Another process is refreshing \u2014 wait and re-read
958
+ for (let i = 0; i < 5; i++) {
959
+ await new Promise(r => setTimeout(r, 500));
960
+ if (!existsSync(lockdir)) break;
961
+ }
962
+ // Re-read creds
963
+ const fresh = loadJwt();
964
+ return fresh || jwt;
965
+ }
966
+
967
+ try {
968
+ // Re-check \u2014 another hook may have just refreshed
969
+ const freshJwt = loadJwt();
970
+ if (freshJwt) {
971
+ const freshExp = decodeJwtExp(freshJwt);
972
+ if (freshExp - Math.floor(Date.now() / 1000) >= 60) return freshJwt;
973
+ }
974
+ return await refreshJwt(jwt);
975
+ } finally {
976
+ if (acquired) {
977
+ try { rmdirSync(lockdir); } catch {}
978
+ }
979
+ }
980
+ }
981
+
982
+ // \u2500\u2500\u2500 Repo Detection \u2500\u2500\u2500
983
+
984
+ export function detectRepo(cwd: string): string {
985
+ try {
986
+ const url = execSync('git remote get-url origin', { cwd, timeout: 3000, encoding: 'utf-8' }).trim();
987
+ if (!url) return '';
988
+ return url
989
+ .replace(/^git@[^:]+:/, '')
990
+ .replace(/^https?:\\/\\/[^/]+\\//, '')
991
+ .replace(/\\.git$/, '');
992
+ } catch {
993
+ return '';
994
+ }
995
+ }
996
+
997
+ // \u2500\u2500\u2500 Channel Health \u2500\u2500\u2500
998
+
999
+ export async function channelUp(port = 8929): Promise<boolean> {
1000
+ try {
1001
+ await fetch('http://127.0.0.1:' + port, { signal: AbortSignal.timeout(500) });
1002
+ return true;
1003
+ } catch (e: any) {
1004
+ // If we got a connection error vs a response error, check:
1005
+ // fetch throws TypeError for connection refused, but any HTTP response means the port is open
1006
+ if (e?.name === 'TimeoutError') return false;
1007
+ if (e?.cause?.code === 'ECONNREFUSED' || e?.code === 'ECONNREFUSED') return false;
1008
+ // Any other error (like bad response) means the port is actually open
1009
+ return true;
1010
+ }
1011
+ }
1012
+
1013
+ export async function cweChannelUp(): Promise<boolean> {
1014
+ return channelUp(8930);
1015
+ }
1016
+
1017
+ // \u2500\u2500\u2500 Config Loading \u2500\u2500\u2500
1018
+
1019
+ export interface Rule {
1020
+ rule_id: string;
1021
+ text: string;
1022
+ severity: string;
1023
+ category: string;
1024
+ mode: string;
1025
+ }
1026
+
1027
+ export interface HookConfig {
1028
+ captureDepth: string;
1029
+ tier: string;
1030
+ silent: boolean;
1031
+ policyName: string;
1032
+ rules: Rule[];
1033
+ }
1034
+
1035
+ export async function loadConfig(jwt: string, query?: string): Promise<HookConfig> {
1036
+ const config: HookConfig = {
1037
+ captureDepth: 'local_only',
1038
+ tier: 'standard',
1039
+ silent: false,
1040
+ policyName: '',
1041
+ rules: [],
1042
+ };
1043
+ try {
1044
+ const url = GATEWAY_URL + '/api/v1/hook/config' + (query ? '?' + query : '');
1045
+ const resp = await fetch(url, {
1046
+ headers: { Authorization: 'Bearer ' + jwt },
1047
+ signal: AbortSignal.timeout(4000),
1048
+ });
1049
+ const data = await resp.json() as any;
1050
+ config.captureDepth = data.capture_depth || 'local_only';
1051
+ config.tier = data.tier || 'standard';
1052
+ config.silent = data.silent_mode === true || data.silent_mode === 'true';
1053
+ config.policyName = data.active_policy_name || '';
1054
+ if (Array.isArray(data.rules)) {
1055
+ config.rules = data.rules
1056
+ .filter((r: any) => r.hook_stage === 'pre' || r.hook_stage === 'both' || r.hook_stage == null)
1057
+ .map((r: any) => ({
1058
+ rule_id: r.rule_id || '',
1059
+ text: r.text || '',
1060
+ severity: r.severity || '',
1061
+ category: r.category || '',
1062
+ mode: r.mode || 'blocking',
1063
+ }));
1064
+ }
1065
+ } catch {}
1066
+ return config;
1067
+ }
1068
+
1069
+ // \u2500\u2500\u2500 Routing \u2500\u2500\u2500
1070
+
1071
+ export async function route(config: HookConfig): Promise<'local' | 'cloud'> {
1072
+ if (config.captureDepth === 'local_only') return 'local';
1073
+ if (await channelUp()) return 'local';
1074
+ return 'cloud';
1075
+ }
1076
+
1077
+ export async function cweRoute(config: HookConfig): Promise<'local' | 'cloud'> {
1078
+ if (config.captureDepth === 'local_only') return 'local';
1079
+ if (await cweChannelUp()) return 'local';
1080
+ return 'cloud';
1081
+ }
1082
+
1083
+ // \u2500\u2500\u2500 Tag Building \u2500\u2500\u2500
1084
+
1085
+ export function tag(rt: string, config: HookConfig): string {
1086
+ if (config.silent) return '[synkro:silent]';
1087
+ const rs = config.policyName || 'all';
1088
+ return '[synkro:' + rt + ':' + rs + ']';
1089
+ }
1090
+
1091
+ // \u2500\u2500\u2500 Local Grading \u2500\u2500\u2500
1092
+
1093
+ function spawnGrade(surface: string, prompt: string, envOverride?: Record<string, string>, timeoutMs = 22000): Promise<string> {
1094
+ return new Promise((resolve, reject) => {
1095
+ const cliBin = process.env.SYNKRO_CLI_BIN;
1096
+ let cmd: string;
1097
+ let args: string[];
1098
+
1099
+ if (cliBin && existsSync(cliBin)) {
1100
+ // Use the CLI binary directly with bun/node
1101
+ cmd = 'node';
1102
+ args = [cliBin, 'grade', surface];
1103
+ } else {
1104
+ cmd = 'synkro';
1105
+ args = ['grade', surface];
1106
+ }
1107
+
1108
+ const child = spawn(cmd, args, {
1109
+ stdio: ['pipe', 'pipe', 'pipe'],
1110
+ env: { ...process.env, ...envOverride },
1111
+ });
1112
+
1113
+ let stdout = '';
1114
+ let stderr = '';
1115
+ child.stdout.on('data', (d: Buffer) => { stdout += d.toString(); });
1116
+ child.stderr.on('data', (d: Buffer) => { stderr += d.toString(); });
1117
+ child.stdin.write(prompt);
1118
+ child.stdin.end();
1119
+
1120
+ const timer = setTimeout(() => {
1121
+ child.kill();
1122
+ reject(new Error('SYNKRO_GRADE_TIMEOUT'));
1123
+ }, timeoutMs);
1124
+
1125
+ child.on('close', (code: number | null) => {
1126
+ clearTimeout(timer);
1127
+ if (code === 0) resolve(stdout);
1128
+ else reject(new Error(stderr || 'exit ' + code));
1129
+ });
1130
+ child.on('error', (err: Error) => { clearTimeout(timer); reject(err); });
1131
+ });
1132
+ }
1133
+
1134
+ export async function localGrade(surface: string, prompt: string): Promise<string> {
1135
+ if (!(await channelUp())) throw new Error('SYNKRO_CHANNEL_DOWN');
1136
+ return spawnGrade(surface, prompt);
1137
+ }
1138
+
1139
+ export async function localGradeCwe(prompt: string): Promise<string> {
1140
+ if (!(await cweChannelUp())) throw new Error('SYNKRO_CHANNEL_DOWN');
1141
+ return spawnGrade('cwe', prompt, { SYNKRO_CHANNEL_PORT: '8930' }, 12000);
1142
+ }
1143
+
1144
+ // \u2500\u2500\u2500 Verdict Parsing \u2500\u2500\u2500
1145
+
1146
+ export interface Verdict {
1147
+ ok: boolean;
1148
+ reason: string;
1149
+ ruleId: string;
1150
+ ruleMode: string;
1151
+ severity: string;
1152
+ category: string;
1153
+ }
1154
+
1155
+ export function parseVerdict(resp: string): Verdict {
1156
+ const verdict: Verdict = {
1157
+ ok: true,
1158
+ reason: '',
1159
+ ruleId: '',
1160
+ ruleMode: '',
1161
+ severity: 'low',
1162
+ category: 'clean',
1163
+ };
1164
+
1165
+ // Flatten newlines for easier regex
1166
+ const flat = resp.replace(/\\n/g, ' ');
1167
+ const outerMatch = flat.match(/<synkro-verdict>(.*)<\\/synkro-verdict>/);
1168
+ if (!outerMatch) return verdict;
1169
+ const inner = outerMatch[1];
1170
+
1171
+ const okMatch = inner.match(/<ok>(.*?)<\\/ok>/);
1172
+ if (okMatch) verdict.ok = okMatch[1].trim() !== 'false';
1173
+
1174
+ const reasonMatch = inner.match(/<reason>(.*?)<\\/reason>/) || inner.match(/<reasoning>(.*?)<\\/reasoning>/);
1175
+ if (reasonMatch) verdict.reason = reasonMatch[1].trim();
1176
+
1177
+ if (!verdict.ok) {
1178
+ const ruleIdMatch = inner.match(/<rule_id>(.*?)<\\/rule_id>/);
1179
+ const ruleModeMatch = inner.match(/<rule_mode>(.*?)<\\/rule_mode>/);
1180
+ const sevMatch = inner.match(/<risk_level>(.*?)<\\/risk_level>/);
1181
+
1182
+ if (ruleIdMatch) {
1183
+ verdict.ruleId = ruleIdMatch[1].trim();
1184
+ } else {
1185
+ // Try to find inside a <violation> block
1186
+ const violationMatch = inner.match(/<violation>(.*?)<\\/violation>/);
1187
+ if (violationMatch) {
1188
+ const vBlock = violationMatch[1];
1189
+ const vRuleId = vBlock.match(/<rule_id>(.*?)<\\/rule_id>/);
1190
+ if (vRuleId) verdict.ruleId = vRuleId[1].trim();
1191
+ if (!verdict.reason) {
1192
+ const vReason = vBlock.match(/<reason>(.*?)<\\/reason>/);
1193
+ if (vReason) verdict.reason = vReason[1].trim();
1194
+ }
1195
+ if (!sevMatch) {
1196
+ const vSev = vBlock.match(/<severity>(.*?)<\\/severity>/);
1197
+ if (vSev) verdict.severity = vSev[1].trim();
1198
+ }
1199
+ }
1200
+ }
1201
+
1202
+ if (ruleModeMatch) verdict.ruleMode = ruleModeMatch[1].trim();
1203
+ if (sevMatch) verdict.severity = sevMatch[1].trim();
1204
+ verdict.severity = verdict.severity || 'high';
1205
+
1206
+ const catMatch = inner.match(/<category>(.*?)<\\/category>/);
1207
+ verdict.category = catMatch ? catMatch[1].trim() : 'uncategorized';
1208
+
1209
+ // Fallback: extract rule ID from reason text
1210
+ if (!verdict.ruleId && verdict.reason) {
1211
+ const rMatch = verdict.reason.match(/[Rr]\\d{3}/);
1212
+ if (rMatch) verdict.ruleId = rMatch[0];
1213
+ }
1214
+ }
1215
+
1216
+ return verdict;
1217
+ }
1218
+
1219
+ // \u2500\u2500\u2500 Telemetry Dispatch \u2500\u2500\u2500
1220
+
1221
+ export function dispatchCapture(
1222
+ jwt: string,
1223
+ hookType: string,
1224
+ verdictStr: string,
1225
+ severity: string,
1226
+ category: string,
1227
+ toolName: string,
1228
+ repo: string,
1229
+ sessionId: string,
1230
+ captureDepth: string,
1231
+ opts?: {
1232
+ command?: string;
1233
+ reasoning?: string;
1234
+ rulesChecked?: Rule[] | string;
1235
+ violatedRules?: string[];
1236
+ recentUserMessages?: string[];
1237
+ ccModel?: string;
1238
+ },
1239
+ ): void {
1240
+ // Fire-and-forget
1241
+ const eventId = 'evt_' + Date.now() + '_' + process.pid;
1242
+ const model = opts?.ccModel || 'unknown';
1243
+ const sendFull =
1244
+ captureDepth === 'full' ||
1245
+ (captureDepth === 'evidence_on_violation' && ['block', 'warning', 'deny'].includes(verdictStr));
1246
+
1247
+ const body: Record<string, any> = {
1248
+ capture_type: 'local_verdict',
1249
+ event_id: eventId,
1250
+ hook_type: hookType,
1251
+ verdict: verdictStr,
1252
+ severity,
1253
+ category,
1254
+ cc_model: model,
1255
+ model,
1256
+ tool_name: toolName,
1257
+ };
1258
+ if (repo) body.repo = repo;
1259
+ if (sessionId) body.session_id = sessionId;
1260
+
1261
+ if (sendFull && opts) {
1262
+ body.capture_depth = captureDepth;
1263
+ if (opts.command) body.command = opts.command;
1264
+ if (opts.reasoning) body.reasoning = opts.reasoning;
1265
+ if (opts.rulesChecked) body.rules_checked = opts.rulesChecked;
1266
+ if (opts.violatedRules) body.violated_rules = opts.violatedRules;
1267
+ if (opts.recentUserMessages) body.recent_user_messages = opts.recentUserMessages;
1268
+ }
1269
+
1270
+ fetch(GATEWAY_URL + '/api/v1/hook/capture', {
1271
+ method: 'POST',
1272
+ headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + jwt },
1273
+ body: JSON.stringify(body),
1274
+ signal: AbortSignal.timeout(3000),
1275
+ }).catch(() => {});
1276
+ }
1277
+
1278
+ // \u2500\u2500\u2500 Rule Mode Lookup \u2500\u2500\u2500
1279
+
1280
+ export function ruleMode(ruleId: string, rules: Rule[]): 'blocking' | 'audit' {
1281
+ if (!ruleId || !rules.length) return 'blocking';
1282
+ const matched = rules.filter(r => r.rule_id === ruleId);
1283
+ if (matched.some(r => r.mode === 'blocking')) return 'blocking';
1284
+ return (matched[0]?.mode as 'blocking' | 'audit') || 'blocking';
1285
+ }
1286
+
1287
+ // \u2500\u2500\u2500 Content Reconstruction \u2500\u2500\u2500
1288
+
1289
+ export function reconstructContent(toolName: string, toolInput: any, filePath: string): string {
1290
+ switch (toolName) {
1291
+ case 'Write':
1292
+ return toolInput.content || '';
1293
+ case 'Edit': {
1294
+ let content = '';
1295
+ try {
1296
+ if (filePath && existsSync(filePath)) {
1297
+ content = readFileSync(filePath, 'utf-8').slice(0, 65536);
1298
+ }
1299
+ } catch {}
1300
+ const oldStr = toolInput.old_string || '';
1301
+ const newStr = toolInput.new_string || '';
1302
+ if (oldStr && content.includes(oldStr)) {
1303
+ return content.replace(oldStr, newStr);
1304
+ }
1305
+ return content || newStr;
1306
+ }
1307
+ case 'MultiEdit': {
1308
+ let content = '';
1309
+ try {
1310
+ if (filePath && existsSync(filePath)) {
1311
+ content = readFileSync(filePath, 'utf-8').slice(0, 65536);
1312
+ }
1313
+ } catch {}
1314
+ const edits = Array.isArray(toolInput.edits) ? toolInput.edits : [];
1315
+ for (const edit of edits) {
1316
+ if (!edit || typeof edit !== 'object') continue;
1317
+ const old = edit.old_string || '';
1318
+ const nw = edit.new_string || '';
1319
+ if (old && content.includes(old)) {
1320
+ content = content.replace(old, nw);
1321
+ }
1322
+ }
1323
+ return content;
1324
+ }
1325
+ case 'NotebookEdit':
1326
+ return toolInput.new_source || '';
1327
+ default:
1328
+ return '';
1329
+ }
1330
+ }
1331
+
1332
+ // \u2500\u2500\u2500 HTTP with Retry \u2500\u2500\u2500
1333
+
1334
+ export async function postWithRetry(url: string, body: any, jwt: string, timeout = 8000): Promise<any> {
1335
+ let currentJwt = jwt;
1336
+ let resp: Response;
1337
+ try {
1338
+ resp = await fetch(url, {
1339
+ method: 'POST',
1340
+ headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + currentJwt },
1341
+ body: JSON.stringify(body),
1342
+ signal: AbortSignal.timeout(timeout),
1343
+ });
1344
+ } catch {
1345
+ return null;
1346
+ }
1347
+
1348
+ let data: any;
1349
+ try { data = await resp.json(); } catch { return null; }
1350
+
1351
+ // Retry on token expiry
1352
+ if (data?.detail && (data.detail.includes('Token has expired') || data.detail.includes('Invalid or expired token'))) {
1353
+ try {
1354
+ currentJwt = await refreshJwt(currentJwt);
1355
+ const resp2 = await fetch(url, {
1356
+ method: 'POST',
1357
+ headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + currentJwt },
1358
+ body: JSON.stringify(body),
1359
+ signal: AbortSignal.timeout(timeout),
1360
+ });
1361
+ data = await resp2.json();
1362
+ } catch {
1363
+ return null;
1364
+ }
1365
+ }
1366
+
1367
+ return data;
1368
+ }
1369
+
1370
+ // \u2500\u2500\u2500 Read Stdin \u2500\u2500\u2500
1371
+
1372
+ export async function readStdin(): Promise<string> {
1373
+ const chunks: Buffer[] = [];
1374
+ for await (const chunk of process.stdin) {
1375
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
1376
+ }
1377
+ return Buffer.concat(chunks).toString('utf-8');
1378
+ }
1379
+
1380
+ // \u2500\u2500\u2500 Transcript Extraction \u2500\u2500\u2500
1381
+
1382
+ export interface TranscriptContext {
1383
+ userIntent: string;
1384
+ recentUserMessages: string[];
1385
+ recentMessages: Array<{ type: string; text: string }>;
1386
+ recentActions: Array<{ tool: string; input: string }>;
1387
+ sessionSummary: string;
1388
+ ccModel: string;
1389
+ ccUsage: Record<string, any>;
1390
+ }
1391
+
1392
+ export function extractTranscript(transcriptPath: string | undefined): TranscriptContext {
1393
+ const ctx: TranscriptContext = {
1394
+ userIntent: '',
1395
+ recentUserMessages: [],
1396
+ recentMessages: [],
1397
+ recentActions: [],
1398
+ sessionSummary: '',
1399
+ ccModel: '',
1400
+ ccUsage: {},
1401
+ };
1402
+
1403
+ if (!transcriptPath || !existsSync(transcriptPath)) return ctx;
1404
+
1405
+ try {
1406
+ const raw = readFileSync(transcriptPath, 'utf-8');
1407
+ const lines = raw.split('\\n').filter(l => l.trim());
1408
+ // Take the last 400 lines
1409
+ const tail = lines.slice(-400);
1410
+
1411
+ const parsed: any[] = [];
1412
+ for (const line of tail) {
1413
+ try { parsed.push(JSON.parse(line)); } catch {}
1414
+ }
1415
+
1416
+ // Recent user messages (last 5)
1417
+ const userMsgs: string[] = [];
1418
+ for (const entry of parsed) {
1419
+ if (entry.type !== 'user') continue;
1420
+ const content = entry.message?.content;
1421
+ let text = '';
1422
+ if (typeof content === 'string') text = content;
1423
+ else if (Array.isArray(content)) text = content.map((c: any) => c.text || '').join(' ');
1424
+ if (text) userMsgs.push(text);
1425
+ }
1426
+ ctx.recentUserMessages = userMsgs.slice(-5);
1427
+ ctx.userIntent = ctx.recentUserMessages[ctx.recentUserMessages.length - 1] || '';
1428
+
1429
+ // Recent messages (last 10, user + assistant)
1430
+ const msgs: Array<{ type: string; text: string }> = [];
1431
+ for (const entry of parsed) {
1432
+ if (entry.type !== 'user' && entry.type !== 'assistant') continue;
1433
+ const content = entry.message?.content;
1434
+ let text = '';
1435
+ if (typeof content === 'string') text = content.slice(0, 500);
1436
+ else if (Array.isArray(content)) text = content.map((c: any) => (c.text || '').slice(0, 300)).join(' ');
1437
+ msgs.push({ type: entry.type, text });
1438
+ }
1439
+ ctx.recentMessages = msgs.slice(-10);
1440
+
1441
+ // Recent tool calls (last 5)
1442
+ const actions: Array<{ tool: string; input: string }> = [];
1443
+ for (const entry of parsed) {
1444
+ if (entry.type !== 'assistant') continue;
1445
+ const content = entry.message?.content;
1446
+ if (!Array.isArray(content)) continue;
1447
+ for (const block of content) {
1448
+ if (block.type !== 'tool_use') continue;
1449
+ actions.push({
1450
+ tool: block.name || '',
1451
+ input: JSON.stringify(block.input || {}).slice(0, 200),
1452
+ });
1453
+ }
1454
+ }
1455
+ ctx.recentActions = actions.slice(-5);
1456
+
1457
+ // Session summary
1458
+ for (const entry of parsed) {
1459
+ if (entry.type === 'summary' && entry.summary) {
1460
+ ctx.sessionSummary = entry.summary;
1461
+ }
1462
+ }
1463
+
1464
+ // CC model
1465
+ const assistantEntries = parsed.filter(e => e.type === 'assistant');
1466
+ if (assistantEntries.length > 0) {
1467
+ const last = assistantEntries[assistantEntries.length - 1];
1468
+ ctx.ccModel = last.message?.model || '';
1469
+ const usage = last.message?.usage;
1470
+ if (usage) {
1471
+ ctx.ccUsage = {
1472
+ input_tokens: usage.input_tokens,
1473
+ output_tokens: usage.output_tokens,
1474
+ cache_creation_input_tokens: usage.cache_creation_input_tokens,
1475
+ cache_read_input_tokens: usage.cache_read_input_tokens,
1476
+ };
1477
+ }
1478
+ }
1479
+ } catch {}
1480
+
1481
+ return ctx;
1482
+ }
1483
+
1484
+ // \u2500\u2500\u2500 Last Prompt \u2500\u2500\u2500
1485
+
1486
+ export function readLastPrompt(): string {
1487
+ try {
1488
+ if (!existsSync(LAST_PROMPT_FILE)) return '';
1489
+ return readFileSync(LAST_PROMPT_FILE, 'utf-8').trim();
1490
+ } catch {
1491
+ return '';
1492
+ }
1493
+ }
1494
+
1495
+ // \u2500\u2500\u2500 Find Nearest Package Dependencies \u2500\u2500\u2500
1496
+
1497
+ export function findNearestDeps(filePath: string): Record<string, string> {
1498
+ let dir = dirname(filePath);
1499
+ while (dir !== '/' && dir !== '.') {
1500
+ const pkg = join(dir, 'package.json');
1501
+ if (existsSync(pkg)) {
1502
+ try {
1503
+ const data = JSON.parse(readFileSync(pkg, 'utf-8'));
1504
+ return { ...(data.dependencies || {}), ...(data.devDependencies || {}) };
1505
+ } catch {}
1506
+ }
1507
+ const parent = dirname(dir);
1508
+ if (parent === dir) break;
1509
+ dir = parent;
1510
+ }
1511
+ return {};
1512
+ }
1513
+
1514
+ // \u2500\u2500\u2500 Consent Tracking \u2500\u2500\u2500
1515
+
1516
+ const CONSENT_FILE = join(HOME, '.synkro', '.local-consent');
1517
+
1518
+ export function consentGrant(sessionId: string, hash: string): void {
1519
+ try {
1520
+ const dir = dirname(CONSENT_FILE);
1521
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
1522
+ const line = sessionId + '\\t' + hash + '\\tactive\\n';
1523
+ const { appendFileSync } = require('node:fs');
1524
+ appendFileSync(CONSENT_FILE, line, 'utf-8');
1525
+ } catch {}
1526
+ }
1527
+
1528
+ export function consentHasActive(sessionId: string, hash: string): boolean {
1529
+ try {
1530
+ if (!existsSync(CONSENT_FILE)) return false;
1531
+ const content = readFileSync(CONSENT_FILE, 'utf-8');
1532
+ return content.includes(sessionId + '\\t' + hash + '\\tactive');
1533
+ } catch {
1534
+ return false;
1535
+ }
1536
+ }
1537
+
1538
+ export function consentConsume(sessionId: string, hash: string): void {
1539
+ try {
1540
+ if (!existsSync(CONSENT_FILE)) return;
1541
+ const content = readFileSync(CONSENT_FILE, 'utf-8');
1542
+ const target = sessionId + '\\t' + hash + '\\tactive';
1543
+ const replacement = sessionId + '\\t' + hash + '\\tconsumed';
1544
+ const updated = content.split('\\n').map(l => l === target ? replacement : l).join('\\n');
1545
+ writeFileSync(CONSENT_FILE, updated, 'utf-8');
1546
+ } catch {}
1547
+ }
1548
+
1549
+ // \u2500\u2500\u2500 Crypto Hash \u2500\u2500\u2500
1550
+
1551
+ export function hashCommand(cmd: string): string {
1552
+ const { createHash } = require('node:crypto');
1553
+ return createHash('sha256').update(cmd).digest('hex').slice(0, 16);
1554
+ }
1555
+
1556
+ // \u2500\u2500\u2500 Transcript Usage Aggregation \u2500\u2500\u2500
1557
+
1558
+ export function aggregateUsage(transcriptPath: string): { model: string; totals: Record<string, number> } {
1559
+ const result = { model: '', totals: { in: 0, out: 0, cw: 0, cr: 0 } };
1560
+ if (!transcriptPath || !existsSync(transcriptPath)) return result;
1561
+ try {
1562
+ const raw = readFileSync(transcriptPath, 'utf-8');
1563
+ const lines = raw.split('\\n').filter(l => l.trim());
1564
+ for (const line of lines) {
1565
+ try {
1566
+ const entry = JSON.parse(line);
1567
+ if (entry.type !== 'assistant') continue;
1568
+ result.model = entry.message?.model || result.model;
1569
+ const u = entry.message?.usage;
1570
+ if (u) {
1571
+ result.totals.in += u.input_tokens || 0;
1572
+ result.totals.out += u.output_tokens || 0;
1573
+ result.totals.cw += u.cache_creation_input_tokens || 0;
1574
+ result.totals.cr += u.cache_read_input_tokens || 0;
1575
+ }
1576
+ } catch {}
1577
+ }
1578
+ } catch {}
1579
+ return result;
1580
+ }
1581
+
1582
+ // \u2500\u2500\u2500 Output Helpers \u2500\u2500\u2500
1583
+
1584
+ export function outputJson(obj: any): void {
1585
+ console.log(JSON.stringify(obj));
1586
+ }
1587
+
1588
+ export function outputEmpty(): void {
1589
+ console.log('{}');
1590
+ }
1423
1591
  `;
1424
- CC_STOP_SUMMARY_SCRIPT = `#!/bin/bash
1425
- SCRIPT_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
1426
- . "$SCRIPT_DIR/_synkro-common.sh"
1592
+ EDIT_PRECHECK_TS = `#!/usr/bin/env bun
1593
+ import {
1594
+ loadJwt, ensureFreshJwt, detectRepo, loadConfig, route, tag, localGrade,
1595
+ parseVerdict, dispatchCapture, ruleMode, reconstructContent, postWithRetry,
1596
+ readStdin, extractTranscript, readLastPrompt, findNearestDeps, log,
1597
+ outputJson, outputEmpty, GATEWAY_URL,
1598
+ type HookConfig, type Rule,
1599
+ } from './_synkro-common.ts';
1600
+ import { existsSync, readFileSync } from 'node:fs';
1601
+ import { basename, dirname, join } from 'node:path';
1602
+
1603
+ async function main() {
1604
+ try {
1605
+ const input = await readStdin();
1606
+ if (!input.trim()) { outputEmpty(); return; }
1607
+
1608
+ const payload = JSON.parse(input);
1609
+ const toolName = payload.tool_name || '';
1610
+ if (!['Edit', 'Write', 'MultiEdit', 'NotebookEdit'].includes(toolName)) {
1611
+ outputEmpty();
1612
+ return;
1613
+ }
1614
+
1615
+ const toolInput = payload.tool_input || {};
1616
+ const sessionId = payload.session_id || '';
1617
+ const toolUseId = payload.tool_use_id || '';
1618
+ const cwd = payload.cwd || '';
1619
+ const permissionMode = payload.permission_mode || '';
1620
+ const transcriptPath = payload.transcript_path || '';
1621
+
1622
+ const filePath = toolInput.file_path || toolInput.notebook_path || toolInput.path || '';
1623
+ if (!filePath) { outputEmpty(); return; }
1624
+
1625
+ const fileShort = basename(filePath);
1626
+ log('editGuard checking: ' + fileShort);
1627
+
1628
+ const gitRepo = detectRepo(cwd || '.');
1629
+
1630
+ let jwt = loadJwt();
1631
+ if (!jwt) { outputEmpty(); return; }
1632
+ jwt = await ensureFreshJwt(jwt);
1633
+
1634
+ // Reconstruct proposed content
1635
+ const proposed = reconstructContent(toolName, toolInput, filePath);
1636
+ if (!proposed) { outputEmpty(); return; }
1637
+
1638
+ // Build diff field
1639
+ let diffField: any = null;
1640
+ if (toolInput.old_string != null || toolInput.new_string != null || toolInput.edits != null) {
1641
+ diffField = {};
1642
+ if (toolInput.old_string != null) diffField.old_string = toolInput.old_string;
1643
+ if (toolInput.new_string != null) diffField.new_string = toolInput.new_string;
1644
+ if (toolInput.edits != null) diffField.edits = toolInput.edits;
1645
+ }
1646
+
1647
+ // Read file before edit for cloud payload
1648
+ let fileBefore = '';
1649
+ if (toolName !== 'Write' && filePath && existsSync(filePath)) {
1650
+ try { fileBefore = readFileSync(filePath, 'utf-8').slice(0, 65536); } catch {}
1651
+ }
1652
+
1653
+ // Extract transcript context
1654
+ const transcript = extractTranscript(transcriptPath);
1655
+ const lastPrompt = readLastPrompt();
1656
+
1657
+ // Load config and decide route
1658
+ const config = await loadConfig(jwt);
1659
+ const rt = await route(config);
1660
+ const tagStr = tag(rt, config);
1661
+
1662
+ if (config.silent) {
1663
+ outputJson({ systemMessage: tagStr + ' editGuard \\u2192 skipped (silent mode)' });
1664
+ return;
1665
+ }
1666
+
1667
+ if (rt === 'local') {
1668
+ // \u2500\u2500\u2500 Local grading: org rules ONLY (channel 1, port 8929) \u2500\u2500\u2500
1669
+ const proposedShort = proposed.slice(0, 4000);
1670
+ const graderPrompt = [
1671
+ 'Working directory: ' + (cwd || '.'),
1672
+ 'Repo: ' + (gitRepo || 'unknown'),
1673
+ 'File: ' + filePath,
1674
+ 'Proposed content (first 4000 chars):',
1675
+ proposedShort,
1676
+ 'User intent (last human message): ' + (transcript.userIntent || 'none stated'),
1677
+ 'Last user prompt: ' + (lastPrompt || 'none'),
1678
+ 'Org rules: ' + JSON.stringify(config.rules),
1679
+ ].join('\\n');
1680
+
1681
+ let gradeResp: string;
1682
+ try {
1683
+ gradeResp = await localGrade('edit', graderPrompt);
1684
+ } catch {
1685
+ outputEmpty();
1686
+ return;
1687
+ }
1688
+
1689
+ const verdict = parseVerdict(gradeResp);
1690
+ const editContent = 'file=' + filePath + ' content=' + proposed.slice(0, 2000);
1691
+ const violatedRules = verdict.ruleId ? [verdict.ruleId] : [];
1692
+
1693
+ if (!verdict.ok) {
1694
+ const mode = verdict.ruleMode || ruleMode(verdict.ruleId, config.rules);
1695
+ const guardReason = (verdict.ruleId ? '(' + verdict.ruleId + ') ' : '') + (verdict.reason || 'policy violation');
1696
+
1697
+ if (mode !== 'audit') {
1698
+ const denyReason = 'Guard: ' + guardReason + '\\nFix all issues before retrying.';
1699
+ dispatchCapture(jwt, 'edit', 'block', verdict.severity || 'critical', verdict.category || 'security',
1700
+ toolName, gitRepo, sessionId, config.captureDepth, {
1701
+ command: editContent, reasoning: guardReason,
1702
+ rulesChecked: config.rules, violatedRules,
1703
+ ccModel: transcript.ccModel,
1704
+ });
1705
+ outputJson({
1706
+ systemMessage: tagStr + ' editGuard ' + fileShort + ' \\u2192 blocked: ' + guardReason,
1707
+ hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: denyReason },
1708
+ });
1709
+ return;
1710
+ }
1711
+
1712
+ // Audit mode \u2014 warn but allow
1713
+ dispatchCapture(jwt, 'edit', 'warning', verdict.severity || 'medium', verdict.category || 'security',
1714
+ toolName, gitRepo, sessionId, config.captureDepth, {
1715
+ command: editContent, reasoning: guardReason,
1716
+ rulesChecked: config.rules, violatedRules,
1717
+ ccModel: transcript.ccModel,
1718
+ });
1719
+ outputJson({ systemMessage: tagStr + ' editGuard ' + fileShort + ' \\u2192 warning: ' + guardReason });
1720
+ return;
1721
+ }
1722
+
1723
+ // Clean
1724
+ dispatchCapture(jwt, 'edit', 'pass', 'audit', verdict.category || 'trivial_edit',
1725
+ toolName, gitRepo, sessionId, config.captureDepth, {
1726
+ command: editContent, reasoning: verdict.reason || 'no policy violations detected',
1727
+ rulesChecked: config.rules, violatedRules: [],
1728
+ ccModel: transcript.ccModel,
1729
+ });
1730
+ outputJson({ systemMessage: tagStr + ' editGuard ' + fileShort + ' \\u2192 pass: ' + (verdict.reason || 'no policy violations detected') });
1731
+ return;
1732
+ }
1733
+
1734
+ // \u2500\u2500\u2500 Cloud grading \u2500\u2500\u2500
1735
+ const deps = findNearestDeps(filePath);
1736
+ const isHeadless = ['acceptEdits', 'bypassPermissions', 'plan', 'auto'].includes(permissionMode)
1737
+ || process.env.SYNKRO_HEADLESS === '1';
1738
+
1739
+ const body = {
1740
+ hook_event: 'PreToolUse',
1741
+ tool_name: toolName,
1742
+ tool_input: toolInput,
1743
+ file_path: filePath,
1744
+ content: proposed,
1745
+ file_before: fileBefore || null,
1746
+ diff: diffField,
1747
+ dependencies: deps,
1748
+ user_intent: transcript.userIntent || null,
1749
+ last_user_message: lastPrompt || null,
1750
+ recent_user_messages: transcript.recentUserMessages,
1751
+ recent_messages: transcript.recentMessages,
1752
+ recent_actions: transcript.recentActions,
1753
+ session_id: sessionId || null,
1754
+ tool_use_id: toolUseId || null,
1755
+ cwd: cwd || null,
1756
+ repo: gitRepo || null,
1757
+ permission_mode: permissionMode || null,
1758
+ headless: isHeadless,
1759
+ };
1760
+
1761
+ const resp = await postWithRetry(GATEWAY_URL + '/api/v1/hook/judge', body, jwt, 8000);
1762
+
1763
+ if (!resp) {
1764
+ log('editGuard ' + fileShort + ' \\u2192 error (timeout)');
1765
+ outputEmpty();
1766
+ return;
1767
+ }
1768
+
1769
+ if (!resp.hook_response || typeof resp.hook_response !== 'object') {
1770
+ log('editGuard ' + fileShort + ' \\u2192 pass (no hook_response)');
1771
+ outputEmpty();
1772
+ return;
1773
+ }
1774
+
1775
+ const hookResp = resp.hook_response;
1776
+ const decision = hookResp?.hookSpecificOutput?.permissionDecision;
1777
+
1778
+ if (decision === 'deny' || decision === 'ask') {
1779
+ log('editGuard ' + fileShort + ' \\u2192 BLOCKED');
1780
+ // Strip permissionDecision \u2014 we use systemMessage only
1781
+ const cleaned = { ...hookResp };
1782
+ if (cleaned.hookSpecificOutput) {
1783
+ cleaned.hookSpecificOutput = { ...cleaned.hookSpecificOutput };
1784
+ delete cleaned.hookSpecificOutput.permissionDecision;
1785
+ delete cleaned.hookSpecificOutput.permissionDecisionReason;
1786
+ }
1787
+ outputJson(cleaned);
1788
+ } else {
1789
+ const reason = hookResp.reason || '';
1790
+ log('editGuard ' + fileShort + ' \\u2192 pass' + (reason ? ': ' + reason : ''));
1791
+ outputJson(hookResp);
1792
+ }
1793
+ } catch (err) {
1794
+ process.stderr.write('[synkro] editGuard error: ' + String(err) + '\\n');
1795
+ outputEmpty();
1796
+ }
1797
+ }
1798
+
1799
+ main();
1800
+ `;
1801
+ CWE_PRECHECK_TS = `#!/usr/bin/env bun
1802
+ import {
1803
+ loadJwt, ensureFreshJwt, detectRepo, loadConfig, cweRoute, tag,
1804
+ localGradeCwe, parseVerdict, reconstructContent, readStdin, log,
1805
+ outputJson, outputEmpty, GATEWAY_URL,
1806
+ } from './_synkro-common.ts';
1807
+ import { basename, extname } from 'node:path';
1808
+
1809
+ async function main() {
1810
+ try {
1811
+ const input = await readStdin();
1812
+ if (!input.trim()) { outputEmpty(); return; }
1427
1813
 
1428
- JWT=$(synkro_load_jwt)
1429
- if [ -z "$JWT" ]; then echo '{}'; exit 0; fi
1814
+ const payload = JSON.parse(input);
1815
+ const toolName = payload.tool_name || '';
1816
+ if (!['Edit', 'Write', 'MultiEdit', 'NotebookEdit'].includes(toolName)) {
1817
+ outputEmpty();
1818
+ return;
1819
+ }
1430
1820
 
1431
- PAYLOAD=$(cat)
1432
- SESSION_ID=$(echo "$PAYLOAD" | jq -r '.session_id // empty' 2>/dev/null)
1433
- if [ -z "$SESSION_ID" ]; then echo '{}'; exit 0; fi
1821
+ const toolInput = payload.tool_input || {};
1822
+ const sessionId = payload.session_id || '';
1823
+ const cwd = payload.cwd || '';
1824
+ const gitRepo = detectRepo(cwd || '.');
1434
1825
 
1435
- CWD=$(echo "$PAYLOAD" | jq -r '.cwd // empty' 2>/dev/null)
1436
- TRANSCRIPT_PATH=$(echo "$PAYLOAD" | jq -r '.transcript_path // empty' 2>/dev/null)
1437
- GIT_REPO=$(synkro_detect_repo "\${CWD:-.}")
1826
+ const filePath = toolInput.file_path || toolInput.notebook_path || toolInput.path || '';
1827
+ if (!filePath) { outputEmpty(); return; }
1438
1828
 
1439
- # Aggregate token usage across all assistant turns in the transcript and POST silently
1440
- if [ -n "$TRANSCRIPT_PATH" ] && [ -f "$TRANSCRIPT_PATH" ]; then
1441
- (
1442
- _LAST=$(grep '"type":"assistant"' "$TRANSCRIPT_PATH" 2>/dev/null | tail -1)
1443
- CC_MODEL=$(echo "$_LAST" | jq -r '.message.model // empty' 2>/dev/null)
1444
- TOTALS=$(grep '"type":"assistant"' "$TRANSCRIPT_PATH" 2>/dev/null \\
1445
- | jq -c '.message.usage' 2>/dev/null \\
1446
- | jq -s '{in:(map(.input_tokens//0)|add//0),out:(map(.output_tokens//0)|add//0),cw:(map(.cache_creation_input_tokens//0)|add//0),cr:(map(.cache_read_input_tokens//0)|add//0)}' 2>/dev/null \\
1447
- || echo '{"in":0,"out":0,"cw":0,"cr":0}')
1448
- TOK_IN=$(echo "$TOTALS" | jq -r '.in // 0')
1449
- TOK_OUT=$(echo "$TOTALS" | jq -r '.out // 0')
1450
- TOK_CW=$(echo "$TOTALS" | jq -r '.cw // 0')
1451
- TOK_CR=$(echo "$TOTALS" | jq -r '.cr // 0')
1452
- HAS_TOKENS=$(( TOK_IN + TOK_OUT ))
1453
- if [ "$HAS_TOKENS" != "0" ]; then
1454
- CC_USAGE="{"input_tokens":$TOK_IN,"output_tokens":$TOK_OUT,"cache_creation_input_tokens":$TOK_CW,"cache_read_input_tokens":$TOK_CR}"
1455
- BODY=$(jq -n \\
1456
- --arg event_id "usage_$(date +%s)_$$" \\
1457
- --arg hook_type "stop" --arg verdict "allow" --arg severity "none" \\
1458
- --arg model "\${CC_MODEL:-unknown}" \\
1459
- --arg cc_model "\${CC_MODEL:-}" \\
1460
- --arg repo "\${GIT_REPO:-}" --arg session_id "$SESSION_ID" \\
1461
- --argjson cc_usage "$CC_USAGE" \\
1462
- '{capture_type:"local_verdict",event_id:$event_id,hook_type:$hook_type,verdict:$verdict,severity:$severity,model:$model,cc_usage:$cc_usage}
1463
- + (if $repo != "" then {repo:$repo} else {} end)
1464
- + (if $session_id != "" then {session_id:$session_id} else {} end)
1465
- + (if $cc_model != "" then {cc_model:$cc_model} else {} end)')
1466
- curl -sS -X POST "\${GATEWAY_URL}/api/v1/hook/capture" \\
1467
- -H "Content-Type: application/json" -H "Authorization: Bearer $JWT" \\
1468
- -d "$BODY" --max-time 2 >/dev/null 2>&1
1469
- fi
1470
- ) &
1471
- fi
1829
+ const fileShort = basename(filePath);
1830
+ const fileExt = extname(filePath); // e.g. ".ts"
1472
1831
 
1473
- RESP=$(curl -sS -G "\${GATEWAY_URL}/api/v1/cli/session-summary" \\
1474
- --data-urlencode "session_id=$SESSION_ID" \\
1475
- -H "Authorization: Bearer $JWT" --max-time 2 2>/dev/null || echo "")
1832
+ let jwt = loadJwt();
1833
+ if (!jwt) { outputEmpty(); return; }
1834
+ jwt = await ensureFreshJwt(jwt);
1476
1835
 
1477
- if [ -z "$RESP" ]; then echo '{}'; exit 0; fi
1836
+ // Reconstruct proposed content
1837
+ const proposed = reconstructContent(toolName, toolInput, filePath);
1838
+ if (!proposed) { outputEmpty(); return; }
1478
1839
 
1479
- EDITS=$(echo "$RESP" | jq -r '.edits_scanned // 0' 2>/dev/null)
1480
- FINDINGS=$(echo "$RESP" | jq -r '.findings // 0' 2>/dev/null)
1481
- AUTO_FIXED=$(echo "$RESP" | jq -r '.auto_fixed // 0' 2>/dev/null)
1482
- OPEN=$(echo "$RESP" | jq -r '.open // 0' 2>/dev/null)
1840
+ const config = await loadConfig(jwt);
1841
+ const rt = await cweRoute(config);
1483
1842
 
1484
- if [ "\${EDITS:-0}" = "0" ] || [ -z "$EDITS" ]; then echo '{}'; exit 0; fi
1843
+ if (config.silent) {
1844
+ outputJson({ systemMessage: '[synkro:' + rt + ':cweScan] ' + fileShort + ' \\u2192 skipped (silent mode)' });
1845
+ return;
1846
+ }
1485
1847
 
1486
- synkro_load_config
1487
- TAG=$(synkro_tag)
1488
- if [ "\${FINDINGS:-0}" = "0" ] || [ -z "$FINDINGS" ]; then
1489
- SYS_MSG="$TAG stop \u2192 0 issues across \${EDITS} edit(s), session complete"
1490
- else
1491
- SYS_MSG="$TAG stop \u2192 \${FINDINGS} finding(s): \${AUTO_FIXED} auto-fixed, \${OPEN} open"
1492
- fi
1848
+ const cweTag = '[synkro:' + rt + ':cweScan]';
1493
1849
 
1494
- jq -n --arg m "$SYS_MSG" '{systemMessage: $m}'
1495
- exit 0
1496
- `;
1497
- CC_SESSION_START_SCRIPT = `#!/bin/bash
1498
- SCRIPT_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
1499
- . "$SCRIPT_DIR/_synkro-common.sh"
1850
+ if (rt === 'local') {
1851
+ // \u2500\u2500\u2500 Local CWE grading on channel 2 (port 8930) \u2500\u2500\u2500
1852
+ let cweRules: any[] = [];
1853
+ try {
1854
+ const resp = await fetch(GATEWAY_URL + '/api/v1/cwe-rules?ext=' + encodeURIComponent(fileExt), {
1855
+ headers: { Authorization: 'Bearer ' + jwt },
1856
+ signal: AbortSignal.timeout(4000),
1857
+ });
1858
+ const data = await resp.json() as any;
1859
+ cweRules = data.rules || [];
1860
+ } catch {}
1500
1861
 
1501
- JWT=$(synkro_load_jwt)
1862
+ if (cweRules.length === 0) {
1863
+ outputJson({ systemMessage: cweTag + ' ' + fileShort + ' \\u2192 clean (no CWE rules for ' + fileExt + ')' });
1864
+ return;
1865
+ }
1502
1866
 
1503
- # Route preamble
1504
- SYNKRO_PORT="\${SYNKRO_CHANNEL_PORT:-8929}"
1505
- PAYLOAD=$(cat)
1506
- CWD=$(echo "$PAYLOAD" | jq -r '.cwd // empty' 2>/dev/null)
1507
- SESSION_ID=$(echo "$PAYLOAD" | jq -r '.session_id // empty' 2>/dev/null)
1508
- GIT_REPO=$(synkro_detect_repo "\${CWD:-.}")
1867
+ const proposedShort = proposed.slice(0, 4000);
1868
+ const graderPrompt = [
1869
+ 'File: ' + filePath,
1870
+ 'Content (first 4000 chars):',
1871
+ proposedShort,
1872
+ '',
1873
+ 'CWE rules to check against:',
1874
+ JSON.stringify(cweRules),
1875
+ ].join('\\n');
1876
+
1877
+ let gradeResp: string;
1878
+ try {
1879
+ gradeResp = await localGradeCwe(graderPrompt);
1880
+ } catch {
1881
+ outputJson({ systemMessage: cweTag + ' ' + fileShort + ' \\u2192 grader unavailable, skipped' });
1882
+ return;
1883
+ }
1509
1884
 
1510
- RESP=""
1511
- if [ -n "$JWT" ]; then
1512
- RESP=$(curl -sS -G "\${GATEWAY_URL}/api/v1/hook/config" \\
1513
- --data-urlencode "session_id=\${SESSION_ID:-}" \\
1514
- --data-urlencode "repo=\${GIT_REPO:-}" \\
1515
- -H "Authorization: Bearer $JWT" --max-time 3 2>/dev/null || echo "")
1516
- if [ -n "$RESP" ]; then
1517
- SYNKRO_SILENT=$(echo "$RESP" | jq -r '.silent_mode // false' 2>/dev/null)
1518
- SYNKRO_POLICY_NAME=$(echo "$RESP" | jq -r '.active_policy_name // empty' 2>/dev/null)
1519
- fi
1520
- fi
1885
+ const verdict = parseVerdict(gradeResp);
1521
1886
 
1522
- if (exec 3<>/dev/tcp/127.0.0.1/"$SYNKRO_PORT") 2>/dev/null; then
1523
- exec 3<&- 3>&- 2>/dev/null || true
1524
- TAG=$(synkro_tag "local")
1525
- ROUTE_LINE="$TAG inference: local-cc (channel reachable on 127.0.0.1:$SYNKRO_PORT)"
1526
- else
1527
- TAG=$(synkro_tag "cloud")
1528
- ROUTE_LINE="$TAG inference: cloud (local-cc channel not reachable)"
1529
- fi
1887
+ if (!verdict.ok) {
1888
+ // Extract all CWE rule_ids from the raw response
1889
+ const ruleIdMatches = gradeResp.match(/<rule_id>([^<]+)<\\/rule_id>/g) || [];
1890
+ const cweIds: string[] = [];
1891
+ for (const match of ruleIdMatches.slice(0, 5)) {
1892
+ const id = match.replace(/<\\/?rule_id>/g, '').trim().replace(/^cwe-/, 'CWE-');
1893
+ if (id && !cweIds.includes(id)) cweIds.push(id);
1894
+ }
1895
+ const displayIds = cweIds.slice(0, 3).join(', ');
1896
+ const count = cweIds.length;
1897
+ const label = count === 1 ? 'match' : 'matches';
1898
+ const cweMsg = cweTag + ' ' + fileShort + ' \\u2192 ' + count + ' CWE ' + label + ' (' + displayIds + ')';
1899
+ const denyDetail = '[' + displayIds + '] ' + (verdict.reason || 'code weakness detected');
1900
+ const ctx = 'CWE: ' + denyDetail + '\\nFix all issues before retrying.';
1901
+
1902
+ outputJson({
1903
+ systemMessage: cweMsg,
1904
+ hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: ctx },
1905
+ });
1906
+ return;
1907
+ }
1530
1908
 
1531
- if [ -z "$JWT" ]; then
1532
- jq -n --arg m "$ROUTE_LINE" '{systemMessage: $m}'
1533
- exit 0
1534
- fi
1909
+ outputJson({ systemMessage: cweTag + ' ' + fileShort + ' \\u2192 clean' });
1910
+ return;
1911
+ }
1535
1912
 
1536
- OPEN=0
1537
- if [ -n "$RESP" ]; then
1538
- OPEN=$(echo "$RESP" | jq -r '.session_context.open_findings // 0' 2>/dev/null)
1539
- fi
1913
+ // \u2500\u2500\u2500 Cloud CWE grading (handled by server) \u2500\u2500\u2500
1914
+ // Cloud edit precheck already includes CWE \u2014 this hook is a no-op for cloud.
1915
+ outputEmpty();
1916
+ } catch (err) {
1917
+ process.stderr.write('[synkro] cweGuard error: ' + String(err) + '\\n');
1918
+ outputEmpty();
1919
+ }
1920
+ }
1540
1921
 
1541
- if [ "$OPEN" = "0" ] || [ -z "$OPEN" ]; then
1542
- jq -n --arg m "$ROUTE_LINE" '{systemMessage: $m}'
1543
- else
1544
- if [ "$OPEN" = "1" ]; then
1545
- SYS_MSG="$ROUTE_LINE"$'\\n'"$TAG session start \u2192 1 open finding in this repo from a prior session."
1546
- else
1547
- SYS_MSG="$ROUTE_LINE"$'\\n'"$TAG session start \u2192 \${OPEN} open findings in this repo from prior sessions."
1548
- fi
1549
- jq -n --arg m "$SYS_MSG" '{systemMessage: $m}'
1550
- fi
1551
- exit 0
1922
+ main();
1552
1923
  `;
1553
- CC_BASH_FOLLOWUP_SCRIPT = `#!/bin/bash
1554
- SCRIPT_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
1555
- . "$SCRIPT_DIR/_synkro-common.sh"
1924
+ CVE_PRECHECK_TS = `#!/usr/bin/env bun
1925
+ import {
1926
+ loadJwt, ensureFreshJwt, detectRepo, loadConfig, route, tag,
1927
+ reconstructContent, readStdin, findNearestDeps, log,
1928
+ outputJson, outputEmpty, GATEWAY_URL,
1929
+ } from './_synkro-common.ts';
1930
+ import { basename } from 'node:path';
1931
+
1932
+ const MANIFEST_NAMES = new Set([
1933
+ 'package.json', 'requirements.txt', 'requirements-dev.txt', 'requirements-test.txt',
1934
+ 'Pipfile', 'go.mod', 'go.sum', 'Gemfile', 'pom.xml', 'Cargo.toml', 'composer.json', 'pyproject.toml',
1935
+ ]);
1936
+
1937
+ function isManifest(filename: string): boolean {
1938
+ if (MANIFEST_NAMES.has(filename)) return true;
1939
+ if (filename.startsWith('requirements') && filename.endsWith('.txt')) return true;
1940
+ if (filename.startsWith('build.gradle')) return true;
1941
+ if (filename.endsWith('.cabal')) return true;
1942
+ return false;
1943
+ }
1556
1944
 
1557
- JWT=$(synkro_load_jwt)
1558
- if [ -z "$JWT" ]; then echo '{}'; exit 0; fi
1945
+ async function main() {
1946
+ try {
1947
+ const input = await readStdin();
1948
+ if (!input.trim()) { outputEmpty(); return; }
1559
1949
 
1560
- PAYLOAD=$(cat)
1561
- TOOL_NAME=$(echo "$PAYLOAD" | jq -r '.tool_name // empty' 2>/dev/null)
1562
- if [ "$TOOL_NAME" != "Bash" ]; then echo '{}'; exit 0; fi
1950
+ const payload = JSON.parse(input);
1951
+ const toolName = payload.tool_name || '';
1952
+ if (!['Edit', 'Write', 'MultiEdit', 'NotebookEdit'].includes(toolName)) {
1953
+ outputEmpty();
1954
+ return;
1955
+ }
1563
1956
 
1564
- SESSION_ID=$(echo "$PAYLOAD" | jq -r '.session_id // empty' 2>/dev/null)
1565
- TOOL_USE_ID=$(echo "$PAYLOAD" | jq -r '.tool_use_id // empty' 2>/dev/null)
1566
- if [ -z "$SESSION_ID" ] || [ -z "$TOOL_USE_ID" ]; then echo '{}'; exit 0; fi
1957
+ const toolInput = payload.tool_input || {};
1958
+ const cwd = payload.cwd || '';
1567
1959
 
1568
- # Extract is_error from tool_result and compute command hash for consent tracking
1569
- IS_ERROR=$(echo "$PAYLOAD" | jq -r '.tool_result.is_error // false' 2>/dev/null)
1570
- CMD=$(echo "$PAYLOAD" | jq -r '.tool_input.command // empty' 2>/dev/null)
1571
- CMD_HASH=""
1572
- if [ -n "$CMD" ]; then
1573
- CMD_HASH=$(printf '%s' "$CMD" | shasum -a 256 | cut -c1-16)
1574
- fi
1960
+ const filePath = toolInput.file_path || toolInput.notebook_path || toolInput.path || '';
1961
+ if (!filePath) { outputEmpty(); return; }
1575
1962
 
1576
- BODY=$(jq -n --arg sid "$SESSION_ID" --arg tid "$TOOL_USE_ID" \\
1577
- --argjson err "$IS_ERROR" --arg ch "$CMD_HASH" \\
1578
- '{capture_type:"bash_followup",session_id:$sid,tool_use_id:$tid,is_error:$err,command_hash:$ch}')
1963
+ const fileShort = basename(filePath);
1579
1964
 
1580
- # Local consent tracking: command ran = user consented
1581
- # On success \u2192 consume (next attempt blocks fresh)
1582
- # On failure \u2192 grant active (retry allowed)
1583
- # Consent tracking: on success \u2192 consume (next run blocks fresh), on error \u2192 grant (retry allowed)
1584
- if [ -n "$CMD_HASH" ] && [ -n "$SESSION_ID" ]; then
1585
- if [ "$IS_ERROR" = "false" ]; then
1586
- synkro_consent_consume "$SESSION_ID" "$CMD_HASH"
1587
- else
1588
- if ! synkro_consent_has_active "$SESSION_ID" "$CMD_HASH"; then
1589
- synkro_consent_grant "$SESSION_ID" "$CMD_HASH"
1590
- fi
1591
- fi
1592
- fi
1965
+ let jwt = loadJwt();
1966
+ if (!jwt) { outputEmpty(); return; }
1967
+ jwt = await ensureFreshJwt(jwt);
1593
1968
 
1594
- curl -sS -X POST "\${GATEWAY_URL}/api/v1/hook/capture" \\
1595
- -H "Content-Type: application/json" -H "Authorization: Bearer $JWT" \\
1596
- -d "$BODY" --max-time 2 >/dev/null 2>&1 || true
1969
+ const config = await loadConfig(jwt);
1970
+ const rt = await route(config);
1597
1971
 
1598
- echo '{}'
1599
- exit 0
1600
- `;
1601
- CC_CVE_SCAN_SCRIPT = `#!/bin/bash
1602
- SCRIPT_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
1603
- . "$SCRIPT_DIR/_synkro-common.sh"
1972
+ if (config.silent) {
1973
+ outputJson({ systemMessage: '[synkro:' + rt + ':cveScan] ' + fileShort + ' \\u2192 skipped (silent mode)' });
1974
+ return;
1975
+ }
1604
1976
 
1605
- JWT=$(synkro_load_jwt)
1606
- if [ -z "$JWT" ]; then echo '{}'; exit 0; fi
1607
- synkro_ensure_fresh_jwt
1977
+ const cveTag = '[synkro:' + rt + ':cveScan]';
1608
1978
 
1609
- PAYLOAD=$(cat)
1610
- if [ -z "$PAYLOAD" ]; then echo '{}'; exit 0; fi
1979
+ // Reconstruct proposed content
1980
+ const proposed = reconstructContent(toolName, toolInput, filePath);
1981
+ if (!proposed) {
1982
+ outputJson({ systemMessage: cveTag + ' ' + fileShort + ' \\u2192 skip (no content)' });
1983
+ return;
1984
+ }
1611
1985
 
1612
- TOOL_NAME=$(echo "$PAYLOAD" | jq -r '.tool_name // empty' 2>/dev/null)
1613
- case "$TOOL_NAME" in Edit|Write|MultiEdit|NotebookEdit) ;; *) echo '{}'; exit 0 ;; esac
1986
+ const proposedShort = proposed.slice(0, 4000);
1614
1987
 
1615
- TOOL_INPUT=$(echo "$PAYLOAD" | jq -c '.tool_input // {}' 2>/dev/null)
1616
- CWD=$(echo "$PAYLOAD" | jq -r '.cwd // empty' 2>/dev/null)
1988
+ // For code files, find nearest package.json and extract deps
1989
+ let deps: Record<string, string> = {};
1990
+ if (!isManifest(fileShort)) {
1991
+ deps = findNearestDeps(filePath);
1992
+ }
1617
1993
 
1618
- FILE_PATH=$(echo "$TOOL_INPUT" | jq -r '.file_path // .notebook_path // .path // empty' 2>/dev/null)
1619
- if [ -z "$FILE_PATH" ] || [ ! -f "$FILE_PATH" ]; then echo '{}'; exit 0; fi
1994
+ // CVE scan via OSV API
1995
+ const cveBody = {
1996
+ file_path: filePath,
1997
+ content: proposedShort,
1998
+ dependencies: deps,
1999
+ };
1620
2000
 
1621
- FILE_CONTENT=$(head -c 65536 "$FILE_PATH" 2>/dev/null || echo "")
1622
- if [ -z "$FILE_CONTENT" ]; then echo '{}'; exit 0; fi
2001
+ let cveResp: any;
2002
+ try {
2003
+ const resp = await fetch(GATEWAY_URL + '/api/v1/cve-scan', {
2004
+ method: 'POST',
2005
+ headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + jwt },
2006
+ body: JSON.stringify(cveBody),
2007
+ signal: AbortSignal.timeout(8000),
2008
+ });
2009
+ cveResp = await resp.json();
2010
+ } catch {
2011
+ outputJson({ systemMessage: cveTag + ' ' + fileShort + ' \\u2192 error (timeout)' });
2012
+ return;
2013
+ }
1623
2014
 
1624
- DEPS_JSON="{}"
1625
- _PKG_DIR=$(dirname "$FILE_PATH")
1626
- while [ "$_PKG_DIR" != "/" ]; do
1627
- if [ -f "$_PKG_DIR/package.json" ]; then
1628
- DEPS_JSON=$(jq -c '(.dependencies // {}) + (.devDependencies // {})' "$_PKG_DIR/package.json" 2>/dev/null || echo "{}")
1629
- break
1630
- fi
1631
- _PKG_DIR=$(dirname "$_PKG_DIR")
1632
- done
2015
+ const findings = Array.isArray(cveResp?.findings) ? cveResp.findings : [];
2016
+ if (findings.length > 0) {
2017
+ const top3 = findings.slice(0, 3).map((f: any) => {
2018
+ const id = f.cve || f.id || '?';
2019
+ const pkg = f.package || '?';
2020
+ const ver = f.version || '?';
2021
+ const title = f.title || f.summary || 'vulnerable';
2022
+ return '[' + id + '] ' + pkg + '@' + ver + ': ' + title;
2023
+ }).join('; ');
2024
+
2025
+ const count = findings.length;
2026
+ const label = count === 1 ? 'advisory' : 'advisories';
2027
+ const cveMsg = cveTag + ' ' + fileShort + ' \\u2192 ' + count + ' ' + label;
2028
+ const ctx = 'CVE: ' + top3 + '\\nFix all issues before retrying.';
2029
+
2030
+ outputJson({
2031
+ systemMessage: cveMsg,
2032
+ hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: ctx },
2033
+ });
2034
+ return;
2035
+ }
1633
2036
 
1634
- synkro_load_config
1635
- ROUTE=$(synkro_route)
1636
- TAG=$(synkro_tag "$ROUTE")
2037
+ outputJson({ systemMessage: cveTag + ' ' + fileShort + ' \\u2192 clean' });
2038
+ } catch (err) {
2039
+ process.stderr.write('[synkro] cveGuard error: ' + String(err) + '\\n');
2040
+ outputEmpty();
2041
+ }
2042
+ }
1637
2043
 
1638
- if [ "$SYNKRO_SILENT" = "true" ]; then echo '{}'; exit 0; fi
2044
+ main();
2045
+ `;
2046
+ BASH_JUDGE_TS = `#!/usr/bin/env bun
2047
+ import {
2048
+ loadJwt, ensureFreshJwt, detectRepo, loadConfig, route, tag, localGrade,
2049
+ parseVerdict, dispatchCapture, ruleMode, postWithRetry, readStdin,
2050
+ extractTranscript, readLastPrompt, log,
2051
+ outputJson, outputEmpty, GATEWAY_URL,
2052
+ type HookConfig, type Rule,
2053
+ } from './_synkro-common.ts';
1639
2054
 
1640
- BASENAME=$(basename "$FILE_PATH")
2055
+ async function main() {
2056
+ try {
2057
+ const input = await readStdin();
2058
+ if (!input.trim()) { outputEmpty(); return; }
1641
2059
 
1642
- BODY=$(jq -n --arg fp "$FILE_PATH" --arg c "$FILE_CONTENT" --argjson deps "$DEPS_JSON" \\
1643
- '{file_path:$fp, content:$c, dependencies:$deps}')
2060
+ const payload = JSON.parse(input);
2061
+ const toolName = payload.tool_name || '';
2062
+ if (!['Bash', 'Read', 'Grep', 'Glob'].includes(toolName)) {
2063
+ outputEmpty();
2064
+ return;
2065
+ }
1644
2066
 
1645
- RESP=$(curl -sS -X POST "\${GATEWAY_URL}/api/v1/cve-scan" \\
1646
- -H "Content-Type: application/json" -H "Authorization: Bearer $JWT" \\
1647
- -d "$BODY" --max-time 6 2>/dev/null || echo "")
2067
+ const toolInput = payload.tool_input || {};
2068
+ const sessionId = payload.session_id || '';
2069
+ const toolUseId = payload.tool_use_id || '';
2070
+ const cwd = payload.cwd || '';
2071
+ const permissionMode = payload.permission_mode || '';
2072
+ const transcriptPath = payload.transcript_path || '';
2073
+ const gitRepo = detectRepo(cwd || '.');
2074
+
2075
+ let command = '';
2076
+ switch (toolName) {
2077
+ case 'Bash': command = toolInput.command || ''; break;
2078
+ case 'Read': command = 'cat ' + (toolInput.file_path || ''); break;
2079
+ case 'Grep': command = "grep -r '" + (toolInput.pattern || '') + "' " + (toolInput.path || '.'); break;
2080
+ case 'Glob': command = "find . -name '" + (toolInput.pattern || '') + "'"; break;
2081
+ }
2082
+ if (!command) { outputEmpty(); return; }
1648
2083
 
1649
- if [ -z "$RESP" ] || ! echo "$RESP" | jq -e 'type == "object"' >/dev/null 2>&1; then
1650
- echo '{}'; exit 0
1651
- fi
2084
+ const cmdShort = command.slice(0, 80);
2085
+ log('bashGuard checking: ' + cmdShort);
1652
2086
 
1653
- CVE_COUNT=$(echo "$RESP" | jq -r '.findings | length' 2>/dev/null || echo "0")
1654
- if [ "$CVE_COUNT" -gt 0 ] 2>/dev/null; then
1655
- CVE_CRIT=$(echo "$RESP" | jq '[.findings[] | select((.severity | tonumber? // 0) >= 7.0)] | length' 2>/dev/null || echo "0")
1656
- CRIT_PKGS=$(echo "$RESP" | jq -r '[.findings[] | select((.severity | tonumber? // 0) >= 7.0) | .package] | unique | .[:3] | join(", ")' 2>/dev/null || echo "")
1657
- ALL_PKGS=$(echo "$RESP" | jq -r '[.findings[].package] | unique | .[:3] | join(", ")' 2>/dev/null || echo "")
1658
- ALL_TOTAL=$(echo "$RESP" | jq -r '[.findings[].package] | unique | length' 2>/dev/null || echo "0")
1659
- [ "$CVE_COUNT" = "1" ] && LABEL="advisory" || LABEL="advisories"
1660
- if [ "$CVE_CRIT" -gt 0 ]; then
1661
- [ "$ALL_TOTAL" -gt 3 ] && CRIT_PKGS="\${CRIT_PKGS}, ..."
1662
- jq -n --arg m "[synkro:\${ROUTE}:cveScan] \${CVE_COUNT} \${LABEL}, \${CVE_CRIT} critical/high (\${CRIT_PKGS})" '{systemMessage: $m}'
1663
- else
1664
- [ "$ALL_TOTAL" -gt 3 ] && ALL_PKGS="\${ALL_PKGS}, ..."
1665
- jq -n --arg m "[synkro:\${ROUTE}:cveScan] \${CVE_COUNT} \${LABEL} (\${ALL_PKGS})" '{systemMessage: $m}'
1666
- fi
1667
- else
1668
- jq -n --arg m "[synkro:\${ROUTE}:cveScan] clean" '{systemMessage: $m}'
1669
- fi
1670
- exit 0
1671
- `;
1672
- CC_CWE_SCAN_SCRIPT = `#!/bin/bash
1673
- SCRIPT_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
1674
- . "$SCRIPT_DIR/_synkro-common.sh"
2087
+ let jwt = loadJwt();
2088
+ if (!jwt) { outputEmpty(); return; }
2089
+ jwt = await ensureFreshJwt(jwt);
1675
2090
 
1676
- JWT=$(synkro_load_jwt)
1677
- if [ -z "$JWT" ]; then echo '{}'; exit 0; fi
1678
- synkro_ensure_fresh_jwt
2091
+ const transcript = extractTranscript(transcriptPath);
2092
+ const lastPrompt = readLastPrompt();
1679
2093
 
1680
- PAYLOAD=$(cat)
1681
- if [ -z "$PAYLOAD" ]; then echo '{}'; exit 0; fi
2094
+ const config = await loadConfig(jwt);
2095
+ const rt = await route(config);
2096
+ const tagStr = tag(rt, config);
1682
2097
 
1683
- TOOL_NAME=$(echo "$PAYLOAD" | jq -r '.tool_name // empty' 2>/dev/null)
1684
- case "$TOOL_NAME" in Edit|Write|MultiEdit|NotebookEdit) ;; *) echo '{}'; exit 0 ;; esac
2098
+ if (config.silent) {
2099
+ outputJson({ systemMessage: tagStr + ' bashGuard \\u2192 skipped (silent mode)' });
2100
+ return;
2101
+ }
1685
2102
 
1686
- TOOL_INPUT=$(echo "$PAYLOAD" | jq -c '.tool_input // {}' 2>/dev/null)
1687
- CWD=$(echo "$PAYLOAD" | jq -r '.cwd // empty' 2>/dev/null)
2103
+ if (rt === 'local') {
2104
+ const graderPrompt = [
2105
+ 'Working directory: ' + (cwd || '.'),
2106
+ 'Repo: ' + (gitRepo || 'unknown'),
2107
+ 'Command: ' + command,
2108
+ 'User intent (last human message): ' + (transcript.userIntent || 'none stated'),
2109
+ 'Last user prompt: ' + (lastPrompt || 'none'),
2110
+ 'Org rules: ' + JSON.stringify(config.rules),
2111
+ ].join('\\n');
2112
+
2113
+ let gradeResp: string;
2114
+ try {
2115
+ gradeResp = await localGrade('bash', graderPrompt);
2116
+ } catch {
2117
+ outputEmpty();
2118
+ return;
2119
+ }
1688
2120
 
1689
- FILE_PATH=$(echo "$TOOL_INPUT" | jq -r '.file_path // .notebook_path // .path // empty' 2>/dev/null)
1690
- if [ -z "$FILE_PATH" ] || [ ! -f "$FILE_PATH" ]; then echo '{}'; exit 0; fi
2121
+ const verdict = parseVerdict(gradeResp);
2122
+ const violatedRules = verdict.ruleId ? [verdict.ruleId] : [];
1691
2123
 
1692
- FILE_CONTENT=$(head -c 65536 "$FILE_PATH" 2>/dev/null || echo "")
1693
- if [ -z "$FILE_CONTENT" ]; then echo '{}'; exit 0; fi
2124
+ if (!verdict.ok) {
2125
+ const mode = verdict.ruleMode || ruleMode(verdict.ruleId, config.rules);
2126
+ const guardReason = (verdict.ruleId ? '(' + verdict.ruleId + ') ' : '') + (verdict.reason || 'policy violation');
1694
2127
 
1695
- synkro_load_config
1696
- ROUTE=$(synkro_route)
1697
- TAG=$(synkro_tag "$ROUTE")
2128
+ if (mode === 'audit') {
2129
+ const reason = tagStr + ' bashGuard \\u2192 warning' + (verdict.ruleId ? ' (' + verdict.ruleId + ')' : '') + ': ' + (verdict.reason || 'policy violation');
2130
+ outputJson({ systemMessage: reason });
2131
+ dispatchCapture(jwt, 'bash', 'warning', verdict.severity || 'medium', verdict.category || 'security',
2132
+ toolName, gitRepo, sessionId, config.captureDepth, {
2133
+ command, reasoning: guardReason, rulesChecked: config.rules, violatedRules,
2134
+ recentUserMessages: transcript.recentUserMessages, ccModel: transcript.ccModel,
2135
+ });
2136
+ } else {
2137
+ const reason = tagStr + ' bashGuard \\u2192 blocked' + (verdict.ruleId ? ' (' + verdict.ruleId + ')' : '') + ': ' + (verdict.reason || 'policy violation') + '. Ask the user for explicit consent before retrying.';
2138
+ outputJson({
2139
+ systemMessage: reason,
2140
+ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: reason, additionalContext: reason },
2141
+ });
2142
+ dispatchCapture(jwt, 'bash', 'block', verdict.severity || 'critical', verdict.category || 'security',
2143
+ toolName, gitRepo, sessionId, config.captureDepth, {
2144
+ command, reasoning: guardReason, rulesChecked: config.rules, violatedRules,
2145
+ recentUserMessages: transcript.recentUserMessages, ccModel: transcript.ccModel,
2146
+ });
2147
+ }
2148
+ } else {
2149
+ outputJson({ systemMessage: tagStr + ' bashGuard \\u2192 pass: ' + (verdict.reason || 'no policy violations detected') });
2150
+ dispatchCapture(jwt, 'bash', 'pass', 'audit', verdict.category || 'trivial_utility',
2151
+ toolName, gitRepo, sessionId, config.captureDepth, {
2152
+ command, reasoning: verdict.reason || 'no policy violations detected',
2153
+ rulesChecked: config.rules, violatedRules: [],
2154
+ recentUserMessages: transcript.recentUserMessages, ccModel: transcript.ccModel,
2155
+ });
2156
+ }
2157
+ return;
2158
+ }
1698
2159
 
1699
- if [ "$SYNKRO_SILENT" = "true" ]; then echo '{}'; exit 0; fi
2160
+ // \u2500\u2500\u2500 Cloud grading \u2500\u2500\u2500
2161
+ const isHeadless = ['acceptEdits', 'bypassPermissions', 'plan', 'auto'].includes(permissionMode)
2162
+ || process.env.SYNKRO_HEADLESS === '1';
2163
+
2164
+ const body: Record<string, any> = {
2165
+ hook_event: 'PreToolUse',
2166
+ tool_name: toolName,
2167
+ tool_input: toolInput,
2168
+ user_intent: transcript.userIntent || null,
2169
+ last_user_message: lastPrompt || null,
2170
+ recent_user_messages: transcript.recentUserMessages,
2171
+ recent_messages: transcript.recentMessages,
2172
+ recent_actions: transcript.recentActions,
2173
+ session_id: sessionId || null,
2174
+ tool_use_id: toolUseId || null,
2175
+ cwd: cwd || null,
2176
+ repo: gitRepo || null,
2177
+ permission_mode: permissionMode || null,
2178
+ headless: isHeadless,
2179
+ cc_model: transcript.ccModel || null,
2180
+ cc_usage: transcript.ccUsage || {},
2181
+ session_summary: transcript.sessionSummary || null,
2182
+ };
1700
2183
 
1701
- BASENAME=$(basename "$FILE_PATH")
1702
- FILE_EXT=".\${FILE_PATH##*.}"
2184
+ const resp = await postWithRetry(GATEWAY_URL + '/api/v1/hook/judge', body, jwt, 8000);
1703
2185
 
1704
- # \u2500\u2500\u2500 Helper: format CWE findings into system message \u2500\u2500\u2500
1705
- format_cwe_result() {
1706
- local count="$1" crit="$2" ids="$3" total="$4"
1707
- [ "$count" = "1" ] && local label="match" || local label="matches"
1708
- if [ "$crit" -gt 0 ] 2>/dev/null; then
1709
- [ "$total" -gt 3 ] && ids="\${ids}, ..."
1710
- jq -n --arg m "[synkro:\${ROUTE}:cweScan] \${count} CWE \${label}, \${crit} critical (\${ids})" '{systemMessage: $m}'
1711
- else
1712
- [ "$total" -gt 3 ] && ids="\${ids}, ..."
1713
- jq -n --arg m "[synkro:\${ROUTE}:cweScan] \${count} CWE \${label} (\${ids})" '{systemMessage: $m}'
1714
- fi
1715
- }
2186
+ if (!resp) {
2187
+ log('bashGuard ' + cmdShort + ' \\u2192 error (timeout)');
2188
+ outputEmpty();
2189
+ return;
2190
+ }
1716
2191
 
1717
- if [ "$ROUTE" = "local" ]; then
1718
- # \u2500\u2500\u2500 Local CWE scan: deterministic Top 25 filter + local-cc grader \u2500\u2500\u2500
1719
- CWE_RULES=$(curl -sS -X GET "\${GATEWAY_URL}/api/v1/cwe-rules?ext=$FILE_EXT" \\
1720
- -H "Authorization: Bearer $JWT" --max-time 4 2>/dev/null || echo "")
1721
- CWE_LIST=$(echo "$CWE_RULES" | jq -c '.rules // []' 2>/dev/null || echo "[]")
1722
- CWE_RULE_COUNT=$(echo "$CWE_LIST" | jq 'length' 2>/dev/null || echo "0")
1723
- if [ "$CWE_RULE_COUNT" -eq 0 ] 2>/dev/null; then
1724
- jq -n --arg m "[synkro:\${ROUTE}:cweScan] clean (no CWEs for $FILE_EXT)" '{systemMessage: $m}'
1725
- exit 0
1726
- fi
2192
+ if (!resp.hook_response || typeof resp.hook_response !== 'object') {
2193
+ log('bashGuard ' + cmdShort + ' \\u2192 pass (no hook_response)');
2194
+ outputEmpty();
2195
+ return;
2196
+ }
1727
2197
 
1728
- GRADER_FILE=$(mktemp -t synkro-cwescan.XXXXXX)
1729
- trap "rm -f \\"$GRADER_FILE\\"" EXIT
1730
- printf 'File: %s\\nContent (first 4000 chars):\\n%s\\n\\nCWE rules to check against:\\n%s\\n' "$FILE_PATH" "$(printf '%s' "$FILE_CONTENT" | head -c 4000)" "$CWE_LIST" > "$GRADER_FILE"
2198
+ outputJson(resp.hook_response);
2199
+ } catch (err) {
2200
+ process.stderr.write('[synkro] bashGuard error: ' + String(err) + '\\n');
2201
+ outputEmpty();
2202
+ }
2203
+ }
1731
2204
 
1732
- CC_RESP=$(synkro_local_grade_cwe < "$GRADER_FILE" 2>&1)
1733
- if [ $? -ne 0 ]; then
1734
- jq -n --arg m "[synkro:\${ROUTE}:cweScan] pass: local grader unavailable" '{systemMessage: $m}'
1735
- exit 0
1736
- fi
1737
- synkro_parse_local_verdict "$CC_RESP"
1738
-
1739
- if [ "$LOCAL_OK" = "false" ]; then
1740
- CWE_IDS=""
1741
- CWE_COUNT=0
1742
- CWE_CRIT=0
1743
- while IFS= read -r vid; do
1744
- [ -z "$vid" ] && continue
1745
- CWE_COUNT=$((CWE_COUNT + 1))
1746
- cwe_tag=$(printf '%s' "$CC_RESP" | tr '\\n' ' ' | grep -oE "<violation>[^<]*<rule_id>$vid</rule_id>[^<]*<severity>[^<]*</severity>" | head -1)
1747
- sev=$(printf '%s' "$cwe_tag" | sed -nE 's|.*<severity>(.*)</severity>.*|\\1|p')
1748
- [ "$sev" = "critical" ] && CWE_CRIT=$((CWE_CRIT + 1))
1749
- CWE_ID=$(echo "$vid" | sed 's/cwe-/CWE-/')
1750
- [ "$CWE_COUNT" -le 3 ] && { [ -n "$CWE_IDS" ] && CWE_IDS="$CWE_IDS, $CWE_ID" || CWE_IDS="$CWE_ID"; }
1751
- done <<< "$(printf '%s' "$CC_RESP" | tr '\\n' ' ' | grep -oE '<rule_id>[^<]+</rule_id>' | sed 's/<[^>]*>//g')"
1752
-
1753
- if [ "$CWE_COUNT" -gt 0 ]; then
1754
- format_cwe_result "$CWE_COUNT" "$CWE_CRIT" "$CWE_IDS" "$CWE_COUNT"
1755
- else
1756
- jq -n --arg m "[synkro:\${ROUTE}:cweScan] clean" '{systemMessage: $m}'
1757
- fi
1758
- else
1759
- jq -n --arg m "[synkro:\${ROUTE}:cweScan] clean" '{systemMessage: $m}'
1760
- fi
1761
- exit 0
1762
- fi
2205
+ main();
2206
+ `;
2207
+ PLAN_JUDGE_TS = `#!/usr/bin/env bun
2208
+ import {
2209
+ loadJwt, ensureFreshJwt, detectRepo, loadConfig, route, tag, localGrade,
2210
+ parseVerdict, dispatchCapture, postWithRetry, readStdin, log,
2211
+ outputJson, outputEmpty, GATEWAY_URL,
2212
+ } from './_synkro-common.ts';
2213
+ import { existsSync, readFileSync, writeFileSync, readdirSync, statSync } from 'node:fs';
2214
+ import { join } from 'node:path';
2215
+ import { homedir } from 'node:os';
2216
+
2217
+ function findLatestPlan(): string | null {
2218
+ const plansDir = join(homedir(), '.claude', 'plans');
2219
+ if (!existsSync(plansDir)) return null;
2220
+ try {
2221
+ const files = readdirSync(plansDir)
2222
+ .filter(f => f.endsWith('.md'))
2223
+ .map(f => ({ name: f, mtime: statSync(join(plansDir, f)).mtimeMs }))
2224
+ .sort((a, b) => b.mtime - a.mtime);
2225
+ return files.length > 0 ? join(plansDir, files[0].name) : null;
2226
+ } catch {
2227
+ return null;
2228
+ }
2229
+ }
1763
2230
 
1764
- # \u2500\u2500\u2500 Cloud CWE scan: cosine similarity against full CWE catalog \u2500\u2500\u2500
1765
- BODY=$(jq -n --arg fp "$FILE_PATH" --arg c "$FILE_CONTENT" \\
1766
- '{file_path:$fp, content:$c}')
2231
+ function appendReviewToPlan(planFile: string, verdict: string): void {
2232
+ try {
2233
+ let content = readFileSync(planFile, 'utf-8');
2234
+ content = content.replace(/<!-- synkro-plan-review -->[\\s\\S]*?<!-- \\/synkro-plan-review -->/g, '').trimEnd();
2235
+ const now = new Date().toISOString().replace('T', ' ').slice(0, 16);
2236
+ content += '\\n\\n<!-- synkro-plan-review -->\\n\\n---\\n\\n**Synkro Plan Review** \\u2014 ' + now + '\\n\\n' + verdict + '\\n\\n<!-- /synkro-plan-review -->\\n';
2237
+ writeFileSync(planFile, content, 'utf-8');
2238
+ } catch {}
2239
+ }
1767
2240
 
1768
- RESP=$(curl -sS -X POST "\${GATEWAY_URL}/api/v1/cwe-scan" \\
1769
- -H "Content-Type: application/json" -H "Authorization: Bearer $JWT" \\
1770
- -d "$BODY" --max-time 8 2>/dev/null || echo "")
2241
+ async function main() {
2242
+ try {
2243
+ const input = await readStdin();
2244
+ if (!input.trim()) { outputEmpty(); return; }
1771
2245
 
1772
- if [ -z "$RESP" ] || ! echo "$RESP" | jq -e 'type == "object"' >/dev/null 2>&1; then
1773
- echo '{}'; exit 0
1774
- fi
2246
+ const payload = JSON.parse(input);
2247
+ const toolName = payload.tool_name || '';
2248
+ if (toolName !== 'ExitPlanMode') { outputEmpty(); return; }
1775
2249
 
1776
- CWE_COUNT=$(echo "$RESP" | jq -r '.findings | length' 2>/dev/null || echo "0")
1777
- if [ "$CWE_COUNT" -gt 0 ] 2>/dev/null; then
1778
- CWE_CRIT=$(echo "$RESP" | jq '[.findings[] | select(.severity == "critical" or .mode == "blocking")] | length' 2>/dev/null || echo "0")
1779
- CWE_IDS=$(echo "$RESP" | jq -r '[.findings[:3][] | .cwe] | join(", ")' 2>/dev/null || echo "")
1780
- CWE_TOTAL=$(echo "$RESP" | jq -r '.findings | length' 2>/dev/null || echo "0")
1781
- format_cwe_result "$CWE_COUNT" "$CWE_CRIT" "$CWE_IDS" "$CWE_TOTAL"
1782
- else
1783
- jq -n --arg m "[synkro:\${ROUTE}:cweScan] clean" '{systemMessage: $m}'
1784
- fi
1785
- exit 0
1786
- `;
1787
- CC_TRANSCRIPT_SYNC_SCRIPT = `#!/bin/bash
1788
- SCRIPT_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
1789
- . "$SCRIPT_DIR/_synkro-common.sh"
2250
+ const planFile = findLatestPlan();
2251
+ if (!planFile) { outputEmpty(); return; }
2252
+ const plan = readFileSync(planFile, 'utf-8');
2253
+ if (plan.length < 20) { outputEmpty(); return; }
1790
2254
 
1791
- JWT=$(synkro_load_jwt)
1792
- if [ -z "$JWT" ]; then echo '{}'; exit 0; fi
2255
+ const sessionId = payload.session_id || '';
2256
+ const cwd = payload.cwd || '';
2257
+ const gitRepo = detectRepo(cwd || '.');
1793
2258
 
1794
- PAYLOAD=$(cat)
1795
- SESSION_ID=$(echo "$PAYLOAD" | jq -r '.session_id // empty' 2>/dev/null)
1796
- TRANSCRIPT_PATH=$(echo "$PAYLOAD" | jq -r '.transcript_path // empty' 2>/dev/null)
1797
- CWD=$(echo "$PAYLOAD" | jq -r '.cwd // empty' 2>/dev/null)
2259
+ const planShort = plan.slice(0, 80);
2260
+ log('planReview checking: ' + planShort + '...');
1798
2261
 
1799
- if [ -z "$SESSION_ID" ] || [ -z "$TRANSCRIPT_PATH" ] || [ ! -f "$TRANSCRIPT_PATH" ]; then
1800
- echo '{}'; exit 0
1801
- fi
2262
+ let jwt = loadJwt();
2263
+ if (!jwt) { outputEmpty(); return; }
2264
+ jwt = await ensureFreshJwt(jwt);
1802
2265
 
1803
- # Usage telemetry \u2014 last turn only (metadata, ungated by privacy/consent)
1804
- _LAST_ASST=$(grep '"type":"assistant"' "$TRANSCRIPT_PATH" 2>/dev/null | tail -1)
1805
- if [ -n "$_LAST_ASST" ]; then
1806
- _CC_MODEL=$(echo "$_LAST_ASST" | jq -r '.message.model // empty' 2>/dev/null)
1807
- _TI=$(echo "$_LAST_ASST" | jq -r '.message.usage.input_tokens // 0' 2>/dev/null)
1808
- _TO=$(echo "$_LAST_ASST" | jq -r '.message.usage.output_tokens // 0' 2>/dev/null)
1809
- _TCW=$(echo "$_LAST_ASST" | jq -r '.message.usage.cache_creation_input_tokens // 0' 2>/dev/null)
1810
- _TCR=$(echo "$_LAST_ASST" | jq -r '.message.usage.cache_read_input_tokens // 0' 2>/dev/null)
1811
- if [ "\${_TI:-0}" != "0" ] || [ "\${_TO:-0}" != "0" ]; then
1812
- (
1813
- _USAGE="{\\"input_tokens\\":$_TI,\\"output_tokens\\":$_TO,\\"cache_creation_input_tokens\\":$_TCW,\\"cache_read_input_tokens\\":$_TCR}"
1814
- _BODY=$(jq -n \\
1815
- --arg event_id "usage_$(date +%s)_$$" \\
1816
- --arg hook_type "stop" --arg verdict "allow" --arg severity "none" \\
1817
- --arg model "\${_CC_MODEL:-unknown}" \\
1818
- --arg cc_model "\${_CC_MODEL:-}" \\
1819
- --arg session_id "$SESSION_ID" \\
1820
- --argjson cc_usage "$_USAGE" \\
1821
- '{capture_type:"usage_tick",event_id:$event_id,hook_type:$hook_type,verdict:$verdict,severity:$severity,model:$model,cc_usage:$cc_usage,session_id:$session_id}
1822
- + (if $cc_model != "" then {cc_model:$cc_model} else {} end)')
1823
- curl -sS -X POST "\${GATEWAY_URL}/api/v1/hook/capture" \\
1824
- -H "Content-Type: application/json" -H "Authorization: Bearer $JWT" \\
1825
- -d "$_BODY" --max-time 2 >/dev/null 2>&1
1826
- ) &
1827
- fi
1828
- fi
2266
+ const config = await loadConfig(jwt);
2267
+ const rt = await route(config);
2268
+ const tagStr = tag(rt, config);
1829
2269
 
1830
- # Transcript sync below is gated by consent + capture depth
1831
- if [ "\${SYNKRO_TRANSCRIPT_CONSENT:-yes}" = "no" ]; then echo '{}'; exit 0; fi
2270
+ if (config.silent) {
2271
+ outputJson({ systemMessage: tagStr + ' planReview \\u2192 skipped (silent mode)' });
2272
+ return;
2273
+ }
1832
2274
 
1833
- GIT_REPO=$(synkro_detect_repo "\${CWD:-.}")
1834
- if [ -z "$GIT_REPO" ]; then echo '{}'; exit 0; fi
2275
+ if (rt === 'local') {
2276
+ const graderPrompt = [
2277
+ 'Working directory: ' + (cwd || '.'),
2278
+ 'Repo: ' + (gitRepo || 'unknown'),
2279
+ 'Plan:',
2280
+ plan.slice(0, 8000),
2281
+ 'Org rules: ' + JSON.stringify(config.rules),
2282
+ ].join('\\n');
1835
2283
 
1836
- # Check capture depth \u2014 skip in local_only
1837
- CONFIG_RESP=$(curl -sS "\${GATEWAY_URL}/api/v1/hook/config" -H "Authorization: Bearer $JWT" --max-time 3 2>/dev/null || echo "")
1838
- CAPTURE_DEPTH=$(echo "$CONFIG_RESP" | jq -r '.capture_depth // "local_only"' 2>/dev/null)
1839
- if [ "$CAPTURE_DEPTH" = "local_only" ]; then echo '{}'; exit 0; fi
2284
+ let gradeResp: string;
2285
+ try {
2286
+ gradeResp = await localGrade('plan', graderPrompt);
2287
+ } catch {
2288
+ outputEmpty();
2289
+ return;
2290
+ }
1840
2291
 
1841
- OFFSET_DIR="$HOME/.synkro/.transcript-offsets"
1842
- mkdir -p "$OFFSET_DIR" 2>/dev/null || true
1843
- OFFSET_FILE="$OFFSET_DIR/$SESSION_ID"
1844
- OFFSET=0
1845
- [ -f "$OFFSET_FILE" ] && OFFSET=$(cat "$OFFSET_FILE" 2>/dev/null || echo "0")
2292
+ const verdict = parseVerdict(gradeResp);
2293
+ const planContent = plan.slice(0, 2000);
2294
+ const violatedRules = verdict.ruleId ? [verdict.ruleId] : [];
2295
+
2296
+ if (!verdict.ok) {
2297
+ const reviewMsg = (verdict.ruleId ? '(first: ' + verdict.ruleId + ') ' : '') + (verdict.reason || 'check org rules during implementation');
2298
+ appendReviewToPlan(planFile, '\\u26a0\\ufe0f Advisory \\u2014 ' + reviewMsg);
2299
+ outputJson({ systemMessage: tagStr + ' planReview \\u2192 ' + reviewMsg });
2300
+ dispatchCapture(jwt, 'plan_review', 'advisory', verdict.severity || 'medium', verdict.category || 'general',
2301
+ 'ExitPlanMode', gitRepo, sessionId, config.captureDepth, {
2302
+ command: planContent, reasoning: verdict.reason || 'check org rules',
2303
+ rulesChecked: config.rules, violatedRules,
2304
+ });
2305
+ } else {
2306
+ const reviewMsg = verdict.reason || 'no relevant org rules for this plan';
2307
+ appendReviewToPlan(planFile, '\\u2705 Clean \\u2014 ' + reviewMsg);
2308
+ outputJson({ systemMessage: tagStr + ' planReview \\u2192 clean: ' + reviewMsg });
2309
+ dispatchCapture(jwt, 'plan_review', 'clean', 'audit', verdict.category || 'general',
2310
+ 'ExitPlanMode', gitRepo, sessionId, config.captureDepth, {
2311
+ command: planContent, reasoning: reviewMsg,
2312
+ rulesChecked: config.rules, violatedRules: [],
2313
+ });
2314
+ }
2315
+ return;
2316
+ }
1846
2317
 
1847
- TOTAL_LINES=$(wc -l < "$TRANSCRIPT_PATH" 2>/dev/null | tr -d ' ')
1848
- if [ -z "$TOTAL_LINES" ] || [ "$TOTAL_LINES" -le "$OFFSET" ] 2>/dev/null; then echo '{}'; exit 0; fi
2318
+ // \u2500\u2500\u2500 Cloud grading \u2500\u2500\u2500
2319
+ const body = {
2320
+ hook_event: 'PreToolUse',
2321
+ tool_name: 'ExitPlanMode',
2322
+ tool_input: { plan: plan.slice(0, 16000) },
2323
+ session_id: sessionId || null,
2324
+ cwd: cwd || null,
2325
+ repo: gitRepo || null,
2326
+ };
1849
2327
 
1850
- DELTA=$((TOTAL_LINES - OFFSET))
1851
- START_LINE=$((OFFSET + 1))
1852
- [ "$DELTA" -gt 200 ] && START_LINE=$((TOTAL_LINES - 199))
2328
+ const resp = await postWithRetry(GATEWAY_URL + '/api/v1/hook/judge', body, jwt, 12000);
1853
2329
 
1854
- MESSAGES=$(tail -n +"$START_LINE" "$TRANSCRIPT_PATH" 2>/dev/null | jq -c --argjson base_idx "$((START_LINE - 1))" '
1855
- . as $line |
1856
- if ($line.type == "user" or $line.type == "assistant") then
1857
- {
1858
- message_index: (input_line_number + $base_idx),
1859
- type: $line.type,
1860
- content: (if $line.type == "user" then ($line.message.content | if type == "string" then .[0:8000] else ([.[]? | if type == "string" then . elif (type == "object" and .type == "text") then (.text // "") else "" end] | join(" ") | .[0:8000]) end) else ([$line.message.content[]? | select(type == "object" and .type == "text") | .text // ""] | join(" ") | .[0:8000]) end),
1861
- tool_calls: (if $line.type == "assistant" then [$line.message.content[]? | select(.type == "tool_use") | {name, input: (.input | tostring | .[0:500]), id}] else null end | if . == null or length == 0 then null else . end),
1862
- model: ($line.message.model // null),
1863
- usage: (if $line.type == "assistant" and $line.message.usage then {input_tokens: $line.message.usage.input_tokens, output_tokens: $line.message.usage.output_tokens, cache_creation_input_tokens: $line.message.usage.cache_creation_input_tokens, cache_read_input_tokens: $line.message.usage.cache_read_input_tokens} else null end)
1864
- }
1865
- else empty end
1866
- ' 2>/dev/null | jq -s '.' 2>/dev/null)
1867
-
1868
- if [ -z "$MESSAGES" ] || [ "$MESSAGES" = "[]" ] || [ "$MESSAGES" = "null" ]; then
1869
- printf '%s' "$TOTAL_LINES" > "$OFFSET_FILE" 2>/dev/null || true
1870
- echo '{}'; exit 0
1871
- fi
2330
+ if (!resp) {
2331
+ log('planReview \\u2192 error (timeout)');
2332
+ outputEmpty();
2333
+ return;
2334
+ }
1872
2335
 
1873
- BODY=$(jq -n --arg repo "$GIT_REPO" --arg sid "$SESSION_ID" --argjson messages "$MESSAGES" \\
1874
- '{repo: $repo, sessions: [{cc_session_id: $sid, messages: $messages}]}')
2336
+ const hookResp = resp?.hook_response;
2337
+ if (!hookResp) { outputEmpty(); return; }
1875
2338
 
1876
- (
1877
- curl -sS -X POST "\${GATEWAY_URL}/api/v1/cli/sync-transcripts" \\
1878
- -H "Content-Type: application/json" -H "Authorization: Bearer $JWT" \\
1879
- -d "$BODY" --max-time 10 >/dev/null 2>&1
1880
- ) &
1881
- disown 2>/dev/null || true
2339
+ const decision = hookResp?.hookSpecificOutput?.permissionDecision;
2340
+ if (decision) {
2341
+ const reason = hookResp?.hookSpecificOutput?.permissionDecisionReason || 'check org rules';
2342
+ appendReviewToPlan(planFile, '\\u26a0\\ufe0f Advisory \\u2014 ' + reason);
2343
+ outputJson({ systemMessage: tagStr + ' planReview \\u2192 advisory: ' + reason });
2344
+ } else {
2345
+ const cloudMsg = hookResp.systemMessage || '';
2346
+ if (cloudMsg) appendReviewToPlan(planFile, '\\u2705 ' + cloudMsg);
2347
+ outputJson(hookResp);
2348
+ }
2349
+ } catch (err) {
2350
+ process.stderr.write('[synkro] planReview error: ' + String(err) + '\\n');
2351
+ outputEmpty();
2352
+ }
2353
+ }
1882
2354
 
1883
- printf '%s' "$TOTAL_LINES" > "$OFFSET_FILE" 2>/dev/null || true
1884
- echo '{}'; exit 0
2355
+ main();
1885
2356
  `;
1886
- CURSOR_BASH_JUDGE_SCRIPT = `#!/bin/bash
1887
- SCRIPT_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
1888
- . "$SCRIPT_DIR/_synkro-common.sh"
2357
+ STOP_SUMMARY_TS = `#!/usr/bin/env bun
2358
+ import {
2359
+ loadJwt, detectRepo, loadConfig, tag, readStdin, aggregateUsage,
2360
+ outputJson, outputEmpty, GATEWAY_URL,
2361
+ } from './_synkro-common.ts';
1889
2362
 
1890
- JWT=$(synkro_load_jwt)
1891
- if [ -z "$JWT" ]; then echo '{}'; exit 0; fi
1892
- synkro_ensure_fresh_jwt
2363
+ async function main() {
2364
+ try {
2365
+ const input = await readStdin();
2366
+ if (!input.trim()) { outputEmpty(); return; }
2367
+
2368
+ const payload = JSON.parse(input);
2369
+ const sessionId = payload.session_id || '';
2370
+ if (!sessionId) { outputEmpty(); return; }
2371
+
2372
+ const cwd = payload.cwd || '';
2373
+ const transcriptPath = payload.transcript_path || '';
2374
+ const gitRepo = detectRepo(cwd || '.');
2375
+
2376
+ let jwt = loadJwt();
2377
+ if (!jwt) { outputEmpty(); return; }
2378
+
2379
+ if (transcriptPath) {
2380
+ const usage = aggregateUsage(transcriptPath);
2381
+ if (usage.totals.in + usage.totals.out > 0) {
2382
+ const usageBody = {
2383
+ capture_type: 'local_verdict',
2384
+ event_id: 'usage_' + Date.now() + '_' + process.pid,
2385
+ hook_type: 'stop',
2386
+ verdict: 'allow',
2387
+ severity: 'none',
2388
+ model: usage.model || 'unknown',
2389
+ cc_model: usage.model || '',
2390
+ cc_usage: {
2391
+ input_tokens: usage.totals.in,
2392
+ output_tokens: usage.totals.out,
2393
+ cache_creation_input_tokens: usage.totals.cw,
2394
+ cache_read_input_tokens: usage.totals.cr,
2395
+ },
2396
+ ...(gitRepo ? { repo: gitRepo } : {}),
2397
+ ...(sessionId ? { session_id: sessionId } : {}),
2398
+ };
2399
+ fetch(GATEWAY_URL + '/api/v1/hook/capture', {
2400
+ method: 'POST',
2401
+ headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + jwt },
2402
+ body: JSON.stringify(usageBody),
2403
+ signal: AbortSignal.timeout(3000),
2404
+ }).catch(() => {});
2405
+ }
2406
+ }
2407
+
2408
+ let resp: any;
2409
+ try {
2410
+ const r = await fetch(GATEWAY_URL + '/api/v1/cli/session-summary?session_id=' + encodeURIComponent(sessionId), {
2411
+ headers: { Authorization: 'Bearer ' + jwt },
2412
+ signal: AbortSignal.timeout(3000),
2413
+ });
2414
+ resp = await r.json();
2415
+ } catch {
2416
+ outputEmpty();
2417
+ return;
2418
+ }
2419
+
2420
+ const edits = resp?.edits_scanned || 0;
2421
+ const findings = resp?.findings || 0;
2422
+ const autoFixed = resp?.auto_fixed || 0;
2423
+ const open = resp?.open || 0;
2424
+
2425
+ if (!edits) { outputEmpty(); return; }
2426
+
2427
+ const config = await loadConfig(jwt);
2428
+ const tagStr = tag('local', config);
2429
+
2430
+ if (!findings) {
2431
+ outputJson({ systemMessage: tagStr + ' stop \\u2192 0 issues across ' + edits + ' edit(s), session complete' });
2432
+ } else {
2433
+ outputJson({ systemMessage: tagStr + ' stop \\u2192 ' + findings + ' finding(s): ' + autoFixed + ' auto-fixed, ' + open + ' open' });
2434
+ }
2435
+ } catch (err) {
2436
+ process.stderr.write('[synkro] stopSummary error: ' + String(err) + '\\n');
2437
+ outputEmpty();
2438
+ }
2439
+ }
2440
+
2441
+ main();
2442
+ `;
2443
+ SESSION_START_TS = `#!/usr/bin/env bun
2444
+ import {
2445
+ loadJwt, detectRepo, channelUp, tag, readStdin,
2446
+ outputJson, outputEmpty, GATEWAY_URL,
2447
+ type HookConfig,
2448
+ } from './_synkro-common.ts';
1893
2449
 
1894
- PAYLOAD=$(cat)
1895
- if [ -z "$PAYLOAD" ]; then echo '{}'; exit 0; fi
2450
+ async function main() {
2451
+ try {
2452
+ const input = await readStdin();
2453
+ if (!input.trim()) { outputEmpty(); return; }
1896
2454
 
1897
- COMMAND=$(echo "$PAYLOAD" | jq -r '.command // empty' 2>/dev/null)
1898
- if [ -z "$COMMAND" ]; then echo '{}'; exit 0; fi
2455
+ const payload = JSON.parse(input);
2456
+ const cwd = payload.cwd || '';
2457
+ const sessionId = payload.session_id || '';
2458
+ const gitRepo = detectRepo(cwd || '.');
1899
2459
 
1900
- CWD=$(echo "$PAYLOAD" | jq -r '.cwd // empty' 2>/dev/null)
1901
- SESSION_ID=$(echo "$PAYLOAD" | jq -r '.conversation_id // empty' 2>/dev/null)
1902
- GIT_REPO=$(synkro_detect_repo "\${CWD:-.}")
2460
+ let jwt = loadJwt();
1903
2461
 
1904
- CMD_SHORT=$(printf '%s' "$COMMAND" | head -c 80)
1905
- synkro_log "bashGuard checking: $CMD_SHORT"
2462
+ const isChannelUp = await channelUp();
2463
+ const rt = isChannelUp ? 'local' : 'cloud';
1906
2464
 
1907
- synkro_load_config
1908
- if [ "$SYNKRO_SILENT" = "true" ]; then
1909
- echo '{}'; exit 0
1910
- fi
2465
+ let policyName = '';
2466
+ let silent = false;
2467
+ let openFindings = 0;
1911
2468
 
1912
- BODY=$(jq -n \\
1913
- --arg cmd "$COMMAND" \\
1914
- --arg session_id "$SESSION_ID" \\
1915
- --arg cwd "$CWD" \\
1916
- --arg repo "$GIT_REPO" \\
1917
- '{
1918
- hook_event: "PreToolUse",
1919
- tool_name: "Bash",
1920
- tool_input: {command: $cmd},
1921
- response_format: "cursor",
1922
- session_id: (if ($session_id | length) > 0 then $session_id else null end),
1923
- cwd: (if ($cwd | length) > 0 then $cwd else null end),
1924
- repo: (if ($repo | length) > 0 then $repo else null end)
1925
- }')
2469
+ if (jwt) {
2470
+ try {
2471
+ const url = GATEWAY_URL + '/api/v1/hook/config?session_id=' + encodeURIComponent(sessionId || '') + '&repo=' + encodeURIComponent(gitRepo || '');
2472
+ const r = await fetch(url, {
2473
+ headers: { Authorization: 'Bearer ' + jwt },
2474
+ signal: AbortSignal.timeout(3000),
2475
+ });
2476
+ const data = await r.json() as any;
2477
+ silent = data.silent_mode === true || data.silent_mode === 'true';
2478
+ policyName = data.active_policy_name || '';
2479
+ openFindings = data.session_context?.open_findings || 0;
2480
+ } catch {}
2481
+ }
1926
2482
 
1927
- RESP=$(synkro_post_with_retry "\${GATEWAY_URL}/api/v1/hook/judge" "$BODY" 6)
2483
+ const fakeConfig: HookConfig = { captureDepth: 'local_only', tier: 'standard', silent, policyName, rules: [] };
2484
+ const tagStr = tag(rt, fakeConfig);
2485
+ const routeLine = tagStr + ' inference: ' + (isChannelUp ? 'local-cc (channel reachable on 127.0.0.1:8929)' : 'cloud (local-cc channel not reachable)');
1928
2486
 
1929
- if [ -z "$RESP" ]; then
1930
- synkro_log "bashGuard $CMD_SHORT \u2192 error (timeout)"
1931
- echo '{}'; exit 0
1932
- fi
2487
+ if (!jwt) {
2488
+ outputJson({ systemMessage: routeLine });
2489
+ return;
2490
+ }
1933
2491
 
1934
- # Server returns cursor-format directly in hook_response
1935
- if echo "$RESP" | jq -e '.hook_response' >/dev/null 2>&1; then
1936
- echo "$RESP" | jq -c '.hook_response'
1937
- else
1938
- echo '{}'
1939
- fi
1940
- exit 0
2492
+ if (!openFindings) {
2493
+ outputJson({ systemMessage: routeLine });
2494
+ } else if (openFindings === 1) {
2495
+ outputJson({ systemMessage: routeLine + '\\n' + tagStr + ' session start \\u2192 1 open finding in this repo from a prior session.' });
2496
+ } else {
2497
+ outputJson({ systemMessage: routeLine + '\\n' + tagStr + ' session start \\u2192 ' + openFindings + ' open findings in this repo from prior sessions.' });
2498
+ }
2499
+ } catch (err) {
2500
+ process.stderr.write('[synkro] sessionStart error: ' + String(err) + '\\n');
2501
+ outputEmpty();
2502
+ }
2503
+ }
2504
+
2505
+ main();
1941
2506
  `;
1942
- CURSOR_EDIT_PRECHECK_SCRIPT = `#!/bin/bash
1943
- SCRIPT_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
1944
- . "$SCRIPT_DIR/_synkro-common.sh"
2507
+ BASH_FOLLOWUP_TS = `#!/usr/bin/env bun
2508
+ import {
2509
+ loadJwt, readStdin, hashCommand, consentGrant, consentHasActive, consentConsume,
2510
+ outputEmpty, GATEWAY_URL,
2511
+ } from './_synkro-common.ts';
1945
2512
 
1946
- JWT=$(synkro_load_jwt)
1947
- if [ -z "$JWT" ]; then echo '{}'; exit 0; fi
1948
- synkro_ensure_fresh_jwt
2513
+ async function main() {
2514
+ try {
2515
+ const input = await readStdin();
2516
+ if (!input.trim()) { outputEmpty(); return; }
1949
2517
 
1950
- PAYLOAD=$(cat)
1951
- if [ -z "$PAYLOAD" ]; then echo '{}'; exit 0; fi
2518
+ const payload = JSON.parse(input);
2519
+ const toolName = payload.tool_name || '';
2520
+ if (toolName !== 'Bash') { outputEmpty(); return; }
1952
2521
 
1953
- TOOL_NAME=$(echo "$PAYLOAD" | jq -r '.tool_name // empty' 2>/dev/null)
1954
- CWD=$(echo "$PAYLOAD" | jq -r '.cwd // empty' 2>/dev/null)
1955
- SESSION_ID=$(echo "$PAYLOAD" | jq -r '.conversation_id // empty' 2>/dev/null)
1956
- GIT_REPO=$(synkro_detect_repo "\${CWD:-.}")
2522
+ const jwt = loadJwt();
2523
+ if (!jwt) { outputEmpty(); return; }
1957
2524
 
1958
- FILE_PATH=$(echo "$PAYLOAD" | jq -r '.tool_input.file_path // .tool_input.path // .tool_input.target_file // empty' 2>/dev/null)
1959
- CONTENT=$(echo "$PAYLOAD" | jq -r '.tool_input.content // .tool_input.new_string // .tool_input.code_edit // empty' 2>/dev/null)
1960
- if [ -z "$FILE_PATH" ]; then echo '{}'; exit 0; fi
2525
+ const sessionId = payload.session_id || '';
2526
+ const toolUseId = payload.tool_use_id || '';
2527
+ if (!sessionId || !toolUseId) { outputEmpty(); return; }
1961
2528
 
1962
- BASENAME=$(basename "$FILE_PATH" 2>/dev/null || echo "$FILE_PATH")
1963
- synkro_log "editGuard checking: $BASENAME"
2529
+ const isError = payload.tool_result?.is_error === true;
2530
+ const cmd = payload.tool_input?.command || '';
2531
+ const cmdHash = cmd ? hashCommand(cmd) : '';
1964
2532
 
1965
- synkro_load_config
1966
- if [ "$SYNKRO_SILENT" = "true" ]; then
1967
- echo '{}'; exit 0
1968
- fi
2533
+ if (cmdHash && sessionId) {
2534
+ if (!isError) {
2535
+ consentConsume(sessionId, cmdHash);
2536
+ } else {
2537
+ if (!consentHasActive(sessionId, cmdHash)) {
2538
+ consentGrant(sessionId, cmdHash);
2539
+ }
2540
+ }
2541
+ }
1969
2542
 
1970
- BODY=$(jq -n \\
1971
- --arg file_path "$FILE_PATH" \\
1972
- --arg content "$CONTENT" \\
1973
- --arg session_id "$SESSION_ID" \\
1974
- --arg cwd "$CWD" \\
1975
- --arg repo "$GIT_REPO" \\
1976
- '{
1977
- hook_event: "PreToolUse",
1978
- tool_name: "Edit",
1979
- tool_input: {file_path: $file_path, content: $content},
1980
- file_path: $file_path,
1981
- content: $content,
1982
- response_format: "cursor",
1983
- session_id: (if ($session_id | length) > 0 then $session_id else null end),
1984
- cwd: (if ($cwd | length) > 0 then $cwd else null end),
1985
- repo: (if ($repo | length) > 0 then $repo else null end)
1986
- }')
2543
+ const body = {
2544
+ capture_type: 'bash_followup',
2545
+ session_id: sessionId,
2546
+ tool_use_id: toolUseId,
2547
+ is_error: isError,
2548
+ command_hash: cmdHash,
2549
+ };
1987
2550
 
1988
- RESP=$(synkro_post_with_retry "\${GATEWAY_URL}/api/v1/hook/judge" "$BODY" 8)
2551
+ fetch(GATEWAY_URL + '/api/v1/hook/capture', {
2552
+ method: 'POST',
2553
+ headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + jwt },
2554
+ body: JSON.stringify(body),
2555
+ signal: AbortSignal.timeout(3000),
2556
+ }).catch(() => {});
1989
2557
 
1990
- if [ -z "$RESP" ]; then
1991
- synkro_log "editGuard $BASENAME \u2192 error (timeout)"
1992
- echo '{}'; exit 0
1993
- fi
2558
+ outputEmpty();
2559
+ } catch {
2560
+ outputEmpty();
2561
+ }
2562
+ }
1994
2563
 
1995
- if echo "$RESP" | jq -e '.hook_response' >/dev/null 2>&1; then
1996
- echo "$RESP" | jq -c '.hook_response'
1997
- else
1998
- echo '{}'
1999
- fi
2000
- exit 0
2564
+ main();
2001
2565
  `;
2002
- CURSOR_EDIT_CAPTURE_SCRIPT = `#!/bin/bash
2003
- SCRIPT_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
2004
- . "$SCRIPT_DIR/_synkro-common.sh"
2566
+ TRANSCRIPT_SYNC_TS = `#!/usr/bin/env bun
2567
+ import {
2568
+ loadJwt, detectRepo, readStdin, aggregateUsage,
2569
+ outputEmpty, GATEWAY_URL,
2570
+ } from './_synkro-common.ts';
2571
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
2572
+ import { join, dirname } from 'node:path';
2573
+ import { homedir } from 'node:os';
2005
2574
 
2006
- JWT=$(synkro_load_jwt)
2007
- if [ -z "$JWT" ]; then echo '{}'; exit 0; fi
2575
+ async function main() {
2576
+ try {
2577
+ const input = await readStdin();
2578
+ if (!input.trim()) { outputEmpty(); return; }
2008
2579
 
2009
- PAYLOAD=$(cat)
2010
- if [ -z "$PAYLOAD" ]; then echo '{}'; exit 0; fi
2580
+ const payload = JSON.parse(input);
2581
+ const sessionId = payload.session_id || '';
2582
+ const transcriptPath = payload.transcript_path || '';
2583
+ const cwd = payload.cwd || '';
2011
2584
 
2012
- FILE_PATH=$(echo "$PAYLOAD" | jq -r '.file_path // empty' 2>/dev/null)
2013
- if [ -z "$FILE_PATH" ]; then echo '{}'; exit 0; fi
2585
+ if (!sessionId || !transcriptPath || !existsSync(transcriptPath)) {
2586
+ outputEmpty();
2587
+ return;
2588
+ }
2014
2589
 
2015
- CWD=$(echo "$PAYLOAD" | jq -r '.cwd // .workspace_roots[0] // empty' 2>/dev/null)
2016
- SESSION_ID=$(echo "$PAYLOAD" | jq -r '.conversation_id // empty' 2>/dev/null)
2017
- GIT_REPO=$(synkro_detect_repo "\${CWD:-.}")
2018
- BASENAME=$(basename "$FILE_PATH" 2>/dev/null || echo "$FILE_PATH")
2590
+ const jwt = loadJwt();
2591
+ if (!jwt) { outputEmpty(); return; }
2592
+
2593
+ const usage = aggregateUsage(transcriptPath);
2594
+ if (usage.totals.in + usage.totals.out > 0) {
2595
+ const usageBody = {
2596
+ capture_type: 'usage_tick',
2597
+ event_id: 'usage_' + Date.now() + '_' + process.pid,
2598
+ hook_type: 'stop',
2599
+ verdict: 'allow',
2600
+ severity: 'none',
2601
+ model: usage.model || 'unknown',
2602
+ cc_model: usage.model || '',
2603
+ cc_usage: {
2604
+ input_tokens: usage.totals.in,
2605
+ output_tokens: usage.totals.out,
2606
+ cache_creation_input_tokens: usage.totals.cw,
2607
+ cache_read_input_tokens: usage.totals.cr,
2608
+ },
2609
+ session_id: sessionId,
2610
+ };
2611
+ fetch(GATEWAY_URL + '/api/v1/hook/capture', {
2612
+ method: 'POST',
2613
+ headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + jwt },
2614
+ body: JSON.stringify(usageBody),
2615
+ signal: AbortSignal.timeout(3000),
2616
+ }).catch(() => {});
2617
+ }
2019
2618
 
2020
- FULL_PATH="$FILE_PATH"
2021
- [ -n "$CWD" ] && FULL_PATH="$CWD/$FILE_PATH"
2022
- FULL_CONTENT=""
2023
- [ -f "$FULL_PATH" ] && FULL_CONTENT=$(head -c 50000 "$FULL_PATH" 2>/dev/null || true)
2619
+ if (process.env.SYNKRO_TRANSCRIPT_CONSENT === 'no') { outputEmpty(); return; }
2024
2620
 
2025
- DEPS_JSON="{}"
2026
- _PKG_DIR="\${CWD:-.}"
2027
- while [ "$_PKG_DIR" != "/" ]; do
2028
- if [ -f "$_PKG_DIR/package.json" ]; then
2029
- DEPS_JSON=$(jq -c '(.dependencies // {}) + (.devDependencies // {})' "$_PKG_DIR/package.json" 2>/dev/null || echo "{}")
2030
- break
2031
- fi
2032
- _PKG_DIR=$(dirname "$_PKG_DIR")
2033
- done
2621
+ const gitRepo = detectRepo(cwd || '.');
2622
+ if (!gitRepo) { outputEmpty(); return; }
2034
2623
 
2035
- synkro_log "editScan $BASENAME"
2624
+ let captureDepth = 'local_only';
2625
+ try {
2626
+ const r = await fetch(GATEWAY_URL + '/api/v1/hook/config', {
2627
+ headers: { Authorization: 'Bearer ' + jwt },
2628
+ signal: AbortSignal.timeout(3000),
2629
+ });
2630
+ const data = await r.json() as any;
2631
+ captureDepth = data.capture_depth || 'local_only';
2632
+ } catch {}
2633
+
2634
+ if (captureDepth === 'local_only') { outputEmpty(); return; }
2635
+
2636
+ const offsetDir = join(homedir(), '.synkro', '.transcript-offsets');
2637
+ mkdirSync(offsetDir, { recursive: true });
2638
+ const offsetFile = join(offsetDir, sessionId);
2639
+ let offset = 0;
2640
+ if (existsSync(offsetFile)) {
2641
+ try { offset = parseInt(readFileSync(offsetFile, 'utf-8').trim(), 10) || 0; } catch {}
2642
+ }
2036
2643
 
2037
- (
2038
- BODY=$(jq -n \\
2039
- --arg file_path "$FILE_PATH" --arg content "$FULL_CONTENT" \\
2040
- --arg session_id "$SESSION_ID" --arg cwd "$CWD" --arg repo "$GIT_REPO" \\
2041
- --argjson deps "$DEPS_JSON" \\
2042
- '{capture_type:"edit_scan",tool_input:{file_path:$file_path,content:$content},edit_verdict:{ok:true},dependencies:$deps}
2043
- + (if ($session_id | length) > 0 then {session_id:$session_id} else {} end)
2044
- + (if ($cwd | length) > 0 then {cwd:$cwd} else {} end)
2045
- + (if ($repo | length) > 0 then {repo:$repo} else {} end)')
2046
- curl -sS -X POST "\${GATEWAY_URL}/api/v1/hook/capture" \\
2047
- -H "Content-Type: application/json" -H "Authorization: Bearer $JWT" \\
2048
- -d "$BODY" --max-time 10 >/dev/null 2>&1 || true
2049
- ) &
2050
- disown 2>/dev/null || true
2644
+ const raw = readFileSync(transcriptPath, 'utf-8');
2645
+ const allLines = raw.split('\\n').filter(l => l.trim());
2646
+ const totalLines = allLines.length;
2051
2647
 
2052
- echo '{}'
2053
- exit 0
2054
- `;
2055
- CURSOR_BASH_FOLLOWUP_SCRIPT = `#!/bin/bash
2056
- SCRIPT_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
2057
- . "$SCRIPT_DIR/_synkro-common.sh"
2648
+ if (totalLines <= offset) { outputEmpty(); return; }
2058
2649
 
2059
- JWT=$(synkro_load_jwt)
2060
- if [ -z "$JWT" ]; then echo '{}'; exit 0; fi
2650
+ let startIdx = offset;
2651
+ const delta = totalLines - offset;
2652
+ if (delta > 200) startIdx = totalLines - 200;
2061
2653
 
2062
- PAYLOAD=$(cat)
2063
- TOOL_NAME=$(echo "$PAYLOAD" | jq -r '.tool_name // empty' 2>/dev/null)
2064
- case "$TOOL_NAME" in Shell|Bash|terminal|run_terminal_cmd|execute_command) ;; *) echo '{}'; exit 0 ;; esac
2654
+ const messages: any[] = [];
2655
+ for (let i = startIdx; i < totalLines; i++) {
2656
+ try {
2657
+ const entry = JSON.parse(allLines[i]);
2658
+ if (entry.type !== 'user' && entry.type !== 'assistant') continue;
2659
+ const content = entry.message?.content;
2660
+ let text = '';
2661
+ if (typeof content === 'string') text = content.slice(0, 8000);
2662
+ else if (Array.isArray(content)) {
2663
+ text = content.map((c: any) => {
2664
+ if (typeof c === 'string') return c;
2665
+ if (c?.type === 'text') return c.text || '';
2666
+ return '';
2667
+ }).join(' ').slice(0, 8000);
2668
+ }
2065
2669
 
2066
- SESSION_ID=$(echo "$PAYLOAD" | jq -r '.conversation_id // empty' 2>/dev/null)
2067
- TOOL_USE_ID=$(echo "$PAYLOAD" | jq -r '.tool_use_id // empty' 2>/dev/null)
2670
+ const msg: any = { message_index: i, type: entry.type, content: text };
2671
+ if (entry.type === 'assistant') {
2672
+ const toolCalls = (Array.isArray(content) ? content : [])
2673
+ .filter((c: any) => c?.type === 'tool_use')
2674
+ .map((c: any) => ({ name: c.name, input: JSON.stringify(c.input || {}).slice(0, 500), id: c.id }));
2675
+ if (toolCalls.length > 0) msg.tool_calls = toolCalls;
2676
+ msg.model = entry.message?.model || null;
2677
+ const u = entry.message?.usage;
2678
+ if (u) msg.usage = { input_tokens: u.input_tokens, output_tokens: u.output_tokens, cache_creation_input_tokens: u.cache_creation_input_tokens, cache_read_input_tokens: u.cache_read_input_tokens };
2679
+ }
2680
+ messages.push(msg);
2681
+ } catch {}
2682
+ }
2068
2683
 
2069
- IS_ERROR=$(echo "$PAYLOAD" | jq -r '.tool_result.is_error // false' 2>/dev/null)
2070
- CMD=$(echo "$PAYLOAD" | jq -r '.tool_input.command // empty' 2>/dev/null)
2071
- CMD_HASH=""
2072
- if [ -n "$CMD" ]; then
2073
- CMD_HASH=$(printf '%s' "$CMD" | shasum -a 256 | cut -c1-16)
2074
- fi
2684
+ writeFileSync(offsetFile, String(totalLines), 'utf-8');
2075
2685
 
2076
- if [ -n "$CMD_HASH" ] && [ -n "$SESSION_ID" ]; then
2077
- if [ "$IS_ERROR" = "false" ]; then
2078
- synkro_consent_consume "$SESSION_ID" "$CMD_HASH"
2079
- else
2080
- if ! synkro_consent_has_active "$SESSION_ID" "$CMD_HASH"; then
2081
- synkro_consent_grant "$SESSION_ID" "$CMD_HASH"
2082
- fi
2083
- fi
2084
- fi
2686
+ if (messages.length === 0) { outputEmpty(); return; }
2085
2687
 
2086
- if [ -n "$SESSION_ID" ] && [ -n "$TOOL_USE_ID" ]; then
2087
- (
2088
- BODY=$(jq -n --arg sid "$SESSION_ID" --arg tid "$TOOL_USE_ID" \\
2089
- --argjson err "$IS_ERROR" --arg ch "$CMD_HASH" \\
2090
- '{capture_type:"bash_followup",session_id:$sid,tool_use_id:$tid,is_error:$err,command_hash:$ch}')
2091
- curl -sS -X POST "\${GATEWAY_URL}/api/v1/hook/capture" \\
2092
- -H "Content-Type: application/json" -H "Authorization: Bearer $JWT" \\
2093
- -d "$BODY" --max-time 3 >/dev/null 2>&1 || true
2094
- ) &
2095
- disown 2>/dev/null || true
2096
- fi
2688
+ const syncBody = {
2689
+ repo: gitRepo,
2690
+ sessions: [{ cc_session_id: sessionId, messages }],
2691
+ };
2692
+ fetch(GATEWAY_URL + '/api/v1/cli/sync-transcripts', {
2693
+ method: 'POST',
2694
+ headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + jwt },
2695
+ body: JSON.stringify(syncBody),
2696
+ signal: AbortSignal.timeout(10000),
2697
+ }).catch(() => {});
2698
+
2699
+ outputEmpty();
2700
+ } catch {
2701
+ outputEmpty();
2702
+ }
2703
+ }
2097
2704
 
2098
- echo '{}'
2099
- exit 0
2705
+ main();
2100
2706
  `;
2101
- CC_USER_PROMPT_SUBMIT_SCRIPT = `#!/bin/bash
2102
- # Synkro UserPromptSubmit hook \u2014 stashes the user's last message so PreToolUse
2103
- # hooks can pass it to the grader for consent detection. No regex matching here;
2104
- # the grader (local or cloud) decides whether the message constitutes consent.
2105
- PROMPT_FILE="$HOME/.synkro/.last-prompt"
2106
- PAYLOAD=$(cat)
2107
- if [ -z "$PAYLOAD" ]; then exit 0; fi
2108
- MSG=$(echo "$PAYLOAD" | jq -r '.message // .prompt // .content // empty' 2>/dev/null)
2109
- if [ -n "$MSG" ]; then
2110
- printf '%s' "$MSG" > "$PROMPT_FILE" 2>/dev/null || true
2111
- fi
2112
- exit 0
2707
+ USER_PROMPT_SUBMIT_TS = `#!/usr/bin/env bun
2708
+ import { readStdin } from './_synkro-common.ts';
2709
+ import { writeFileSync, mkdirSync } from 'node:fs';
2710
+ import { join, dirname } from 'node:path';
2711
+ import { homedir } from 'node:os';
2712
+
2713
+ async function main() {
2714
+ try {
2715
+ const input = await readStdin();
2716
+ if (!input.trim()) return;
2717
+ const payload = JSON.parse(input);
2718
+ const msg = payload.message || payload.prompt || payload.content || '';
2719
+ if (msg) {
2720
+ const promptFile = join(homedir(), '.synkro', '.last-prompt');
2721
+ mkdirSync(dirname(promptFile), { recursive: true });
2722
+ writeFileSync(promptFile, msg, 'utf-8');
2723
+ }
2724
+ } catch {}
2725
+ }
2726
+
2727
+ main();
2113
2728
  `;
2114
2729
  }
2115
2730
  });
@@ -3850,9 +4465,14 @@ function findTask(channel = CHANNEL_PRIMARY) {
3850
4465
  function startTask(opts = {}) {
3851
4466
  const ch = opts.channel ?? CHANNEL_PRIMARY;
3852
4467
  const cwd = opts.cwd ?? ch.sessionDir;
3853
- const existing = findTask(ch);
3854
- if (existing) {
4468
+ let existing = findTask(ch);
4469
+ while (existing) {
4470
+ if (existing.status === "Running") {
4471
+ spawnSync2("tmux", ["kill-session", "-t", ch.tmuxSession], { encoding: "utf-8" });
4472
+ spawnSync2("pueue", ["kill", String(existing.id)], { encoding: "utf-8" });
4473
+ }
3855
4474
  spawnSync2("pueue", ["remove", String(existing.id)], { encoding: "utf-8" });
4475
+ existing = findTask(ch);
3856
4476
  }
3857
4477
  const runScript = join8(cwd, "run-claude.sh");
3858
4478
  const args2 = [
@@ -4280,7 +4900,7 @@ __export(install_exports, {
4280
4900
  import { existsSync as existsSync11, mkdirSync as mkdirSync8, writeFileSync as writeFileSync7, chmodSync as chmodSync2, readFileSync as readFileSync10, readdirSync } from "fs";
4281
4901
  import { homedir as homedir10 } from "os";
4282
4902
  import { join as join11 } from "path";
4283
- import { execSync as execSync5 } from "child_process";
4903
+ import { execSync as execSync5, spawnSync as spawnSync3 } from "child_process";
4284
4904
  import { createInterface as createInterface3 } from "readline";
4285
4905
  function sanitizeGatewayCandidate(raw) {
4286
4906
  if (!raw) return void 0;
@@ -4322,50 +4942,50 @@ function ensureSynkroDir() {
4322
4942
  mkdirSync8(OFFSETS_DIR, { recursive: true });
4323
4943
  }
4324
4944
  function writeHookScripts() {
4325
- const bashScriptPath = join11(HOOKS_DIR, "cc-bash-judge.sh");
4326
- const bashFollowupScriptPath = join11(HOOKS_DIR, "cc-bash-followup.sh");
4327
- const editCaptureScriptPath = join11(HOOKS_DIR, "cc-edit-capture.sh");
4328
- const editPrecheckScriptPath = join11(HOOKS_DIR, "cc-edit-precheck.sh");
4329
- const cveScanScriptPath = join11(HOOKS_DIR, "cc-cve-scan.sh");
4330
- const cweScanScriptPath = join11(HOOKS_DIR, "cc-cwe-scan.sh");
4331
- const planJudgeScriptPath = join11(HOOKS_DIR, "cc-plan-judge.sh");
4332
- const stopSummaryScriptPath = join11(HOOKS_DIR, "cc-stop-summary.sh");
4333
- const sessionStartScriptPath = join11(HOOKS_DIR, "cc-session-start.sh");
4334
- const transcriptSyncScriptPath = join11(HOOKS_DIR, "cc-transcript-sync.sh");
4335
- const userPromptSubmitScriptPath = join11(HOOKS_DIR, "cc-user-prompt-submit.sh");
4336
- const commonScriptPath = join11(HOOKS_DIR, "_synkro-common.sh");
4945
+ const bashScriptPath = join11(HOOKS_DIR, "cc-bash-judge.ts");
4946
+ const bashFollowupScriptPath = join11(HOOKS_DIR, "cc-bash-followup.ts");
4947
+ const editPrecheckScriptPath = join11(HOOKS_DIR, "cc-edit-precheck.ts");
4948
+ const cwePrecheckScriptPath = join11(HOOKS_DIR, "cc-cwe-precheck.ts");
4949
+ const cvePrecheckScriptPath = join11(HOOKS_DIR, "cc-cve-precheck.ts");
4950
+ const planJudgeScriptPath = join11(HOOKS_DIR, "cc-plan-judge.ts");
4951
+ const stopSummaryScriptPath = join11(HOOKS_DIR, "cc-stop-summary.ts");
4952
+ const sessionStartScriptPath = join11(HOOKS_DIR, "cc-session-start.ts");
4953
+ const transcriptSyncScriptPath = join11(HOOKS_DIR, "cc-transcript-sync.ts");
4954
+ const userPromptSubmitScriptPath = join11(HOOKS_DIR, "cc-user-prompt-submit.ts");
4955
+ const commonScriptPath = join11(HOOKS_DIR, "_synkro-common.ts");
4956
+ const commonBashScriptPath = join11(HOOKS_DIR, "_synkro-common.sh");
4337
4957
  const cursorBashJudgePath = join11(HOOKS_DIR, "cursor-bash-judge.sh");
4338
4958
  const cursorEditPrecheckPath = join11(HOOKS_DIR, "cursor-edit-precheck.sh");
4339
4959
  const cursorEditCapturePath = join11(HOOKS_DIR, "cursor-edit-capture.sh");
4340
4960
  const cursorBashFollowupPath = join11(HOOKS_DIR, "cursor-bash-followup.sh");
4341
- writeFileSync7(bashScriptPath, CC_BASH_JUDGE_SCRIPT, "utf-8");
4342
- writeFileSync7(bashFollowupScriptPath, CC_BASH_FOLLOWUP_SCRIPT, "utf-8");
4343
- writeFileSync7(editCaptureScriptPath, CC_EDIT_CAPTURE_SCRIPT, "utf-8");
4344
- writeFileSync7(editPrecheckScriptPath, CC_EDIT_PRECHECK_SCRIPT, "utf-8");
4345
- writeFileSync7(cveScanScriptPath, CC_CVE_SCAN_SCRIPT, "utf-8");
4346
- writeFileSync7(cweScanScriptPath, CC_CWE_SCAN_SCRIPT, "utf-8");
4347
- writeFileSync7(planJudgeScriptPath, CC_PLAN_JUDGE_SCRIPT, "utf-8");
4348
- writeFileSync7(stopSummaryScriptPath, CC_STOP_SUMMARY_SCRIPT, "utf-8");
4349
- writeFileSync7(sessionStartScriptPath, CC_SESSION_START_SCRIPT, "utf-8");
4350
- writeFileSync7(transcriptSyncScriptPath, CC_TRANSCRIPT_SYNC_SCRIPT, "utf-8");
4351
- writeFileSync7(userPromptSubmitScriptPath, CC_USER_PROMPT_SUBMIT_SCRIPT, "utf-8");
4352
- writeFileSync7(commonScriptPath, SYNKRO_COMMON_SCRIPT, "utf-8");
4961
+ writeFileSync7(bashScriptPath, BASH_JUDGE_TS, "utf-8");
4962
+ writeFileSync7(bashFollowupScriptPath, BASH_FOLLOWUP_TS, "utf-8");
4963
+ writeFileSync7(editPrecheckScriptPath, EDIT_PRECHECK_TS, "utf-8");
4964
+ writeFileSync7(cwePrecheckScriptPath, CWE_PRECHECK_TS, "utf-8");
4965
+ writeFileSync7(cvePrecheckScriptPath, CVE_PRECHECK_TS, "utf-8");
4966
+ writeFileSync7(planJudgeScriptPath, PLAN_JUDGE_TS, "utf-8");
4967
+ writeFileSync7(stopSummaryScriptPath, STOP_SUMMARY_TS, "utf-8");
4968
+ writeFileSync7(sessionStartScriptPath, SESSION_START_TS, "utf-8");
4969
+ writeFileSync7(transcriptSyncScriptPath, TRANSCRIPT_SYNC_TS, "utf-8");
4970
+ writeFileSync7(userPromptSubmitScriptPath, USER_PROMPT_SUBMIT_TS, "utf-8");
4971
+ writeFileSync7(commonScriptPath, SYNKRO_COMMON_TS, "utf-8");
4972
+ writeFileSync7(commonBashScriptPath, SYNKRO_COMMON_SCRIPT, "utf-8");
4353
4973
  writeFileSync7(cursorBashJudgePath, CURSOR_BASH_JUDGE_SCRIPT, "utf-8");
4354
4974
  writeFileSync7(cursorEditPrecheckPath, CURSOR_EDIT_PRECHECK_SCRIPT, "utf-8");
4355
4975
  writeFileSync7(cursorEditCapturePath, CURSOR_EDIT_CAPTURE_SCRIPT, "utf-8");
4356
4976
  writeFileSync7(cursorBashFollowupPath, CURSOR_BASH_FOLLOWUP_SCRIPT, "utf-8");
4357
4977
  chmodSync2(bashScriptPath, 493);
4358
4978
  chmodSync2(bashFollowupScriptPath, 493);
4359
- chmodSync2(editCaptureScriptPath, 493);
4360
4979
  chmodSync2(editPrecheckScriptPath, 493);
4361
- chmodSync2(cveScanScriptPath, 493);
4362
- chmodSync2(cweScanScriptPath, 493);
4980
+ chmodSync2(cwePrecheckScriptPath, 493);
4981
+ chmodSync2(cvePrecheckScriptPath, 493);
4363
4982
  chmodSync2(planJudgeScriptPath, 493);
4364
4983
  chmodSync2(stopSummaryScriptPath, 493);
4365
4984
  chmodSync2(sessionStartScriptPath, 493);
4366
4985
  chmodSync2(transcriptSyncScriptPath, 493);
4367
4986
  chmodSync2(userPromptSubmitScriptPath, 493);
4368
4987
  chmodSync2(commonScriptPath, 493);
4988
+ chmodSync2(commonBashScriptPath, 493);
4369
4989
  chmodSync2(cursorBashJudgePath, 493);
4370
4990
  chmodSync2(cursorEditPrecheckPath, 493);
4371
4991
  chmodSync2(cursorEditCapturePath, 493);
@@ -4373,10 +4993,9 @@ function writeHookScripts() {
4373
4993
  return {
4374
4994
  bashScript: bashScriptPath,
4375
4995
  bashFollowupScript: bashFollowupScriptPath,
4376
- editCaptureScript: editCaptureScriptPath,
4377
4996
  editPrecheckScript: editPrecheckScriptPath,
4378
- cveScanScript: cveScanScriptPath,
4379
- cweScanScript: cweScanScriptPath,
4997
+ cwePrecheckScript: cwePrecheckScriptPath,
4998
+ cvePrecheckScript: cvePrecheckScriptPath,
4380
4999
  planJudgeScript: planJudgeScriptPath,
4381
5000
  stopSummaryScript: stopSummaryScriptPath,
4382
5001
  sessionStartScript: sessionStartScriptPath,
@@ -4417,7 +5036,7 @@ function writeConfigEnv(opts) {
4417
5036
  `SYNKRO_CREDENTIALS_PATH=${shellQuoteSingle(credsPath)}`,
4418
5037
  `SYNKRO_TIER=${shellQuoteSingle(safeTier)}`,
4419
5038
  `SYNKRO_INFERENCE=${shellQuoteSingle(safeInference)}`,
4420
- `SYNKRO_VERSION=${shellQuoteSingle("1.4.45")}`
5039
+ `SYNKRO_VERSION=${shellQuoteSingle("1.4.48")}`
4421
5040
  ];
4422
5041
  if (safeSynkroBin) lines.push(`SYNKRO_CLI_BIN=${shellQuoteSingle(safeSynkroBin)}`);
4423
5042
  if (safeUserId) lines.push(`SYNKRO_USER_ID=${shellQuoteSingle(safeUserId)}`);
@@ -4542,13 +5161,14 @@ function assertGatewayAllowed(gatewayUrl) {
4542
5161
  }
4543
5162
  function isAlreadyInstalled() {
4544
5163
  const requiredScripts = [
4545
- join11(HOOKS_DIR, "cc-bash-judge.sh"),
4546
- join11(HOOKS_DIR, "cc-bash-followup.sh"),
4547
- join11(HOOKS_DIR, "cc-edit-precheck.sh"),
4548
- join11(HOOKS_DIR, "cc-edit-capture.sh"),
4549
- join11(HOOKS_DIR, "cc-plan-judge.sh"),
4550
- join11(HOOKS_DIR, "cc-stop-summary.sh"),
4551
- join11(HOOKS_DIR, "cc-session-start.sh")
5164
+ join11(HOOKS_DIR, "cc-bash-judge.ts"),
5165
+ join11(HOOKS_DIR, "cc-bash-followup.ts"),
5166
+ join11(HOOKS_DIR, "cc-edit-precheck.ts"),
5167
+ join11(HOOKS_DIR, "cc-cwe-precheck.ts"),
5168
+ join11(HOOKS_DIR, "cc-cve-precheck.ts"),
5169
+ join11(HOOKS_DIR, "cc-plan-judge.ts"),
5170
+ join11(HOOKS_DIR, "cc-stop-summary.ts"),
5171
+ join11(HOOKS_DIR, "cc-session-start.ts")
4552
5172
  ];
4553
5173
  if (!requiredScripts.every((p) => existsSync11(p))) return false;
4554
5174
  if (!existsSync11(CONFIG_PATH3)) return false;
@@ -4568,6 +5188,35 @@ function isAlreadyInstalled() {
4568
5188
  }
4569
5189
  return true;
4570
5190
  }
5191
+ function printChannelDiagnostics() {
5192
+ try {
5193
+ const tmuxCheck = spawnSync3("tmux", ["has-session", "-t", "synkro-local-cc"], { encoding: "utf-8" });
5194
+ console.warn(` tmux session: ${tmuxCheck.status === 0 ? "running" : "not running"}`);
5195
+ const pueueTask = findTask();
5196
+ console.warn(` pueue task: ${pueueTask ? `id=${pueueTask.id} status=${pueueTask.status}` : "not found"}`);
5197
+ const bunCheck = spawnSync3("bun", ["--version"], { encoding: "utf-8" });
5198
+ console.warn(` bun: ${bunCheck.status === 0 ? bunCheck.stdout.trim() : "not found"}`);
5199
+ const claudeCheck = spawnSync3("claude", ["--version"], { encoding: "utf-8" });
5200
+ console.warn(` claude: ${claudeCheck.status === 0 ? claudeCheck.stdout.trim().split("\n")[0] : "not found"}`);
5201
+ if (pueueTask) {
5202
+ const logs = tailLogs(15);
5203
+ if (logs && logs !== "(no output)") {
5204
+ console.warn(` pueue logs (last 15 lines):`);
5205
+ for (const line of logs.split("\n").slice(0, 15)) {
5206
+ console.warn(` ${line}`);
5207
+ }
5208
+ }
5209
+ }
5210
+ const logPath = join11(homedir10(), ".synkro", "cc_sessions", "run-claude.log");
5211
+ if (existsSync11(logPath)) {
5212
+ const logContent = readFileSync10(logPath, "utf-8").trim().split("\n").slice(-10);
5213
+ console.warn(` run-claude.log:`);
5214
+ for (const line of logContent) console.warn(` ${line}`);
5215
+ }
5216
+ } catch {
5217
+ }
5218
+ console.warn(` Run \`synkro local-cc status\` and \`synkro local-cc logs --tmux\` to debug.`);
5219
+ }
4571
5220
  async function installCommand(opts = {}) {
4572
5221
  const gatewayUrl = opts.gatewayUrl || sanitizeGatewayCandidate(process.env.SYNKRO_GATEWAY_URL) || "https://api.synkro.sh";
4573
5222
  try {
@@ -4605,17 +5254,18 @@ async function installCommand(opts = {}) {
4605
5254
  assertPueueInstalled();
4606
5255
  assertTmuxInstalled();
4607
5256
  installLocalCC();
4608
- const t = ensureRunning();
4609
- console.log(` pueue task: id=${t.id} status=${t.status}`);
4610
- console.log(" Waiting for channel...");
4611
- const ready = await waitForChannelReady(CHANNEL_PORT, 6e4, CHANNEL_HOST);
4612
- if (ready) console.log(` channel ready at ${CHANNEL_HOST}:${CHANNEL_PORT}`);
4613
- else console.warn(" \u26A0 channel did not come up within 60s \u2014 check `synkro local-cc logs`");
5257
+ const t1 = ensureRunning();
4614
5258
  const t2 = ensureRunning({ channel: CHANNEL_SECONDARY });
4615
- console.log(` CWE channel: id=${t2.id} status=${t2.status}`);
4616
- const ready2 = await waitForChannelReady(CHANNEL_2_PORT, 6e4, CHANNEL_HOST, CHANNEL_SECONDARY.tmuxSession);
4617
- if (ready2) console.log(` CWE channel ready at ${CHANNEL_HOST}:${CHANNEL_2_PORT}`);
4618
- else console.warn(" \u26A0 CWE channel did not come up within 60s");
5259
+ console.log(` channel 1: pueue id=${t1.id} channel 2: pueue id=${t2.id}`);
5260
+ console.log(" Waiting for both channels...");
5261
+ const [ready1, ready2] = await Promise.all([
5262
+ waitForChannelReady(CHANNEL_PORT, 6e4, CHANNEL_HOST),
5263
+ waitForChannelReady(CHANNEL_2_PORT, 6e4, CHANNEL_HOST, CHANNEL_SECONDARY.tmuxSession)
5264
+ ]);
5265
+ if (ready1) console.log(` channel 1 ready at ${CHANNEL_HOST}:${CHANNEL_PORT}`);
5266
+ else console.warn(" \u26A0 channel 1 did not come up within 60s \u2014 check `synkro local-cc logs`");
5267
+ if (ready2) console.log(` channel 2 ready at ${CHANNEL_HOST}:${CHANNEL_2_PORT}`);
5268
+ else console.warn(" \u26A0 channel 2 did not come up within 60s");
4619
5269
  updateLocalInferenceFlag(true);
4620
5270
  } catch (err) {
4621
5271
  console.warn(` \u26A0 Local-CC setup skipped: ${err.message}`);
@@ -4688,7 +5338,6 @@ async function installCommand(opts = {}) {
4688
5338
  console.log("Wrote hook scripts:");
4689
5339
  console.log(` ${scripts.bashScript}`);
4690
5340
  console.log(` ${scripts.bashFollowupScript}`);
4691
- console.log(` ${scripts.editCaptureScript}`);
4692
5341
  console.log(` ${scripts.editPrecheckScript}`);
4693
5342
  console.log(` ${scripts.planJudgeScript}`);
4694
5343
  console.log(` ${scripts.stopSummaryScript}`);
@@ -4723,10 +5372,9 @@ async function installCommand(opts = {}) {
4723
5372
  installCCHooks(agent.settingsPath, {
4724
5373
  bashJudgeScriptPath: scripts.bashScript,
4725
5374
  bashFollowupScriptPath: scripts.bashFollowupScript,
4726
- editCaptureScriptPath: scripts.editCaptureScript,
4727
5375
  editPrecheckScriptPath: scripts.editPrecheckScript,
4728
- cveScanScriptPath: scripts.cveScanScript,
4729
- cweScanScriptPath: scripts.cweScanScript,
5376
+ cwePrecheckScriptPath: scripts.cwePrecheckScript,
5377
+ cvePrecheckScriptPath: scripts.cvePrecheckScript,
4730
5378
  planJudgeScriptPath: scripts.planJudgeScript,
4731
5379
  stopSummaryScriptPath: scripts.stopSummaryScript,
4732
5380
  sessionStartScriptPath: scripts.sessionStartScript,
@@ -4825,72 +5473,62 @@ async function installCommand(opts = {}) {
4825
5473
  console.warn(` \u26A0 Some dependencies missing \u2014 \`synkro local-cc enable\` may not work until they're installed.`);
4826
5474
  }
4827
5475
  console.log();
4828
- if (profile.localInference) {
5476
+ const priorLocalFlag = (() => {
5477
+ try {
5478
+ const content = readFileSync10(CONFIG_PATH3, "utf-8");
5479
+ return content.includes("SYNKRO_LOCAL_INFERENCE='yes'");
5480
+ } catch {
5481
+ return false;
5482
+ }
5483
+ })();
5484
+ if (profile.localInference || priorLocalFlag && localCcDeps.length === 3) {
4829
5485
  try {
4830
5486
  const r = installLocalCC();
4831
5487
  console.log(`Installed local-CC channel plugin at ${r.pluginPath}`);
4832
- const t = ensureRunning();
4833
- console.log(`Local-CC pueue task: id=${t.id} status=${t.status}`);
4834
- console.log("Waiting for channel (up to 60s)...");
4835
- const ready = await waitForChannelReady(CHANNEL_PORT, 6e4, CHANNEL_HOST);
4836
- if (ready) {
4837
- console.log(` channel ready at ${CHANNEL_HOST}:${CHANNEL_PORT}`);
4838
- try {
4839
- console.log(" warming up inference...");
4840
- await submitToChannel("grade-bash", "Proposed command: echo hello\nUser intent: warmup\nRecent user messages: []\nRecent actions: []\nOrg rules: []\n", { timeoutMs: 3e4 });
4841
- console.log(" inference warm\n");
4842
- } catch {
4843
- console.log(" warmup skipped (non-fatal)\n");
4844
- }
5488
+ const t1 = ensureRunning();
5489
+ const t2 = ensureRunning({ channel: CHANNEL_SECONDARY });
5490
+ console.log(`Channel 1 (org rules): pueue id=${t1.id} status=${t1.status}`);
5491
+ console.log(`Channel 2 (CWE scan): pueue id=${t2.id} status=${t2.status}`);
5492
+ console.log("Waiting for both channels (up to 60s)...");
5493
+ const [ready1, ready2] = await Promise.all([
5494
+ waitForChannelReady(CHANNEL_PORT, 6e4, CHANNEL_HOST),
5495
+ waitForChannelReady(CHANNEL_2_PORT, 6e4, CHANNEL_HOST, CHANNEL_SECONDARY.tmuxSession)
5496
+ ]);
5497
+ if (ready1) {
5498
+ console.log(` channel 1 ready at ${CHANNEL_HOST}:${CHANNEL_PORT}`);
4845
5499
  } else {
4846
- console.warn(` \u26A0 Channel did not come up within 60s.`);
4847
- try {
4848
- const { spawnSync: sp } = await import("child_process");
4849
- const tmuxCheck = sp("tmux", ["has-session", "-t", "synkro-local-cc"], { encoding: "utf-8" });
4850
- console.warn(` tmux session: ${tmuxCheck.status === 0 ? "running" : "not running"}`);
4851
- const pueueTask = findTask();
4852
- console.warn(` pueue task: ${pueueTask ? `id=${pueueTask.id} status=${pueueTask.status}` : "not found"}`);
4853
- const bunCheck = sp("bun", ["--version"], { encoding: "utf-8" });
4854
- console.warn(` bun: ${bunCheck.status === 0 ? bunCheck.stdout.trim() : "not found"}`);
4855
- const claudeCheck = sp("claude", ["--version"], { encoding: "utf-8" });
4856
- console.warn(` claude: ${claudeCheck.status === 0 ? claudeCheck.stdout.trim().split("\n")[0] : "not found"}`);
4857
- if (pueueTask) {
4858
- const logs = tailLogs(15);
4859
- if (logs && logs !== "(no output)") {
4860
- console.warn(` pueue logs (last 15 lines):`);
4861
- for (const line of logs.split("\n").slice(0, 15)) {
4862
- console.warn(` ${line}`);
4863
- }
4864
- }
4865
- }
4866
- const logPath = join11(homedir10(), ".synkro", "cc_sessions", "run-claude.log");
4867
- if (existsSync11(logPath)) {
4868
- const logContent = readFileSync10(logPath, "utf-8").trim().split("\n").slice(-10);
4869
- console.warn(` run-claude.log:`);
4870
- for (const line of logContent) console.warn(` ${line}`);
4871
- }
4872
- } catch {
4873
- }
4874
- console.warn(` Run \`synkro local-cc status\` and \`synkro local-cc logs --tmux\` to debug.
4875
- `);
5500
+ console.warn(` \u26A0 Channel 1 did not come up within 60s.`);
5501
+ printChannelDiagnostics();
4876
5502
  }
4877
- const t2 = ensureRunning({ channel: CHANNEL_SECONDARY });
4878
- console.log(`Local-CC CWE channel: id=${t2.id} status=${t2.status}`);
4879
- console.log("Waiting for CWE channel (up to 60s)...");
4880
- const ready2 = await waitForChannelReady(CHANNEL_2_PORT, 6e4, CHANNEL_HOST, CHANNEL_SECONDARY.tmuxSession);
4881
5503
  if (ready2) {
4882
- console.log(` CWE channel ready at ${CHANNEL_HOST}:${CHANNEL_2_PORT}`);
4883
- try {
4884
- console.log(" warming up CWE inference...");
4885
- await submitToChannel("grade-cwe", 'File: /tmp/warmup.ts\nContent (first 4000 chars):\nconsole.log("hello");\n\nCWE rules to check against:\n[]\n', { timeoutMs: 3e4, port: CHANNEL_2_PORT });
4886
- console.log(" CWE inference warm\n");
4887
- } catch {
4888
- console.log(" CWE warmup skipped (non-fatal)\n");
4889
- }
5504
+ console.log(` channel 2 ready at ${CHANNEL_HOST}:${CHANNEL_2_PORT}`);
4890
5505
  } else {
4891
- console.warn(` \u26A0 CWE channel did not come up within 60s.`);
4892
- console.warn(` Run \`synkro local-cc status\` to debug.
4893
- `);
5506
+ console.warn(` \u26A0 Channel 2 did not come up within 60s.`);
5507
+ console.warn(` Run \`synkro local-cc status\` to debug.`);
5508
+ }
5509
+ if (ready1 || ready2) {
5510
+ console.log("Warming up inference...");
5511
+ const warmups = [];
5512
+ if (ready1) {
5513
+ warmups.push(
5514
+ submitToChannel("grade-bash", "Proposed command: echo hello\nUser intent: warmup\nRecent user messages: []\nRecent actions: []\nOrg rules: []\n", { timeoutMs: 3e4 }).then(() => {
5515
+ console.log(" channel 1 warm");
5516
+ }).catch(() => {
5517
+ console.log(" channel 1 warmup skipped (non-fatal)");
5518
+ })
5519
+ );
5520
+ }
5521
+ if (ready2) {
5522
+ warmups.push(
5523
+ submitToChannel("grade-cwe", 'File: /tmp/warmup.ts\nContent (first 4000 chars):\nconsole.log("hello");\n\nCWE rules to check against:\n[]\n', { timeoutMs: 3e4, port: CHANNEL_2_PORT }).then(() => {
5524
+ console.log(" channel 2 warm");
5525
+ }).catch(() => {
5526
+ console.log(" channel 2 warmup skipped (non-fatal)");
5527
+ })
5528
+ );
5529
+ }
5530
+ await Promise.all(warmups);
5531
+ console.log();
4894
5532
  }
4895
5533
  } catch (err) {
4896
5534
  console.warn(` \u26A0 Local-CC setup failed: ${err.message}
@@ -5131,6 +5769,7 @@ var init_install2 = __esm({
5131
5769
  init_cursorHookConfig();
5132
5770
  init_mcpConfig();
5133
5771
  init_hookScripts();
5772
+ init_hookScriptsTs();
5134
5773
  init_stub();
5135
5774
  init_repoConnect();
5136
5775
  init_projects();
@@ -6385,7 +7024,7 @@ var localCc_exports = {};
6385
7024
  __export(localCc_exports, {
6386
7025
  localCcCommand: () => localCcCommand
6387
7026
  });
6388
- import { spawnSync as spawnSync3 } from "child_process";
7027
+ import { spawnSync as spawnSync4 } from "child_process";
6389
7028
  import { homedir as homedir14 } from "os";
6390
7029
  import { join as join16 } from "path";
6391
7030
  import { existsSync as existsSync16, readFileSync as readFileSync14, writeFileSync as writeFileSync9 } from "fs";
@@ -6529,7 +7168,7 @@ async function cmdStatus() {
6529
7168
  }
6530
7169
  const ch1Up = await isChannelAvailable();
6531
7170
  console.log(`Channel 1 ${CHANNEL_HOST}:${CHANNEL_PORT}: ${ch1Up ? "reachable" : "unreachable"}`);
6532
- const tmux1 = spawnSync3("tmux", ["has-session", "-t", TMUX_SESSION_NAME], { encoding: "utf-8" });
7171
+ const tmux1 = spawnSync4("tmux", ["has-session", "-t", TMUX_SESSION_NAME], { encoding: "utf-8" });
6533
7172
  console.log(`tmux '${TMUX_SESSION_NAME}': ${tmux1.status === 0 ? "live" : "absent"}`);
6534
7173
  const t2 = findTask(CHANNEL_SECONDARY);
6535
7174
  if (!t2) {
@@ -6539,7 +7178,7 @@ async function cmdStatus() {
6539
7178
  }
6540
7179
  const ch2Up = await isChannelAvailable(CHANNEL_2_PORT);
6541
7180
  console.log(`Channel 2 ${CHANNEL_HOST}:${CHANNEL_2_PORT}: ${ch2Up ? "reachable" : "unreachable"}`);
6542
- const tmux2 = spawnSync3("tmux", ["has-session", "-t", TMUX_SESSION_NAME_2], { encoding: "utf-8" });
7181
+ const tmux2 = spawnSync4("tmux", ["has-session", "-t", TMUX_SESSION_NAME_2], { encoding: "utf-8" });
6543
7182
  console.log(`tmux '${TMUX_SESSION_NAME_2}': ${tmux2.status === 0 ? "live" : "absent"}`);
6544
7183
  }
6545
7184
  async function cmdEnable() {
@@ -6731,7 +7370,7 @@ function cmdLogs(rest) {
6731
7370
  function cmdAttach(rest) {
6732
7371
  assertTmuxInstalled();
6733
7372
  const readonly = rest.some((a) => a === "--readonly" || a === "-r");
6734
- const has = spawnSync3("tmux", ["has-session", "-t", TMUX_SESSION_NAME], { encoding: "utf-8" });
7373
+ const has = spawnSync4("tmux", ["has-session", "-t", TMUX_SESSION_NAME], { encoding: "utf-8" });
6735
7374
  if (has.status !== 0) {
6736
7375
  console.error(`No tmux session '${TMUX_SESSION_NAME}' running. Start it with: synkro local-cc start`);
6737
7376
  process.exit(1);
@@ -6744,7 +7383,7 @@ function cmdAttach(rest) {
6744
7383
  console.log("Detach with Ctrl-B then D. (Do not press Ctrl-C \u2014 that would interrupt claude.)");
6745
7384
  console.log();
6746
7385
  const args2 = readonly ? ["attach-session", "-r", "-t", TMUX_SESSION_NAME] : ["attach-session", "-t", TMUX_SESSION_NAME];
6747
- const r = spawnSync3("tmux", args2, { stdio: "inherit" });
7386
+ const r = spawnSync4("tmux", args2, { stdio: "inherit" });
6748
7387
  process.exit(r.status ?? 0);
6749
7388
  }
6750
7389
  async function cmdTest() {