@synkro-sh/cli 1.4.43 → 1.4.47

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
@@ -121,11 +121,13 @@ function installCCHooks(settingsPath, config) {
121
121
  removeSynkroEntries(settings.hooks, "PostToolUse");
122
122
  removeSynkroEntries(settings.hooks, "SessionEnd");
123
123
  removeSynkroEntries(settings.hooks, "SessionStart");
124
+ removeSynkroEntries(settings.hooks, "UserPromptSubmit");
124
125
  removeSynkroEntries(settings.hooks, "Stop");
125
126
  settings.hooks.PreToolUse = settings.hooks.PreToolUse ?? [];
126
127
  settings.hooks.PostToolUse = settings.hooks.PostToolUse ?? [];
127
128
  settings.hooks.SessionEnd = settings.hooks.SessionEnd ?? [];
128
129
  settings.hooks.SessionStart = settings.hooks.SessionStart ?? [];
130
+ settings.hooks.UserPromptSubmit = settings.hooks.UserPromptSubmit ?? [];
129
131
  settings.hooks.PreToolUse.push({
130
132
  matcher: "Bash|Read|Grep|Glob",
131
133
  hooks: [
@@ -143,40 +145,40 @@ function installCCHooks(settingsPath, config) {
143
145
  {
144
146
  type: "command",
145
147
  command: config.editPrecheckScriptPath,
146
- timeout: 15
148
+ timeout: 30
147
149
  }
148
150
  ],
149
151
  [SYNKRO_MARKER]: true
150
152
  });
151
153
  settings.hooks.PreToolUse.push({
152
- matcher: "ExitPlanMode",
154
+ matcher: "Edit|Write|MultiEdit|NotebookEdit",
153
155
  hooks: [
154
156
  {
155
157
  type: "command",
156
- command: config.planJudgeScriptPath,
157
- timeout: 45
158
+ command: config.cwePrecheckScriptPath,
159
+ timeout: 30
158
160
  }
159
161
  ],
160
162
  [SYNKRO_MARKER]: true
161
163
  });
162
- settings.hooks.PostToolUse.push({
164
+ settings.hooks.PreToolUse.push({
163
165
  matcher: "Edit|Write|MultiEdit|NotebookEdit",
164
166
  hooks: [
165
167
  {
166
168
  type: "command",
167
- command: config.editCaptureScriptPath,
168
- timeout: 20
169
+ command: config.cvePrecheckScriptPath,
170
+ timeout: 10
169
171
  }
170
172
  ],
171
173
  [SYNKRO_MARKER]: true
172
174
  });
173
- settings.hooks.PostToolUse.push({
174
- matcher: "Edit|Write|MultiEdit|NotebookEdit",
175
+ settings.hooks.PreToolUse.push({
176
+ matcher: "ExitPlanMode",
175
177
  hooks: [
176
178
  {
177
179
  type: "command",
178
- command: config.cveScanScriptPath,
179
- timeout: 10
180
+ command: config.planJudgeScriptPath,
181
+ timeout: 45
180
182
  }
181
183
  ],
182
184
  [SYNKRO_MARKER]: true
@@ -209,6 +211,16 @@ function installCCHooks(settingsPath, config) {
209
211
  ],
210
212
  [SYNKRO_MARKER]: true
211
213
  });
214
+ settings.hooks.UserPromptSubmit.push({
215
+ hooks: [
216
+ {
217
+ type: "command",
218
+ command: config.userPromptSubmitScriptPath,
219
+ timeout: 5
220
+ }
221
+ ],
222
+ [SYNKRO_MARKER]: true
223
+ });
212
224
  settings.hooks.Stop = settings.hooks.Stop ?? [];
213
225
  removeSynkroEntries(settings.hooks, "Stop");
214
226
  settings.hooks.Stop.push({
@@ -227,7 +239,7 @@ function uninstallCCHooks(settingsPath) {
227
239
  if (!existsSync2(settingsPath)) return false;
228
240
  const settings = readSettings(settingsPath);
229
241
  if (!settings.hooks) return false;
230
- const events = ["PreToolUse", "PostToolUse", "SessionEnd", "SessionStart", "Stop"];
242
+ const events = ["PreToolUse", "PostToolUse", "SessionEnd", "SessionStart", "Stop", "UserPromptSubmit"];
231
243
  for (const evt of events) {
232
244
  removeSynkroEntries(settings.hooks, evt);
233
245
  }
@@ -452,7 +464,7 @@ var init_mcpConfig = __esm({
452
464
  });
453
465
 
454
466
  // cli/installer/hookScripts.ts
455
- 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_TRANSCRIPT_SYNC_SCRIPT, CURSOR_BASH_JUDGE_SCRIPT, CURSOR_EDIT_PRECHECK_SCRIPT, CURSOR_EDIT_CAPTURE_SCRIPT, CURSOR_BASH_FOLLOWUP_SCRIPT;
467
+ var SYNKRO_COMMON_SCRIPT, CURSOR_BASH_JUDGE_SCRIPT, CURSOR_EDIT_PRECHECK_SCRIPT, CURSOR_EDIT_CAPTURE_SCRIPT, CURSOR_BASH_FOLLOWUP_SCRIPT;
456
468
  var init_hookScripts = __esm({
457
469
  "cli/installer/hookScripts.ts"() {
458
470
  "use strict";
@@ -476,6 +488,25 @@ synkro_load_jwt() {
476
488
  }
477
489
 
478
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
+
479
510
  local rt
480
511
  rt=$(jq -r '.refresh_token // empty' "$CREDS_PATH" 2>/dev/null)
481
512
  if [ -z "$rt" ]; then return 1; fi
@@ -491,7 +522,12 @@ synkro_refresh_jwt() {
491
522
  new_rt=$(echo "$resp" | jq -r '.refresh_token // empty' 2>/dev/null)
492
523
  [ -z "$new_rt" ] && new_rt="$rt"
493
524
  local tmp="\${CREDS_PATH}.synkro.tmp"
494
- 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"
495
531
  JWT="$new_at"
496
532
  }
497
533
 
@@ -531,8 +567,6 @@ synkro_load_config() {
531
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 "[]")
532
568
  }
533
569
 
534
- # Build the tag prefix: [synkro:route:ruleset] or [synkro:silent]
535
- # Accepts optional $1 = route override; otherwise calls synkro_route().
536
570
  synkro_tag() {
537
571
  if [ "$SYNKRO_SILENT" = "true" ]; then echo "[synkro:silent]"; return; fi
538
572
  local route="\${1:-$(synkro_route)}"
@@ -540,131 +574,12 @@ synkro_tag() {
540
574
  echo "[synkro:\${route}:\${rs}]"
541
575
  }
542
576
 
543
- # Decide routing: "local" (grade on device) or "cloud" (POST to server)
544
577
  synkro_route() {
545
578
  [ "$SYNKRO_CAPTURE_DEPTH" = "local_only" ] && echo "local" && return
546
579
  synkro_channel_up && echo "local" && return
547
580
  echo "cloud"
548
581
  }
549
582
 
550
- # Grade locally via synkro CLI channel. Reads prompt from stdin.
551
- synkro_local_grade() {
552
- local surface="$1"
553
- if ! synkro_channel_up; then
554
- echo "SYNKRO_CHANNEL_DOWN" >&2
555
- return 1
556
- fi
557
- if [ -n "\${SYNKRO_CLI_BIN:-}" ] && [ -f "$SYNKRO_CLI_BIN" ] && command -v node >/dev/null 2>&1; then
558
- node "$SYNKRO_CLI_BIN" grade "$surface" 2>/dev/null
559
- elif command -v synkro >/dev/null 2>&1; then
560
- synkro grade "$surface" 2>/dev/null
561
- else
562
- echo "SYNKRO_CLI_NOT_FOUND" >&2
563
- return 1
564
- fi
565
- }
566
-
567
- # Parse <synkro-verdict>...</synkro-verdict> XML from local grader output.
568
- # Sets LOCAL_OK, LOCAL_REASON, LOCAL_RULE_ID, LOCAL_RULE_MODE, LOCAL_SEV, LOCAL_CAT.
569
- synkro_parse_local_verdict() {
570
- local resp="$1"
571
- LOCAL_OK="true"; LOCAL_REASON=""; LOCAL_RULE_ID=""; LOCAL_RULE_MODE=""; LOCAL_SEV="low"; LOCAL_CAT="clean"
572
- local inner
573
- inner=$(printf '%s' "$resp" | tr '\\n' ' ' | sed -nE 's|.*<synkro-verdict>(.*)</synkro-verdict>.*|\\1|p' | tail -1)
574
- [ -z "$inner" ] && return
575
- local ok_tag
576
- ok_tag=$(printf '%s' "$inner" | sed -nE 's|.*<ok>(.*)</ok>.*|\\1|p' | head -1)
577
- [ -n "$ok_tag" ] && LOCAL_OK="$ok_tag"
578
- LOCAL_REASON=$(printf '%s' "$inner" | sed -nE 's|.*<reason>(.*)</reason>.*|\\1|p' | head -1)
579
- [ -z "$LOCAL_REASON" ] && LOCAL_REASON=$(printf '%s' "$inner" | sed -nE 's|.*<reasoning>(.*)</reasoning>.*|\\1|p' | head -1)
580
- if [ "$LOCAL_OK" = "false" ]; then
581
- LOCAL_RULE_ID=$(printf '%s' "$inner" | sed -nE 's|.*<rule_id>(.*)</rule_id>.*|\\1|p' | head -1)
582
- LOCAL_RULE_MODE=$(printf '%s' "$inner" | sed -nE 's|.*<rule_mode>(.*)</rule_mode>.*|\\1|p' | head -1)
583
- LOCAL_SEV=$(printf '%s' "$inner" | sed -nE 's|.*<risk_level>(.*)</risk_level>.*|\\1|p' | head -1)
584
- if [ -z "$LOCAL_RULE_ID" ]; then
585
- local fv
586
- fv=$(printf '%s' "$inner" | awk -v RS='</violation>' '/<violation>/{print; exit}')
587
- LOCAL_RULE_ID=$(printf '%s' "$fv" | sed -nE 's|.*<rule_id>(.*)</rule_id>.*|\\1|p' | head -1)
588
- [ -z "$LOCAL_REASON" ] && LOCAL_REASON=$(printf '%s' "$fv" | sed -nE 's|.*<reason>(.*)</reason>.*|\\1|p' | head -1)
589
- [ -z "$LOCAL_SEV" ] && LOCAL_SEV=$(printf '%s' "$fv" | sed -nE 's|.*<severity>(.*)</severity>.*|\\1|p' | head -1)
590
- fi
591
- LOCAL_SEV="\${LOCAL_SEV:-high}"
592
- LOCAL_CAT=$(printf '%s' "$inner" | sed -nE 's|.*<category>(.*)</category>.*|\\1|p' | head -1)
593
- LOCAL_CAT="\${LOCAL_CAT:-uncategorized}"
594
- [ -z "$LOCAL_RULE_ID" ] && LOCAL_RULE_ID=$(printf '%s' "$LOCAL_REASON" | grep -oE '[Rr][0-9]{3}' | head -1)
595
- fi
596
- }
597
-
598
- # Fire anonymized telemetry for local verdicts. All args positional.
599
- synkro_capture_local() {
600
- local hook_type="$1" verdict="$2" severity="$3" category="$4" tool_name="$5" repo="$6" session_id="$7"
601
- (
602
- BODY=$(jq -n \\
603
- --arg eid "$(uuidgen 2>/dev/null || echo "evt_$(date +%s)_$$")" \\
604
- --arg ht "$hook_type" --arg v "$verdict" --arg s "$severity" --arg c "$category" \\
605
- --arg tn "$tool_name" --arg r "$repo" --arg sid "$session_id" \\
606
- '{capture_type:"local_verdict",event_id:$eid,hook_type:$ht,verdict:$v,severity:$s,category:$c,model:"claude-sonnet-4-6",tool_name:$tn}
607
- + (if $r != "" then {repo:$r} else {} end)
608
- + (if $sid != "" then {session_id:$sid} else {} end)')
609
- curl -sS -X POST "\${GATEWAY_URL}/api/v1/hook/capture" \\
610
- -H "Content-Type: application/json" -H "Authorization: Bearer $JWT" \\
611
- -d "$BODY" --max-time 2 >/dev/null 2>&1
612
- ) &
613
- }
614
-
615
- # Fire full-content telemetry for local verdicts (used when capture_depth is full or evidence_on_violation).
616
- synkro_capture_local_full() {
617
- local hook_type="$1" verdict="$2" severity="$3" category="$4" tool_name="$5" repo="$6" session_id="$7"
618
- local command="$8" reasoning="$9" rules_checked="\${10:-[]}" violated_rules="\${11:-[]}" recent_user_messages="\${12:-[]}"
619
- (
620
- BODY=$(jq -n \\
621
- --arg eid "$(uuidgen 2>/dev/null || echo "evt_$(date +%s)_$$")" \\
622
- --arg ht "$hook_type" --arg v "$verdict" --arg s "$severity" --arg c "$category" \\
623
- --arg tn "$tool_name" --arg r "$repo" --arg sid "$session_id" \\
624
- --arg cmd "$command" --arg rsn "$reasoning" --arg cd "$SYNKRO_CAPTURE_DEPTH" \\
625
- --argjson rc "$rules_checked" --argjson vr "$violated_rules" --argjson rum "$recent_user_messages" \\
626
- '{capture_type:"local_verdict",event_id:$eid,hook_type:$ht,verdict:$v,severity:$s,category:$c,
627
- model:"claude-sonnet-4-6",tool_name:$tn,capture_depth:$cd,
628
- command:(if ($cmd|length) > 0 then $cmd else null end),
629
- reasoning:(if ($rsn|length) > 0 then $rsn else null end),
630
- rules_checked:$rc, violated_rules:$vr, recent_user_messages:$rum}
631
- + (if $r != "" then {repo:$r} else {} end)
632
- + (if $sid != "" then {session_id:$sid} else {} end)')
633
- curl -sS -X POST "\${GATEWAY_URL}/api/v1/hook/capture" \\
634
- -H "Content-Type: application/json" -H "Authorization: Bearer $JWT" \\
635
- -d "$BODY" --max-time 3 >/dev/null 2>&1
636
- ) &
637
- }
638
-
639
- # Dispatch local verdict capture based on capture_depth privacy setting.
640
- # For full: always send full content. For evidence_on_violation: full only on violations. For local_only: anonymized only.
641
- synkro_dispatch_capture() {
642
- local hook_type="$1" verdict="$2" severity="$3" category="$4" tool_name="$5" repo="$6" session_id="$7"
643
- local command="$8" reasoning="$9" rules_checked="\${10:-[]}" violated_rules="\${11:-[]}" recent_user_messages="\${12:-[]}"
644
- local send_full=false
645
- case "\${SYNKRO_CAPTURE_DEPTH:-local_only}" in
646
- full) send_full=true ;;
647
- evidence_on_violation)
648
- case "$verdict" in block|warning|deny) send_full=true ;; esac ;;
649
- esac
650
- if [ "$send_full" = "true" ]; then
651
- synkro_capture_local_full "$hook_type" "$verdict" "$severity" "$category" "$tool_name" "$repo" "$session_id" \\
652
- "$command" "$reasoning" "$rules_checked" "$violated_rules" "$recent_user_messages"
653
- else
654
- synkro_capture_local "$hook_type" "$verdict" "$severity" "$category" "$tool_name" "$repo" "$session_id"
655
- fi
656
- }
657
-
658
- # Look up a rule's mode from cached rules. Returns "blocking" or "audit".
659
- synkro_rule_mode() {
660
- local rid="$1"
661
- [ -z "$rid" ] || [ -z "\${SYNKRO_RULES:-}" ] && echo "blocking" && return
662
- local m
663
- 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)
664
- [ -z "$m" ] || [ "$m" = "null" ] && m="blocking"
665
- echo "$m"
666
- }
667
-
668
583
  SYNKRO_CONSENT_FILE="$HOME/.synkro/.local-consent"
669
584
 
670
585
  _TAB=$(printf '\\t')
@@ -688,18 +603,6 @@ synkro_consent_consume() {
688
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
689
604
  }
690
605
 
691
- synkro_consent_has_consumed() {
692
- local sid="$1" hash="$2"
693
- grep -q "^\${sid}\${_TAB}\${hash}\${_TAB}consumed$" "$SYNKRO_CONSENT_FILE" 2>/dev/null
694
- }
695
-
696
- synkro_consent_clear_consumed() {
697
- local sid="$1" hash="$2"
698
- [ ! -f "$SYNKRO_CONSENT_FILE" ] && return
699
- local tmp="\${SYNKRO_CONSENT_FILE}.tmp"
700
- grep -v "^\${sid}\${_TAB}\${hash}\${_TAB}consumed$" "$SYNKRO_CONSENT_FILE" > "$tmp" 2>/dev/null && mv "$tmp" "$SYNKRO_CONSENT_FILE" 2>/dev/null || true
701
- }
702
-
703
606
  synkro_post_with_retry() {
704
607
  local url="$1" body="$2" timeout="\${3:-8}"
705
608
  local resp
@@ -718,7 +621,7 @@ synkro_post_with_retry() {
718
621
  echo "$resp"
719
622
  }
720
623
  `;
721
- CC_BASH_JUDGE_SCRIPT = `#!/bin/bash
624
+ CURSOR_BASH_JUDGE_SCRIPT = `#!/bin/bash
722
625
  SCRIPT_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
723
626
  . "$SCRIPT_DIR/_synkro-common.sh"
724
627
 
@@ -729,156 +632,52 @@ synkro_ensure_fresh_jwt
729
632
  PAYLOAD=$(cat)
730
633
  if [ -z "$PAYLOAD" ]; then echo '{}'; exit 0; fi
731
634
 
732
- TOOL_NAME=$(echo "$PAYLOAD" | jq -r '.tool_name // empty' 2>/dev/null)
733
- 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
734
637
 
735
- SESSION_ID=$(echo "$PAYLOAD" | jq -r '.session_id // empty' 2>/dev/null)
736
- TOOL_USE_ID=$(echo "$PAYLOAD" | jq -r '.tool_use_id // empty' 2>/dev/null)
737
638
  CWD=$(echo "$PAYLOAD" | jq -r '.cwd // empty' 2>/dev/null)
639
+ SESSION_ID=$(echo "$PAYLOAD" | jq -r '.conversation_id // empty' 2>/dev/null)
738
640
  GIT_REPO=$(synkro_detect_repo "\${CWD:-.}")
739
- PERMISSION_MODE=$(echo "$PAYLOAD" | jq -r '.permission_mode // empty' 2>/dev/null)
740
-
741
- # Translate tool calls to command string for logging
742
- case "$TOOL_NAME" in
743
- Bash) COMMAND=$(echo "$PAYLOAD" | jq -r '.tool_input.command // empty' 2>/dev/null) ;;
744
- Read) COMMAND="cat $(echo "$PAYLOAD" | jq -r '.tool_input.file_path // empty' 2>/dev/null)" ;;
745
- 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)" ;;
746
- Glob) COMMAND="find . -name '$(echo "$PAYLOAD" | jq -r '.tool_input.pattern // empty' 2>/dev/null)'" ;;
747
- esac
748
- if [ -z "$COMMAND" ]; then echo '{}'; exit 0; fi
749
641
 
750
642
  CMD_SHORT=$(printf '%s' "$COMMAND" | head -c 80)
751
643
  synkro_log "bashGuard checking: $CMD_SHORT"
752
644
 
753
- TRANSCRIPT_PATH=$(echo "$PAYLOAD" | jq -r '.transcript_path // empty' 2>/dev/null)
754
- USER_INTENT=""
755
- RECENT_USER_MESSAGES="[]"
756
- if [ -n "$TRANSCRIPT_PATH" ] && [ -f "$TRANSCRIPT_PATH" ]; then
757
- 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 "[]")
758
- USER_INTENT=$(echo "$RECENT_USER_MESSAGES" | jq -r '.[-1] // ""' 2>/dev/null || echo "")
759
- fi
760
-
761
- # Headless detection
762
- IS_HEADLESS="\${SYNKRO_HEADLESS:-0}"
763
- case "$PERMISSION_MODE" in acceptEdits|bypassPermissions|plan|auto) IS_HEADLESS="1" ;; esac
764
-
765
645
  synkro_load_config
766
- ROUTE=$(synkro_route)
767
- TAG=$(synkro_tag "$ROUTE")
768
-
769
646
  if [ "$SYNKRO_SILENT" = "true" ]; then
770
- jq -n --arg m "$TAG bashGuard \u2192 skipped (silent mode)" '{systemMessage: $m}'
771
- exit 0
772
- fi
773
-
774
- if [ "$ROUTE" = "local" ]; then
775
- # \u2500\u2500\u2500 Local grading (local_only privacy or local-cc channel) \u2500\u2500\u2500
776
- GRADER_FILE=$(mktemp -t synkro-bash.XXXXXX)
777
- trap "rm -f \\"$GRADER_FILE\\"" EXIT
778
- printf 'Working directory: %s\\nRepo: %s\\nCommand: %s\\nUser intent (last human message): %s\\nOrg rules: %s\\n' "\${CWD:-.}" "\${GIT_REPO:-unknown}" "$COMMAND" "\${USER_INTENT:-none stated}" "\${SYNKRO_RULES:-[]}" > "$GRADER_FILE"
779
-
780
- CC_RESP=$(synkro_local_grade bash < "$GRADER_FILE" 2>&1)
781
- if [ $? -ne 0 ]; then
782
- jq -n --arg m "$TAG bashGuard \u2192 pass: local grader unavailable (run synkro local-cc start)" '{systemMessage: $m}'
783
- exit 0
784
- fi
785
- synkro_parse_local_verdict "$CC_RESP"
786
-
787
- # Build violated rules JSON for full-content capture
788
- VIOLATED_JSON="[]"
789
- [ -n "$LOCAL_RULE_ID" ] && VIOLATED_JSON=$(jq -n --arg r "$LOCAL_RULE_ID" '[$r]')
790
-
791
- if [ "$LOCAL_OK" = "false" ]; then
792
- RULE_MODE="\${LOCAL_RULE_MODE:-$(synkro_rule_mode "\${LOCAL_RULE_ID}")}"
793
- if [ "$RULE_MODE" = "audit" ]; then
794
- REASON="$TAG bashGuard \u2192 warning\${LOCAL_RULE_ID:+ ($LOCAL_RULE_ID)}: \${LOCAL_REASON:-policy violation}"
795
- jq -n --arg m "$REASON" '{systemMessage: $m}'
796
- synkro_dispatch_capture "bash" "warning" "\${LOCAL_SEV}" "\${LOCAL_CAT}" "$TOOL_NAME" "$GIT_REPO" "$SESSION_ID" \\
797
- "$COMMAND" "$LOCAL_REASON" "\${SYNKRO_RULES:-[]}" "$VIOLATED_JSON" "\${RECENT_USER_MESSAGES:-[]}"
798
- else
799
- REASON="$TAG bashGuard \u2192 blocked\${LOCAL_RULE_ID:+ ($LOCAL_RULE_ID)}: \${LOCAL_REASON:-policy violation}. Ask the user for explicit consent before retrying."
800
- jq -n --arg reason "$REASON" \\
801
- '{systemMessage:$reason,hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"deny",permissionDecisionReason:$reason,additionalContext:$reason}}'
802
- synkro_dispatch_capture "bash" "block" "\${LOCAL_SEV}" "\${LOCAL_CAT}" "$TOOL_NAME" "$GIT_REPO" "$SESSION_ID" \\
803
- "$COMMAND" "$LOCAL_REASON" "\${SYNKRO_RULES:-[]}" "$VIOLATED_JSON" "\${RECENT_USER_MESSAGES:-[]}"
804
- fi
805
- else
806
- jq -n --arg m "$TAG bashGuard \u2192 pass: \${LOCAL_REASON:-no policy violations detected}" '{systemMessage: $m}'
807
- synkro_dispatch_capture "bash" "pass" "audit" "\${LOCAL_CAT:-trivial_utility}" "$TOOL_NAME" "$GIT_REPO" "$SESSION_ID" \\
808
- "$COMMAND" "$LOCAL_REASON" "\${SYNKRO_RULES:-[]}" "[]" "\${RECENT_USER_MESSAGES:-[]}"
809
- fi
810
- exit 0
811
- fi
812
-
813
- # \u2500\u2500\u2500 Cloud grading \u2500\u2500\u2500
814
- CC_MODEL=""
815
- CC_USAGE="{}"
816
- RECENT_MESSAGES="[]"
817
- RECENT_ACTIONS="[]"
818
- SESSION_SUMMARY=""
819
- if [ -n "$TRANSCRIPT_PATH" ] && [ -f "$TRANSCRIPT_PATH" ]; then
820
- _LAST=$(grep '"type":"assistant"' "$TRANSCRIPT_PATH" 2>/dev/null | tail -1)
821
- if [ -n "$_LAST" ]; then
822
- CC_MODEL=$(echo "$_LAST" | jq -r '.message.model // empty' 2>/dev/null)
823
- 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 "{}")
824
- fi
825
- 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 "[]")
826
- 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 "[]")
827
- SESSION_SUMMARY=$(grep '"type":"summary"' "$TRANSCRIPT_PATH" 2>/dev/null | tail -1 | jq -r '.summary // empty' 2>/dev/null || echo "")
647
+ echo '{}'; exit 0
828
648
  fi
829
649
 
830
650
  BODY=$(jq -n \\
831
- --arg hook_event "PreToolUse" \\
832
- --arg tool_name "$TOOL_NAME" \\
833
- --argjson tool_input "$(echo "$PAYLOAD" | jq -c '.tool_input // {}')" \\
834
- --arg user_intent "$USER_INTENT" \\
835
- --argjson recent_user_messages "$RECENT_USER_MESSAGES" \\
836
- --argjson recent_messages "$RECENT_MESSAGES" \\
837
- --argjson recent_actions "$RECENT_ACTIONS" \\
651
+ --arg cmd "$COMMAND" \\
838
652
  --arg session_id "$SESSION_ID" \\
839
- --arg tool_use_id "$TOOL_USE_ID" \\
840
653
  --arg cwd "$CWD" \\
841
654
  --arg repo "$GIT_REPO" \\
842
- --arg permission_mode "$PERMISSION_MODE" \\
843
- --arg cc_model "$CC_MODEL" \\
844
- --argjson cc_usage "$CC_USAGE" \\
845
- --arg session_summary "$SESSION_SUMMARY" \\
846
655
  '{
847
- hook_event: $hook_event,
848
- tool_name: $tool_name,
849
- tool_input: $tool_input,
850
- user_intent: (if ($user_intent | length) > 0 then $user_intent else null end),
851
- recent_user_messages: $recent_user_messages,
852
- recent_messages: $recent_messages,
853
- recent_actions: $recent_actions,
656
+ hook_event: "PreToolUse",
657
+ tool_name: "Bash",
658
+ tool_input: {command: $cmd},
659
+ response_format: "cursor",
854
660
  session_id: (if ($session_id | length) > 0 then $session_id else null end),
855
- tool_use_id: (if ($tool_use_id | length) > 0 then $tool_use_id else null end),
856
661
  cwd: (if ($cwd | length) > 0 then $cwd else null end),
857
- repo: (if ($repo | length) > 0 then $repo else null end),
858
- permission_mode: (if ($permission_mode | length) > 0 then $permission_mode else null end),
859
- cc_model: (if ($cc_model | length) > 0 then $cc_model else null end),
860
- cc_usage: $cc_usage,
861
- session_summary: (if ($session_summary | length) > 0 then $session_summary else null end)
662
+ repo: (if ($repo | length) > 0 then $repo else null end)
862
663
  }')
863
664
 
864
- 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)
865
666
 
866
667
  if [ -z "$RESP" ]; then
867
668
  synkro_log "bashGuard $CMD_SHORT \u2192 error (timeout)"
868
- echo '{}'
869
- exit 0
669
+ echo '{}'; exit 0
870
670
  fi
871
671
 
872
- if ! echo "$RESP" | jq -e '.hook_response' >/dev/null 2>&1; then
873
- 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
874
676
  echo '{}'
875
- exit 0
876
677
  fi
877
-
878
- echo "$RESP" | jq -c '.hook_response'
879
678
  exit 0
880
679
  `;
881
- CC_EDIT_PRECHECK_SCRIPT = `#!/bin/bash
680
+ CURSOR_EDIT_PRECHECK_SCRIPT = `#!/bin/bash
882
681
  SCRIPT_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
883
682
  . "$SCRIPT_DIR/_synkro-common.sh"
884
683
 
@@ -890,231 +689,79 @@ PAYLOAD=$(cat)
890
689
  if [ -z "$PAYLOAD" ]; then echo '{}'; exit 0; fi
891
690
 
892
691
  TOOL_NAME=$(echo "$PAYLOAD" | jq -r '.tool_name // empty' 2>/dev/null)
893
- case "$TOOL_NAME" in Edit|Write|MultiEdit|NotebookEdit) ;; *) echo '{}'; exit 0 ;; esac
894
-
895
- TOOL_INPUT=$(echo "$PAYLOAD" | jq -c '.tool_input // {}' 2>/dev/null)
896
- SESSION_ID=$(echo "$PAYLOAD" | jq -r '.session_id // empty' 2>/dev/null)
897
- TOOL_USE_ID=$(echo "$PAYLOAD" | jq -r '.tool_use_id // empty' 2>/dev/null)
898
692
  CWD=$(echo "$PAYLOAD" | jq -r '.cwd // empty' 2>/dev/null)
693
+ SESSION_ID=$(echo "$PAYLOAD" | jq -r '.conversation_id // empty' 2>/dev/null)
899
694
  GIT_REPO=$(synkro_detect_repo "\${CWD:-.}")
900
- PERMISSION_MODE=$(echo "$PAYLOAD" | jq -r '.permission_mode // empty' 2>/dev/null)
901
695
 
902
- 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)
903
698
  if [ -z "$FILE_PATH" ]; then echo '{}'; exit 0; fi
904
699
 
905
- FILE_SHORT=$(basename "$FILE_PATH")
906
- synkro_log "editGuard checking: $FILE_SHORT"
907
-
908
- IS_HEADLESS="\${SYNKRO_HEADLESS:-0}"
909
- case "$PERMISSION_MODE" in acceptEdits|bypassPermissions|plan|auto) IS_HEADLESS="1" ;; esac
910
-
911
- # Read file before edit for reconstruction
912
- FILE_BEFORE=""
913
- if [ "$TOOL_NAME" != "Write" ] && [ -n "$FILE_PATH" ] && [ -f "$FILE_PATH" ]; then
914
- FILE_BEFORE=$(head -c 65536 "$FILE_PATH" 2>/dev/null || echo "")
915
- fi
916
-
917
- # Reconstruct proposed content
918
- case "$TOOL_NAME" in
919
- Write) PROPOSED=$(echo "$TOOL_INPUT" | jq -r '.content // ""' 2>/dev/null) ;;
920
- Edit|MultiEdit)
921
- if [ -n "$FILE_BEFORE" ] && command -v python3 >/dev/null 2>&1; then
922
- PROPOSED=$(FILE_BEFORE_LITERAL="$FILE_BEFORE" TOOL_INPUT_LITERAL="$TOOL_INPUT" python3 -c '
923
- import os, json, sys
924
- fb = os.environ.get("FILE_BEFORE_LITERAL", "")
925
- ti = json.loads(os.environ.get("TOOL_INPUT_LITERAL", "{}"))
926
- result = fb
927
- if "old_string" in ti and "new_string" in ti:
928
- if ti["old_string"]: result = result.replace(ti["old_string"], ti["new_string"], 1)
929
- elif "edits" in ti and isinstance(ti["edits"], list):
930
- for e in ti["edits"]:
931
- old = e.get("old_string", "") if isinstance(e, dict) else ""
932
- new = e.get("new_string", "") if isinstance(e, dict) else ""
933
- if old: result = result.replace(old, new, 1)
934
- sys.stdout.write(result)
935
- ' 2>/dev/null)
936
- fi
937
- if [ -z "$PROPOSED" ]; then
938
- if [ "$TOOL_NAME" = "MultiEdit" ]; then
939
- PROPOSED=$(echo "$TOOL_INPUT" | jq -r '[.edits[]?.new_string // ""] | join("\\n\\n--- chunk ---\\n\\n")' 2>/dev/null)
940
- else
941
- PROPOSED=$(echo "$TOOL_INPUT" | jq -r '.new_string // ""' 2>/dev/null)
942
- fi
943
- fi ;;
944
- NotebookEdit) PROPOSED=$(echo "$TOOL_INPUT" | jq -r '.new_source // ""' 2>/dev/null) ;;
945
- esac
946
- if [ -z "$PROPOSED" ]; then echo '{}'; exit 0; fi
947
-
948
- DIFF_FIELD=$(echo "$TOOL_INPUT" | jq -c '{old_string, new_string, edits} | with_entries(select(.value != null))' 2>/dev/null)
949
- [ -z "$DIFF_FIELD" ] || [ "$DIFF_FIELD" = "null" ] || [ "$DIFF_FIELD" = "{}" ] && DIFF_FIELD="null"
950
-
951
- # Extract user intent from transcript
952
- USER_INTENT=""
953
- RECENT_ACTIONS="[]"
954
- TRANSCRIPT_PATH=$(echo "$PAYLOAD" | jq -r '.transcript_path // empty' 2>/dev/null)
955
- if [ -n "$TRANSCRIPT_PATH" ] && [ -f "$TRANSCRIPT_PATH" ]; then
956
- USER_INTENT=$(tail -200 "$TRANSCRIPT_PATH" | jq -r 'select(.type == "user") | .message.content | if type == "string" then . else (map(.text? // "") | join(" ")) end' 2>/dev/null | tail -1 || echo "")
957
- 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 "[]")
958
- fi
700
+ BASENAME=$(basename "$FILE_PATH" 2>/dev/null || echo "$FILE_PATH")
701
+ synkro_log "editGuard checking: $BASENAME"
959
702
 
960
703
  synkro_load_config
961
- ROUTE=$(synkro_route)
962
- TAG=$(synkro_tag "$ROUTE")
963
-
964
704
  if [ "$SYNKRO_SILENT" = "true" ]; then
965
- jq -n --arg m "$TAG editGuard \u2192 skipped (silent mode)" '{systemMessage: $m}'
966
- exit 0
967
- fi
968
-
969
- if [ "$ROUTE" = "local" ]; then
970
- # \u2500\u2500\u2500 Local grading (local_only privacy or local-cc channel) \u2500\u2500\u2500
971
- GRADER_FILE=$(mktemp -t synkro-edit.XXXXXX)
972
- trap "rm -f \\"$GRADER_FILE\\"" EXIT
973
- printf 'Working directory: %s\\nRepo: %s\\nFile: %s\\nProposed content (first 4000 chars):\\n%s\\nUser intent (last human message): %s\\nOrg rules: %s\\n' "\${CWD:-.}" "\${GIT_REPO:-unknown}" "$FILE_PATH" "$(printf '%s' "$PROPOSED" | head -c 4000)" "\${USER_INTENT:-none stated}" "\${SYNKRO_RULES:-[]}" > "$GRADER_FILE"
974
-
975
- CC_RESP=$(synkro_local_grade edit < "$GRADER_FILE" 2>&1)
976
- if [ $? -ne 0 ]; then
977
- jq -n --arg m "$TAG editGuard \u2192 pass: local grader unavailable (run synkro local-cc start)" '{systemMessage: $m}'
978
- exit 0
979
- fi
980
- synkro_parse_local_verdict "$CC_RESP"
981
-
982
- # Build edit content description and violated rules for full-content capture
983
- EDIT_CONTENT="file=$FILE_PATH content=$(printf '%s' "$PROPOSED" | head -c 2000)"
984
- VIOLATED_JSON="[]"
985
- [ -n "$LOCAL_RULE_ID" ] && VIOLATED_JSON=$(jq -n --arg r "$LOCAL_RULE_ID" '[$r]')
986
-
987
- if [ "$LOCAL_OK" = "false" ]; then
988
- RULE_MODE="\${LOCAL_RULE_MODE:-$(synkro_rule_mode "\${LOCAL_RULE_ID}")}"
989
- if [ "$RULE_MODE" = "audit" ]; then
990
- REASON="$TAG editGuard $FILE_SHORT \u2192 warning\${LOCAL_RULE_ID:+ ($LOCAL_RULE_ID)}: \${LOCAL_REASON:-policy violation}"
991
- jq -n --arg m "$REASON" '{systemMessage: $m}'
992
- synkro_dispatch_capture "edit" "warning" "\${LOCAL_SEV}" "\${LOCAL_CAT}" "$TOOL_NAME" "$GIT_REPO" "$SESSION_ID" \\
993
- "$EDIT_CONTENT" "$LOCAL_REASON" "\${SYNKRO_RULES:-[]}" "$VIOLATED_JSON" "[]"
994
- else
995
- 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."
996
- jq -n --arg reason "$REASON" \\
997
- '{systemMessage:$reason,hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"deny",permissionDecisionReason:$reason,additionalContext:$reason}}'
998
- synkro_dispatch_capture "edit" "block" "\${LOCAL_SEV}" "\${LOCAL_CAT}" "$TOOL_NAME" "$GIT_REPO" "$SESSION_ID" \\
999
- "$EDIT_CONTENT" "$LOCAL_REASON" "\${SYNKRO_RULES:-[]}" "$VIOLATED_JSON" "[]"
1000
- fi
1001
- else
1002
- jq -n --arg m "$TAG editGuard $FILE_SHORT \u2192 pass: \${LOCAL_REASON:-no policy violations detected}" '{systemMessage: $m}'
1003
- synkro_dispatch_capture "edit" "pass" "audit" "\${LOCAL_CAT:-trivial_edit}" "$TOOL_NAME" "$GIT_REPO" "$SESSION_ID" \\
1004
- "$EDIT_CONTENT" "$LOCAL_REASON" "\${SYNKRO_RULES:-[]}" "[]" "[]"
1005
- fi
1006
- exit 0
705
+ echo '{}'; exit 0
1007
706
  fi
1008
707
 
1009
- # \u2500\u2500\u2500 Cloud grading \u2500\u2500\u2500
1010
708
  BODY=$(jq -n \\
1011
- --arg hook_event "PreToolUse" \\
1012
- --arg tool_name "$TOOL_NAME" \\
1013
- --argjson tool_input "$TOOL_INPUT" \\
1014
709
  --arg file_path "$FILE_PATH" \\
1015
- --arg content "$PROPOSED" \\
1016
- --arg file_before "$FILE_BEFORE" \\
1017
- --argjson diff "$DIFF_FIELD" \\
1018
- --arg user_intent "$USER_INTENT" \\
1019
- --argjson recent_actions "$RECENT_ACTIONS" \\
710
+ --arg content "$CONTENT" \\
1020
711
  --arg session_id "$SESSION_ID" \\
1021
- --arg tool_use_id "$TOOL_USE_ID" \\
1022
712
  --arg cwd "$CWD" \\
1023
713
  --arg repo "$GIT_REPO" \\
1024
- --arg permission_mode "$PERMISSION_MODE" \\
1025
- --arg headless_flag "\${SYNKRO_HEADLESS:-0}" \\
1026
714
  '{
1027
- hook_event: $hook_event,
1028
- tool_name: $tool_name,
1029
- tool_input: $tool_input,
715
+ hook_event: "PreToolUse",
716
+ tool_name: "Edit",
717
+ tool_input: {file_path: $file_path, content: $content},
1030
718
  file_path: $file_path,
1031
719
  content: $content,
1032
- file_before: (if ($file_before | length) > 0 then $file_before else null end),
1033
- diff: $diff,
1034
- user_intent: (if ($user_intent | length) > 0 then $user_intent else null end),
1035
- recent_actions: $recent_actions,
720
+ response_format: "cursor",
1036
721
  session_id: (if ($session_id | length) > 0 then $session_id else null end),
1037
- tool_use_id: (if ($tool_use_id | length) > 0 then $tool_use_id else null end),
1038
722
  cwd: (if ($cwd | length) > 0 then $cwd else null end),
1039
- repo: (if ($repo | length) > 0 then $repo else null end),
1040
- permission_mode: (if ($permission_mode | length) > 0 then $permission_mode else null end),
1041
- headless: ($headless_flag == "1")
723
+ repo: (if ($repo | length) > 0 then $repo else null end)
1042
724
  }')
1043
725
 
1044
726
  RESP=$(synkro_post_with_retry "\${GATEWAY_URL}/api/v1/hook/judge" "$BODY" 8)
1045
727
 
1046
728
  if [ -z "$RESP" ]; then
1047
- synkro_log "editGuard $FILE_SHORT \u2192 error (timeout)"
1048
- echo '{}'
1049
- exit 0
1050
- fi
1051
-
1052
- if ! echo "$RESP" | jq -e 'type == "object"' >/dev/null 2>&1; then
1053
- synkro_log "editGuard $FILE_SHORT \u2192 error (bad response)"
1054
- echo '{}'
1055
- exit 0
729
+ synkro_log "editGuard $BASENAME \u2192 error (timeout)"
730
+ echo '{}'; exit 0
1056
731
  fi
1057
732
 
1058
- DECISION=$(echo "$RESP" | jq -r '.hook_response.hookSpecificOutput.permissionDecision // "allow"' 2>/dev/null)
1059
- if [ "$DECISION" = "deny" ] || [ "$DECISION" = "ask" ]; then
1060
- synkro_log "editGuard $FILE_SHORT \u2192 BLOCKED"
733
+ if echo "$RESP" | jq -e '.hook_response' >/dev/null 2>&1; then
1061
734
  echo "$RESP" | jq -c '.hook_response'
1062
735
  else
1063
- REASON=$(echo "$RESP" | jq -r '.hook_response.reason // empty' 2>/dev/null)
1064
- if [ -n "$REASON" ]; then
1065
- synkro_log "editGuard $FILE_SHORT \u2192 pass: $REASON"
1066
- else
1067
- synkro_log "editGuard $FILE_SHORT \u2192 pass"
1068
- fi
1069
- echo "$RESP" | jq -c '.hook_response // {}'
736
+ echo '{}'
1070
737
  fi
1071
738
  exit 0
1072
739
  `;
1073
- CC_EDIT_CAPTURE_SCRIPT = `#!/bin/bash
740
+ CURSOR_EDIT_CAPTURE_SCRIPT = `#!/bin/bash
1074
741
  SCRIPT_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
1075
742
  . "$SCRIPT_DIR/_synkro-common.sh"
1076
743
 
1077
744
  JWT=$(synkro_load_jwt)
1078
745
  if [ -z "$JWT" ]; then echo '{}'; exit 0; fi
1079
- synkro_ensure_fresh_jwt
1080
746
 
1081
747
  PAYLOAD=$(cat)
1082
748
  if [ -z "$PAYLOAD" ]; then echo '{}'; exit 0; fi
1083
749
 
1084
- TOOL_NAME=$(echo "$PAYLOAD" | jq -r '.tool_name // empty' 2>/dev/null)
1085
- 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
1086
752
 
1087
- TOOL_INPUT=$(echo "$PAYLOAD" | jq -c '.tool_input // {}' 2>/dev/null)
1088
- SESSION_ID=$(echo "$PAYLOAD" | jq -r '.session_id // empty' 2>/dev/null)
1089
- TOOL_USE_ID=$(echo "$PAYLOAD" | jq -r '.tool_use_id // empty' 2>/dev/null)
1090
- 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)
1091
755
  GIT_REPO=$(synkro_detect_repo "\${CWD:-.}")
756
+ BASENAME=$(basename "$FILE_PATH" 2>/dev/null || echo "$FILE_PATH")
1092
757
 
1093
- # Correction followup (backgrounded)
1094
- if [ -n "$SESSION_ID" ] && [ -n "$TOOL_USE_ID" ]; then
1095
- (
1096
- 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"}')
1097
- curl -sS -X POST "\${GATEWAY_URL}/api/v1/hook/capture" \\
1098
- -H "Content-Type: application/json" -H "Authorization: Bearer $JWT" \\
1099
- -d "$BODY" --max-time 2 >/dev/null 2>&1
1100
- ) &
1101
- fi
1102
-
1103
- # Fire-and-forget: POST edit scan to /v1/hook/judge (PostToolUse)
1104
- FILE_PATH=$(echo "$TOOL_INPUT" | jq -r '.file_path // .notebook_path // .path // empty' 2>/dev/null)
1105
- if [ -z "$FILE_PATH" ] || [ ! -f "$FILE_PATH" ]; then echo '{}'; exit 0; fi
1106
-
1107
- BASENAME=$(basename "$FILE_PATH")
1108
- synkro_log "editScan: $BASENAME"
1109
-
1110
- FILE_CONTENT=$(head -c 65536 "$FILE_PATH" 2>/dev/null || echo "")
1111
- if [ -z "$FILE_CONTENT" ]; then echo '{}'; exit 0; fi
1112
-
1113
- DIFF_FIELD=$(echo "$TOOL_INPUT" | jq -c '{old_string, new_string, edits} | with_entries(select(.value != null))' 2>/dev/null || echo "null")
1114
- [ -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)
1115
762
 
1116
763
  DEPS_JSON="{}"
1117
- _PKG_DIR=$(dirname "$FILE_PATH")
764
+ _PKG_DIR="\${CWD:-.}"
1118
765
  while [ "$_PKG_DIR" != "/" ]; do
1119
766
  if [ -f "$_PKG_DIR/package.json" ]; then
1120
767
  DEPS_JSON=$(jq -c '(.dependencies // {}) + (.devDependencies // {})' "$_PKG_DIR/package.json" 2>/dev/null || echo "{}")
@@ -1123,778 +770,1961 @@ while [ "$_PKG_DIR" != "/" ]; do
1123
770
  _PKG_DIR=$(dirname "$_PKG_DIR")
1124
771
  done
1125
772
 
1126
- synkro_load_config
1127
- ROUTE=$(synkro_route)
1128
- TAG=$(synkro_tag "$ROUTE")
1129
-
1130
- if [ "$SYNKRO_SILENT" = "true" ]; then
1131
- echo '{}'; exit 0
1132
- fi
1133
-
1134
- if [ "$ROUTE" = "local" ]; then
1135
- # \u2500\u2500\u2500 Local edit scan (local_only privacy or local-cc channel) \u2500\u2500\u2500
1136
- GRADER_FILE=$(mktemp -t synkro-escan.XXXXXX)
1137
- trap "rm -f \\"$GRADER_FILE\\"" EXIT
1138
- 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"
1139
-
1140
- CC_RESP=$(synkro_local_grade edit < "$GRADER_FILE" 2>&1)
1141
- if [ $? -ne 0 ]; then
1142
- echo '{}'; exit 0
1143
- fi
1144
- synkro_parse_local_verdict "$CC_RESP"
1145
-
1146
- SCAN_CONTENT="file=$FILE_PATH"
1147
- SCAN_VIOLATED="[]"
1148
- [ -n "$LOCAL_RULE_ID" ] && SCAN_VIOLATED=$(jq -n --arg r "$LOCAL_RULE_ID" '[$r]')
1149
-
1150
- if [ "$LOCAL_OK" = "false" ]; then
1151
- RULE_MODE="\${LOCAL_RULE_MODE:-$(synkro_rule_mode "\${LOCAL_RULE_ID}")}"
1152
- if [ "$RULE_MODE" = "audit" ]; then
1153
- REASON="$TAG editScan $BASENAME \u2192 warning\${LOCAL_RULE_ID:+ ($LOCAL_RULE_ID)}: \${LOCAL_REASON:-policy violation}"
1154
- jq -n --arg m "$REASON" '{systemMessage: $m}'
1155
- synkro_dispatch_capture "edit_scan" "warning" "\${LOCAL_SEV}" "\${LOCAL_CAT}" "$TOOL_NAME" "$GIT_REPO" "$SESSION_ID" \\
1156
- "$SCAN_CONTENT" "$LOCAL_REASON" "\${SYNKRO_RULES:-[]}" "$SCAN_VIOLATED" "[]"
1157
- else
1158
- REASON="$TAG editScan $BASENAME \u2192 block: \${LOCAL_REASON:-policy violation}"
1159
- jq -n --arg m "$REASON" '{systemMessage: $m, additionalContext: $m}'
1160
- synkro_dispatch_capture "edit_scan" "block" "\${LOCAL_SEV}" "\${LOCAL_CAT}" "$TOOL_NAME" "$GIT_REPO" "$SESSION_ID" \\
1161
- "$SCAN_CONTENT" "$LOCAL_REASON" "\${SYNKRO_RULES:-[]}" "$SCAN_VIOLATED" "[]"
1162
- fi
1163
- else
1164
- jq -n --arg m "$TAG editScan $BASENAME \u2192 pass: \${LOCAL_REASON:-no policy violations detected}" '{systemMessage: $m}'
1165
- synkro_dispatch_capture "edit_scan" "pass" "audit" "\${LOCAL_CAT:-trivial_edit}" "$TOOL_NAME" "$GIT_REPO" "$SESSION_ID" \\
1166
- "$SCAN_CONTENT" "$LOCAL_REASON" "\${SYNKRO_RULES:-[]}" "[]" "[]"
1167
- fi
1168
- exit 0
1169
- fi
1170
-
1171
- # \u2500\u2500\u2500 Cloud edit scan \u2500\u2500\u2500
1172
- BODY=$(jq -n \\
1173
- --arg hook_event "PostToolUse" \\
1174
- --arg tool_name "$TOOL_NAME" \\
1175
- --argjson tool_input "$TOOL_INPUT" \\
1176
- --arg file_path "$FILE_PATH" \\
1177
- --arg content "$FILE_CONTENT" \\
1178
- --argjson diff "$DIFF_FIELD" \\
1179
- --argjson dependencies "$DEPS_JSON" \\
1180
- --arg session_id "$SESSION_ID" \\
1181
- --arg tool_use_id "$TOOL_USE_ID" \\
1182
- --arg cwd "$CWD" \\
1183
- --arg repo "$GIT_REPO" \\
1184
- '{
1185
- hook_event: $hook_event,
1186
- tool_name: $tool_name,
1187
- tool_input: $tool_input,
1188
- file_path: $file_path,
1189
- content: $content,
1190
- diff: $diff,
1191
- dependencies: $dependencies,
1192
- session_id: (if ($session_id | length) > 0 then $session_id else null end),
1193
- tool_use_id: (if ($tool_use_id | length) > 0 then $tool_use_id else null end),
1194
- cwd: (if ($cwd | length) > 0 then $cwd else null end),
1195
- repo: (if ($repo | length) > 0 then $repo else null end)
1196
- }')
1197
-
1198
- RESP=$(synkro_post_with_retry "\${GATEWAY_URL}/api/v1/hook/judge" "$BODY" 12)
773
+ synkro_log "editScan $BASENAME"
1199
774
 
1200
- if [ -z "$RESP" ] || ! echo "$RESP" | jq -e 'type == "object"' >/dev/null 2>&1; then
1201
- synkro_log "editScan $BASENAME \u2192 error (no response)"
1202
- echo '{}'
1203
- exit 0
1204
- 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
1205
789
 
1206
- if echo "$RESP" | jq -e '.hook_response' >/dev/null 2>&1; then
1207
- echo "$RESP" | jq -c '.hook_response'
1208
- else
1209
- echo '{}'
1210
- fi
790
+ echo '{}'
1211
791
  exit 0
1212
792
  `;
1213
- CC_PLAN_JUDGE_SCRIPT = `#!/bin/bash
793
+ CURSOR_BASH_FOLLOWUP_SCRIPT = `#!/bin/bash
1214
794
  SCRIPT_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
1215
795
  . "$SCRIPT_DIR/_synkro-common.sh"
1216
796
 
1217
797
  JWT=$(synkro_load_jwt)
1218
798
  if [ -z "$JWT" ]; then echo '{}'; exit 0; fi
1219
- synkro_ensure_fresh_jwt
1220
799
 
1221
800
  PAYLOAD=$(cat)
1222
- if [ -z "$PAYLOAD" ]; then echo '{}'; exit 0; fi
1223
-
1224
801
  TOOL_NAME=$(echo "$PAYLOAD" | jq -r '.tool_name // empty' 2>/dev/null)
1225
- if [ "$TOOL_NAME" != "ExitPlanMode" ]; then echo '{}'; exit 0; fi
1226
-
1227
- # ExitPlanMode's tool_input contains {allowedPrompts:[...]}, not plan content.
1228
- # Read from the most recently modified plan file instead.
1229
- PLANS_DIR="$HOME/.claude/plans"
1230
- PLAN_FILE=$(ls -t "$PLANS_DIR"/*.md 2>/dev/null | head -1)
1231
- if [ -z "$PLAN_FILE" ] || [ ! -f "$PLAN_FILE" ]; then echo '{}'; exit 0; fi
1232
- PLAN=$(cat "$PLAN_FILE" 2>/dev/null)
1233
- if [ -z "$PLAN" ] || [ \${#PLAN} -lt 20 ]; then echo '{}'; exit 0; fi
1234
-
1235
- SESSION_ID=$(echo "$PAYLOAD" | jq -r '.session_id // empty' 2>/dev/null)
1236
- CWD=$(echo "$PAYLOAD" | jq -r '.cwd // empty' 2>/dev/null)
1237
- GIT_REPO=$(synkro_detect_repo "\${CWD:-.}")
1238
-
1239
- PLAN_SHORT=$(printf '%s' "$PLAN" | head -c 80)
1240
- synkro_log "planReview checking: $PLAN_SHORT..."
1241
-
1242
- # Write review verdict into the plan file so it survives ExitPlanMode rejection
1243
- append_review_to_plan() {
1244
- local verdict="$1"
1245
- local tmp="\${PLAN_FILE}.synkro.tmp"
1246
- sed '/^<!-- synkro-plan-review -->$/,/^<!-- \\/synkro-plan-review -->$/d' "$PLAN_FILE" | sed -e :a -e '/^\\n*$/{$d;N;ba' -e '}' > "$tmp" 2>/dev/null
1247
- 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"
1248
- mv "$tmp" "$PLAN_FILE" 2>/dev/null
1249
- }
802
+ case "$TOOL_NAME" in Shell|Bash|terminal|run_terminal_cmd|execute_command) ;; *) echo '{}'; exit 0 ;; esac
1250
803
 
1251
- synkro_load_config
1252
- ROUTE=$(synkro_route)
1253
- 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)
1254
806
 
1255
- if [ "$SYNKRO_SILENT" = "true" ]; then
1256
- jq -n --arg m "$TAG planReview \u2192 skipped (silent mode)" '{systemMessage: $m}'
1257
- 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)
1258
812
  fi
1259
813
 
1260
- if [ "$ROUTE" = "local" ]; then
1261
- GRADER_FILE=$(mktemp -t synkro-plan.XXXXXX)
1262
- trap "rm -f \\"$GRADER_FILE\\"" EXIT
1263
- 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"
1264
-
1265
- CC_RESP=$(synkro_local_grade plan < "$GRADER_FILE" 2>&1)
1266
- if [ $? -ne 0 ]; then
1267
- echo '{}'; exit 0
1268
- fi
1269
- synkro_parse_local_verdict "$CC_RESP"
1270
-
1271
- PLAN_CONTENT=$(printf '%s' "$PLAN" | head -c 2000)
1272
- PLAN_VIOLATED="[]"
1273
- [ -n "$LOCAL_RULE_ID" ] && PLAN_VIOLATED=$(jq -n --arg r "$LOCAL_RULE_ID" '[$r]')
1274
-
1275
- if [ "$LOCAL_OK" = "false" ]; then
1276
- VCOUNT=$(printf '%s' "$CC_RESP" | grep -c '<violation>' 2>/dev/null || echo "0")
1277
- [ "$VCOUNT" = "0" ] && VCOUNT="1"
1278
- REVIEW_MSG="\${VCOUNT} rule(s) relevant\${LOCAL_RULE_ID:+ (first: $LOCAL_RULE_ID)}: \${LOCAL_REASON:-check org rules during implementation}"
1279
- append_review_to_plan "\u26A0\uFE0F Advisory \u2014 $REVIEW_MSG"
1280
- MSG="$TAG planReview \u2192 $REVIEW_MSG"
1281
- jq -n --arg m "$MSG" '{systemMessage: $m}'
1282
- synkro_dispatch_capture "plan_review" "advisory" "\${LOCAL_SEV}" "\${LOCAL_CAT}" "ExitPlanMode" "$GIT_REPO" "$SESSION_ID" \\
1283
- "$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"
1284
817
  else
1285
- REVIEW_MSG="Clean \u2014 \${LOCAL_REASON:-no relevant org rules for this plan}"
1286
- append_review_to_plan "\u2705 $REVIEW_MSG"
1287
- jq -n --arg m "$TAG planReview \u2192 clean: \${LOCAL_REASON:-no relevant org rules for this plan}" '{systemMessage: $m}'
1288
- synkro_dispatch_capture "plan_review" "clean" "audit" "\${LOCAL_CAT:-general}" "ExitPlanMode" "$GIT_REPO" "$SESSION_ID" \\
1289
- "$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
1290
821
  fi
1291
- exit 0
1292
- fi
1293
-
1294
- # \u2500\u2500\u2500 Cloud grading \u2500\u2500\u2500
1295
- BODY=$(jq -n \\
1296
- --arg hook_event "PreToolUse" \\
1297
- --arg tool_name "ExitPlanMode" \\
1298
- --arg plan "$(printf '%s' "$PLAN" | head -c 16000)" \\
1299
- --arg session_id "$SESSION_ID" \\
1300
- --arg cwd "$CWD" \\
1301
- --arg repo "$GIT_REPO" \\
1302
- '{
1303
- hook_event: $hook_event,
1304
- tool_name: $tool_name,
1305
- tool_input: {plan: $plan},
1306
- session_id: (if ($session_id | length) > 0 then $session_id else null end),
1307
- cwd: (if ($cwd | length) > 0 then $cwd else null end),
1308
- repo: (if ($repo | length) > 0 then $repo else null end)
1309
- }')
1310
-
1311
- RESP=$(synkro_post_with_retry "\${GATEWAY_URL}/api/v1/hook/judge" "$BODY" 12)
1312
-
1313
- if [ -z "$RESP" ]; then
1314
- synkro_log "planReview \u2192 error (timeout)"
1315
- echo '{}'
1316
- exit 0
1317
822
  fi
1318
823
 
1319
- if ! echo "$RESP" | jq -e '.hook_response' >/dev/null 2>&1; then
1320
- echo '{}'
1321
- exit 0
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
1322
834
  fi
1323
835
 
1324
- # Force advisory: convert any blocking decision to systemMessage
1325
- HR=$(echo "$RESP" | jq -c '.hook_response')
1326
- if echo "$HR" | jq -e '.hookSpecificOutput.permissionDecision' >/dev/null 2>&1; then
1327
- REASON=$(echo "$HR" | jq -r '.hookSpecificOutput.permissionDecisionReason // "check org rules"' 2>/dev/null)
1328
- append_review_to_plan "\u26A0\uFE0F Advisory \u2014 $REASON"
1329
- jq -n --arg m "$TAG planReview \u2192 advisory: $REASON" '{systemMessage: $m}'
1330
- else
1331
- CLOUD_MSG=$(echo "$HR" | jq -r '.systemMessage // empty' 2>/dev/null)
1332
- [ -n "$CLOUD_MSG" ] && append_review_to_plan "\u2705 $CLOUD_MSG"
1333
- echo "$HR"
1334
- fi
836
+ echo '{}'
1335
837
  exit 0
1336
838
  `;
1337
- CC_STOP_SUMMARY_SCRIPT = `#!/bin/bash
1338
- SCRIPT_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
1339
- . "$SCRIPT_DIR/_synkro-common.sh"
839
+ }
840
+ });
1340
841
 
1341
- JWT=$(synkro_load_jwt)
1342
- if [ -z "$JWT" ]; then echo '{}'; exit 0; fi
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';
1343
853
 
1344
- PAYLOAD=$(cat)
1345
- SESSION_ID=$(echo "$PAYLOAD" | jq -r '.session_id // empty' 2>/dev/null)
1346
- if [ -z "$SESSION_ID" ]; then echo '{}'; exit 0; fi
854
+ // \u2500\u2500\u2500 Config \u2500\u2500\u2500
1347
855
 
1348
- CWD=$(echo "$PAYLOAD" | jq -r '.cwd // empty' 2>/dev/null)
1349
- TRANSCRIPT_PATH=$(echo "$PAYLOAD" | jq -r '.transcript_path // empty' 2>/dev/null)
1350
- GIT_REPO=$(synkro_detect_repo "\${CWD:-.}")
856
+ const HOME = homedir();
857
+ const CONFIG_PATH = join(HOME, '.synkro', 'config.env');
1351
858
 
1352
- # Aggregate token usage across all assistant turns in the transcript and POST silently
1353
- if [ -n "$TRANSCRIPT_PATH" ] && [ -f "$TRANSCRIPT_PATH" ]; then
1354
- (
1355
- _LAST=$(grep '"type":"assistant"' "$TRANSCRIPT_PATH" 2>/dev/null | tail -1)
1356
- CC_MODEL=$(echo "$_LAST" | jq -r '.message.model // empty' 2>/dev/null)
1357
- TOTALS=$(grep '"type":"assistant"' "$TRANSCRIPT_PATH" 2>/dev/null \\
1358
- | jq -c '.message.usage' 2>/dev/null \\
1359
- | 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 \\
1360
- || echo '{"in":0,"out":0,"cw":0,"cr":0}')
1361
- TOK_IN=$(echo "$TOTALS" | jq -r '.in // 0')
1362
- TOK_OUT=$(echo "$TOTALS" | jq -r '.out // 0')
1363
- TOK_CW=$(echo "$TOTALS" | jq -r '.cw // 0')
1364
- TOK_CR=$(echo "$TOTALS" | jq -r '.cr // 0')
1365
- HAS_TOKENS=$(( TOK_IN + TOK_OUT ))
1366
- if [ "$HAS_TOKENS" != "0" ]; then
1367
- CC_USAGE="{"input_tokens":$TOK_IN,"output_tokens":$TOK_OUT,"cache_creation_input_tokens":$TOK_CW,"cache_read_input_tokens":$TOK_CR}"
1368
- BODY=$(jq -n \\
1369
- --arg event_id "usage_$(date +%s)_$$" \\
1370
- --arg hook_type "stop" --arg verdict "allow" --arg severity "none" \\
1371
- --arg model "\${CC_MODEL:-claude-sonnet-4-6}" \\
1372
- --arg cc_model "\${CC_MODEL:-}" \\
1373
- --arg repo "\${GIT_REPO:-}" --arg session_id "$SESSION_ID" \\
1374
- --argjson cc_usage "$CC_USAGE" \\
1375
- '{capture_type:"local_verdict",event_id:$event_id,hook_type:$hook_type,verdict:$verdict,severity:$severity,model:$model,cc_usage:$cc_usage}
1376
- + (if $repo != "" then {repo:$repo} else {} end)
1377
- + (if $session_id != "" then {session_id:$session_id} else {} end)
1378
- + (if $cc_model != "" then {cc_model:$cc_model} else {} end)')
1379
- curl -sS -X POST "\${GATEWAY_URL}/api/v1/hook/capture" \\
1380
- -H "Content-Type: application/json" -H "Authorization: Bearer $JWT" \\
1381
- -d "$BODY" --max-time 2 >/dev/null 2>&1
1382
- fi
1383
- ) &
1384
- fi
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
+ }
1385
878
 
1386
- RESP=$(curl -sS -G "\${GATEWAY_URL}/api/v1/cli/session-summary" \\
1387
- --data-urlencode "session_id=$SESSION_ID" \\
1388
- -H "Authorization: Bearer $JWT" --max-time 2 2>/dev/null || echo "")
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');
1389
882
 
1390
- if [ -z "$RESP" ]; then echo '{}'; exit 0; fi
883
+ // \u2500\u2500\u2500 Logging \u2500\u2500\u2500
1391
884
 
1392
- EDITS=$(echo "$RESP" | jq -r '.edits_scanned // 0' 2>/dev/null)
1393
- FINDINGS=$(echo "$RESP" | jq -r '.findings // 0' 2>/dev/null)
1394
- AUTO_FIXED=$(echo "$RESP" | jq -r '.auto_fixed // 0' 2>/dev/null)
1395
- OPEN=$(echo "$RESP" | jq -r '.open // 0' 2>/dev/null)
885
+ export function log(msg: string): void {
886
+ process.stderr.write('[synkro] ' + msg + '\\n');
887
+ }
1396
888
 
1397
- if [ "\${EDITS:-0}" = "0" ] || [ -z "$EDITS" ]; then echo '{}'; exit 0; fi
889
+ // \u2500\u2500\u2500 JWT Management \u2500\u2500\u2500
1398
890
 
1399
- synkro_load_config
1400
- TAG=$(synkro_tag)
1401
- if [ "\${FINDINGS:-0}" = "0" ] || [ -z "$FINDINGS" ]; then
1402
- SYS_MSG="$TAG stop \u2192 0 issues across \${EDITS} edit(s), session complete"
1403
- else
1404
- SYS_MSG="$TAG stop \u2192 \${FINDINGS} finding(s): \${AUTO_FIXED} auto-fixed, \${OPEN} open"
1405
- fi
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
+ }
1406
900
 
1407
- jq -n --arg m "$SYS_MSG" '{systemMessage: $m}'
1408
- exit 0
1409
- `;
1410
- CC_SESSION_START_SCRIPT = `#!/bin/bash
1411
- SCRIPT_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
1412
- . "$SCRIPT_DIR/_synkro-common.sh"
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
+ }
1413
914
 
1414
- JWT=$(synkro_load_jwt)
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;
1415
920
 
1416
- # Route preamble
1417
- SYNKRO_PORT="\${SYNKRO_CHANNEL_PORT:-8929}"
1418
- PAYLOAD=$(cat)
1419
- CWD=$(echo "$PAYLOAD" | jq -r '.cwd // empty' 2>/dev/null)
1420
- SESSION_ID=$(echo "$PAYLOAD" | jq -r '.session_id // empty' 2>/dev/null)
1421
- GIT_REPO=$(synkro_detect_repo "\${CWD:-.}")
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
+ }
1422
944
 
1423
- RESP=""
1424
- if [ -n "$JWT" ]; then
1425
- RESP=$(curl -sS -G "\${GATEWAY_URL}/api/v1/hook/config" \\
1426
- --data-urlencode "session_id=\${SESSION_ID:-}" \\
1427
- --data-urlencode "repo=\${GIT_REPO:-}" \\
1428
- -H "Authorization: Bearer $JWT" --max-time 3 2>/dev/null || echo "")
1429
- if [ -n "$RESP" ]; then
1430
- SYNKRO_SILENT=$(echo "$RESP" | jq -r '.silent_mode // false' 2>/dev/null)
1431
- SYNKRO_POLICY_NAME=$(echo "$RESP" | jq -r '.active_policy_name // empty' 2>/dev/null)
1432
- fi
1433
- fi
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;
1434
950
 
1435
- if (exec 3<>/dev/tcp/127.0.0.1/"$SYNKRO_PORT") 2>/dev/null; then
1436
- exec 3<&- 3>&- 2>/dev/null || true
1437
- TAG=$(synkro_tag "local")
1438
- ROUTE_LINE="$TAG inference: local-cc (channel reachable on 127.0.0.1:$SYNKRO_PORT)"
1439
- else
1440
- TAG=$(synkro_tag "cloud")
1441
- ROUTE_LINE="$TAG inference: cloud (local-cc channel not reachable)"
1442
- fi
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
+ }
1443
966
 
1444
- if [ -z "$JWT" ]; then
1445
- jq -n --arg m "$ROUTE_LINE" '{systemMessage: $m}'
1446
- exit 0
1447
- fi
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
+ }
1448
981
 
1449
- OPEN=0
1450
- if [ -n "$RESP" ]; then
1451
- OPEN=$(echo "$RESP" | jq -r '.session_context.open_findings // 0' 2>/dev/null)
1452
- fi
982
+ // \u2500\u2500\u2500 Repo Detection \u2500\u2500\u2500
1453
983
 
1454
- if [ "$OPEN" = "0" ] || [ -z "$OPEN" ]; then
1455
- jq -n --arg m "$ROUTE_LINE" '{systemMessage: $m}'
1456
- else
1457
- if [ "$OPEN" = "1" ]; then
1458
- SYS_MSG="$ROUTE_LINE"$'\\n'"$TAG session start \u2192 1 open finding in this repo from a prior session."
1459
- else
1460
- SYS_MSG="$ROUTE_LINE"$'\\n'"$TAG session start \u2192 \${OPEN} open findings in this repo from prior sessions."
1461
- fi
1462
- jq -n --arg m "$SYS_MSG" '{systemMessage: $m}'
1463
- fi
1464
- exit 0
1465
- `;
1466
- CC_BASH_FOLLOWUP_SCRIPT = `#!/bin/bash
1467
- SCRIPT_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
1468
- . "$SCRIPT_DIR/_synkro-common.sh"
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
+ }
1469
996
 
1470
- JWT=$(synkro_load_jwt)
1471
- if [ -z "$JWT" ]; then echo '{}'; exit 0; fi
997
+ // \u2500\u2500\u2500 Channel Health \u2500\u2500\u2500
1472
998
 
1473
- PAYLOAD=$(cat)
1474
- TOOL_NAME=$(echo "$PAYLOAD" | jq -r '.tool_name // empty' 2>/dev/null)
1475
- if [ "$TOOL_NAME" != "Bash" ]; then echo '{}'; exit 0; fi
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
+ }
1476
1012
 
1477
- SESSION_ID=$(echo "$PAYLOAD" | jq -r '.session_id // empty' 2>/dev/null)
1478
- TOOL_USE_ID=$(echo "$PAYLOAD" | jq -r '.tool_use_id // empty' 2>/dev/null)
1479
- if [ -z "$SESSION_ID" ] || [ -z "$TOOL_USE_ID" ]; then echo '{}'; exit 0; fi
1013
+ export async function cweChannelUp(): Promise<boolean> {
1014
+ return channelUp(8930);
1015
+ }
1480
1016
 
1481
- # Extract is_error from tool_result and compute command hash for consent tracking
1482
- IS_ERROR=$(echo "$PAYLOAD" | jq -r '.tool_result.is_error // false' 2>/dev/null)
1483
- CMD=$(echo "$PAYLOAD" | jq -r '.tool_input.command // empty' 2>/dev/null)
1484
- CMD_HASH=""
1485
- if [ -n "$CMD" ]; then
1486
- CMD_HASH=$(printf '%s' "$CMD" | shasum -a 256 | cut -c1-16)
1487
- fi
1017
+ // \u2500\u2500\u2500 Config Loading \u2500\u2500\u2500
1488
1018
 
1489
- BODY=$(jq -n --arg sid "$SESSION_ID" --arg tid "$TOOL_USE_ID" \\
1490
- --argjson err "$IS_ERROR" --arg ch "$CMD_HASH" \\
1491
- '{capture_type:"bash_followup",session_id:$sid,tool_use_id:$tid,is_error:$err,command_hash:$ch}')
1019
+ export interface Rule {
1020
+ rule_id: string;
1021
+ text: string;
1022
+ severity: string;
1023
+ category: string;
1024
+ mode: string;
1025
+ }
1492
1026
 
1493
- # Local consent tracking: command ran = user consented
1494
- # On success \u2192 consume (next attempt blocks fresh)
1495
- # On failure \u2192 grant active (retry allowed)
1496
- # Consent tracking: on success \u2192 consume (next run blocks fresh), on error \u2192 grant (retry allowed)
1497
- if [ -n "$CMD_HASH" ] && [ -n "$SESSION_ID" ]; then
1498
- if [ "$IS_ERROR" = "false" ]; then
1499
- synkro_consent_consume "$SESSION_ID" "$CMD_HASH"
1500
- else
1501
- if ! synkro_consent_has_active "$SESSION_ID" "$CMD_HASH"; then
1502
- synkro_consent_grant "$SESSION_ID" "$CMD_HASH"
1503
- fi
1504
- fi
1505
- fi
1027
+ export interface HookConfig {
1028
+ captureDepth: string;
1029
+ tier: string;
1030
+ silent: boolean;
1031
+ policyName: string;
1032
+ rules: Rule[];
1033
+ }
1506
1034
 
1507
- curl -sS -X POST "\${GATEWAY_URL}/api/v1/hook/capture" \\
1508
- -H "Content-Type: application/json" -H "Authorization: Bearer $JWT" \\
1509
- -d "$BODY" --max-time 2 >/dev/null 2>&1 || true
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
+ }
1510
1068
 
1511
- echo '{}'
1512
- exit 0
1513
- `;
1514
- CC_CVE_SCAN_SCRIPT = `#!/bin/bash
1515
- SCRIPT_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
1516
- . "$SCRIPT_DIR/_synkro-common.sh"
1069
+ // \u2500\u2500\u2500 Routing \u2500\u2500\u2500
1517
1070
 
1518
- JWT=$(synkro_load_jwt)
1519
- if [ -z "$JWT" ]; then echo '{}'; exit 0; fi
1520
- synkro_ensure_fresh_jwt
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
+ }
1521
1076
 
1522
- PAYLOAD=$(cat)
1523
- if [ -z "$PAYLOAD" ]; then echo '{}'; exit 0; fi
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
+ }
1524
1082
 
1525
- TOOL_NAME=$(echo "$PAYLOAD" | jq -r '.tool_name // empty' 2>/dev/null)
1526
- case "$TOOL_NAME" in Edit|Write|MultiEdit|NotebookEdit) ;; *) echo '{}'; exit 0 ;; esac
1083
+ // \u2500\u2500\u2500 Tag Building \u2500\u2500\u2500
1527
1084
 
1528
- TOOL_INPUT=$(echo "$PAYLOAD" | jq -c '.tool_input // {}' 2>/dev/null)
1529
- CWD=$(echo "$PAYLOAD" | jq -r '.cwd // empty' 2>/dev/null)
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
+ }
1530
1090
 
1531
- FILE_PATH=$(echo "$TOOL_INPUT" | jq -r '.file_path // .notebook_path // .path // empty' 2>/dev/null)
1532
- if [ -z "$FILE_PATH" ] || [ ! -f "$FILE_PATH" ]; then echo '{}'; exit 0; fi
1091
+ // \u2500\u2500\u2500 Local Grading \u2500\u2500\u2500
1533
1092
 
1534
- FILE_CONTENT=$(head -c 65536 "$FILE_PATH" 2>/dev/null || echo "")
1535
- if [ -z "$FILE_CONTENT" ]; then echo '{}'; exit 0; fi
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[];
1536
1098
 
1537
- DEPS_JSON="{}"
1538
- _PKG_DIR=$(dirname "$FILE_PATH")
1539
- while [ "$_PKG_DIR" != "/" ]; do
1540
- if [ -f "$_PKG_DIR/package.json" ]; then
1541
- DEPS_JSON=$(jq -c '(.dependencies // {}) + (.devDependencies // {})' "$_PKG_DIR/package.json" 2>/dev/null || echo "{}")
1542
- break
1543
- fi
1544
- _PKG_DIR=$(dirname "$_PKG_DIR")
1545
- done
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
+ }
1546
1107
 
1547
- synkro_load_config
1548
- ROUTE=$(synkro_route)
1549
- TAG=$(synkro_tag "$ROUTE")
1108
+ const child = spawn(cmd, args, {
1109
+ stdio: ['pipe', 'pipe', 'pipe'],
1110
+ env: { ...process.env, ...envOverride },
1111
+ });
1550
1112
 
1551
- if [ "$SYNKRO_SILENT" = "true" ]; then echo '{}'; exit 0; fi
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
+ }
1552
1133
 
1553
- BASENAME=$(basename "$FILE_PATH")
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
+ }
1554
1138
 
1555
- BODY=$(jq -n --arg fp "$FILE_PATH" --arg c "$FILE_CONTENT" --argjson deps "$DEPS_JSON" \\
1556
- '{file_path:$fp, content:$c, dependencies:$deps}')
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
+ }
1557
1143
 
1558
- RESP=$(curl -sS -X POST "\${GATEWAY_URL}/api/v1/cve-scan" \\
1559
- -H "Content-Type: application/json" -H "Authorization: Bearer $JWT" \\
1560
- -d "$BODY" --max-time 6 2>/dev/null || echo "")
1144
+ // \u2500\u2500\u2500 Verdict Parsing \u2500\u2500\u2500
1561
1145
 
1562
- if [ -z "$RESP" ] || ! echo "$RESP" | jq -e 'type == "object"' >/dev/null 2>&1; then
1563
- echo '{}'; exit 0
1564
- fi
1146
+ export interface Verdict {
1147
+ ok: boolean;
1148
+ reason: string;
1149
+ ruleId: string;
1150
+ ruleMode: string;
1151
+ severity: string;
1152
+ category: string;
1153
+ }
1565
1154
 
1566
- CVE_COUNT=$(echo "$RESP" | jq -r '.findings | length' 2>/dev/null || echo "0")
1567
- if [ "$CVE_COUNT" -gt 0 ] 2>/dev/null; then
1568
- CVE_CRIT=$(echo "$RESP" | jq '[.findings[] | select((.severity | tonumber? // 0) >= 7.0)] | length' 2>/dev/null || echo "0")
1569
- CRIT_PKGS=$(echo "$RESP" | jq -r '[.findings[] | select((.severity | tonumber? // 0) >= 7.0) | .package] | unique | .[:3] | join(", ")' 2>/dev/null || echo "")
1570
- ALL_PKGS=$(echo "$RESP" | jq -r '[.findings[].package] | unique | .[:3] | join(", ")' 2>/dev/null || echo "")
1571
- ALL_TOTAL=$(echo "$RESP" | jq -r '[.findings[].package] | unique | length' 2>/dev/null || echo "0")
1572
- [ "$CVE_COUNT" = "1" ] && LABEL="advisory" || LABEL="advisories"
1573
- if [ "$CVE_CRIT" -gt 0 ]; then
1574
- [ "$ALL_TOTAL" -gt 3 ] && CRIT_PKGS="\${CRIT_PKGS}, ..."
1575
- jq -n --arg m "[synkro:\${ROUTE}:cveScan] \${CVE_COUNT} \${LABEL}, \${CVE_CRIT} critical/high (\${CRIT_PKGS})" '{systemMessage: $m}'
1576
- else
1577
- [ "$ALL_TOTAL" -gt 3 ] && ALL_PKGS="\${ALL_PKGS}, ..."
1578
- jq -n --arg m "[synkro:\${ROUTE}:cveScan] \${CVE_COUNT} \${LABEL} (\${ALL_PKGS})" '{systemMessage: $m}'
1579
- fi
1580
- else
1581
- jq -n --arg m "[synkro:\${ROUTE}:cveScan] clean" '{systemMessage: $m}'
1582
- fi
1583
- exit 0
1584
- `;
1585
- CC_TRANSCRIPT_SYNC_SCRIPT = `#!/bin/bash
1586
- SCRIPT_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
1587
- . "$SCRIPT_DIR/_synkro-common.sh"
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
+ };
1588
1164
 
1589
- JWT=$(synkro_load_jwt)
1590
- if [ -z "$JWT" ]; then echo '{}'; exit 0; fi
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];
1591
1170
 
1592
- PAYLOAD=$(cat)
1593
- SESSION_ID=$(echo "$PAYLOAD" | jq -r '.session_id // empty' 2>/dev/null)
1594
- TRANSCRIPT_PATH=$(echo "$PAYLOAD" | jq -r '.transcript_path // empty' 2>/dev/null)
1595
- CWD=$(echo "$PAYLOAD" | jq -r '.cwd // empty' 2>/dev/null)
1171
+ const okMatch = inner.match(/<ok>(.*?)<\\/ok>/);
1172
+ if (okMatch) verdict.ok = okMatch[1].trim() !== 'false';
1596
1173
 
1597
- if [ -z "$SESSION_ID" ] || [ -z "$TRANSCRIPT_PATH" ] || [ ! -f "$TRANSCRIPT_PATH" ]; then
1598
- echo '{}'; exit 0
1599
- fi
1174
+ const reasonMatch = inner.match(/<reason>(.*?)<\\/reason>/) || inner.match(/<reasoning>(.*?)<\\/reasoning>/);
1175
+ if (reasonMatch) verdict.reason = reasonMatch[1].trim();
1600
1176
 
1601
- # Usage telemetry \u2014 last turn only (metadata, ungated by privacy/consent)
1602
- _LAST_ASST=$(grep '"type":"assistant"' "$TRANSCRIPT_PATH" 2>/dev/null | tail -1)
1603
- if [ -n "$_LAST_ASST" ]; then
1604
- _CC_MODEL=$(echo "$_LAST_ASST" | jq -r '.message.model // empty' 2>/dev/null)
1605
- _TI=$(echo "$_LAST_ASST" | jq -r '.message.usage.input_tokens // 0' 2>/dev/null)
1606
- _TO=$(echo "$_LAST_ASST" | jq -r '.message.usage.output_tokens // 0' 2>/dev/null)
1607
- _TCW=$(echo "$_LAST_ASST" | jq -r '.message.usage.cache_creation_input_tokens // 0' 2>/dev/null)
1608
- _TCR=$(echo "$_LAST_ASST" | jq -r '.message.usage.cache_read_input_tokens // 0' 2>/dev/null)
1609
- if [ "\${_TI:-0}" != "0" ] || [ "\${_TO:-0}" != "0" ]; then
1610
- (
1611
- _USAGE="{\\"input_tokens\\":$_TI,\\"output_tokens\\":$_TO,\\"cache_creation_input_tokens\\":$_TCW,\\"cache_read_input_tokens\\":$_TCR}"
1612
- _BODY=$(jq -n \\
1613
- --arg event_id "usage_$(date +%s)_$$" \\
1614
- --arg hook_type "stop" --arg verdict "allow" --arg severity "none" \\
1615
- --arg model "\${_CC_MODEL:-claude-sonnet-4-6}" \\
1616
- --arg cc_model "\${_CC_MODEL:-}" \\
1617
- --arg session_id "$SESSION_ID" \\
1618
- --argjson cc_usage "$_USAGE" \\
1619
- '{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}
1620
- + (if $cc_model != "" then {cc_model:$cc_model} else {} end)')
1621
- curl -sS -X POST "\${GATEWAY_URL}/api/v1/hook/capture" \\
1622
- -H "Content-Type: application/json" -H "Authorization: Bearer $JWT" \\
1623
- -d "$_BODY" --max-time 2 >/dev/null 2>&1
1624
- ) &
1625
- fi
1626
- fi
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>/);
1627
1181
 
1628
- # Transcript sync below is gated by consent + capture depth
1629
- if [ "\${SYNKRO_TRANSCRIPT_CONSENT:-yes}" = "no" ]; then echo '{}'; exit 0; fi
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
+ }
1630
1201
 
1631
- GIT_REPO=$(synkro_detect_repo "\${CWD:-.}")
1632
- if [ -z "$GIT_REPO" ]; then echo '{}'; exit 0; fi
1202
+ if (ruleModeMatch) verdict.ruleMode = ruleModeMatch[1].trim();
1203
+ if (sevMatch) verdict.severity = sevMatch[1].trim();
1204
+ verdict.severity = verdict.severity || 'high';
1633
1205
 
1634
- # Check capture depth \u2014 skip in local_only
1635
- CONFIG_RESP=$(curl -sS "\${GATEWAY_URL}/api/v1/hook/config" -H "Authorization: Bearer $JWT" --max-time 3 2>/dev/null || echo "")
1636
- CAPTURE_DEPTH=$(echo "$CONFIG_RESP" | jq -r '.capture_depth // "local_only"' 2>/dev/null)
1637
- if [ "$CAPTURE_DEPTH" = "local_only" ]; then echo '{}'; exit 0; fi
1206
+ const catMatch = inner.match(/<category>(.*?)<\\/category>/);
1207
+ verdict.category = catMatch ? catMatch[1].trim() : 'uncategorized';
1638
1208
 
1639
- OFFSET_DIR="$HOME/.synkro/.transcript-offsets"
1640
- mkdir -p "$OFFSET_DIR" 2>/dev/null || true
1641
- OFFSET_FILE="$OFFSET_DIR/$SESSION_ID"
1642
- OFFSET=0
1643
- [ -f "$OFFSET_FILE" ] && OFFSET=$(cat "$OFFSET_FILE" 2>/dev/null || echo "0")
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
+ }
1644
1215
 
1645
- TOTAL_LINES=$(wc -l < "$TRANSCRIPT_PATH" 2>/dev/null | tr -d ' ')
1646
- if [ -z "$TOTAL_LINES" ] || [ "$TOTAL_LINES" -le "$OFFSET" ] 2>/dev/null; then echo '{}'; exit 0; fi
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;
1647
1260
 
1648
- DELTA=$((TOTAL_LINES - OFFSET))
1649
- START_LINE=$((OFFSET + 1))
1650
- [ "$DELTA" -gt 200 ] && START_LINE=$((TOTAL_LINES - 199))
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
+ }
1651
1269
 
1652
- MESSAGES=$(tail -n +"$START_LINE" "$TRANSCRIPT_PATH" 2>/dev/null | jq -c --argjson base_idx "$((START_LINE - 1))" '
1653
- . as $line |
1654
- if ($line.type == "user" or $line.type == "assistant") then
1655
- {
1656
- message_index: (input_line_number + $base_idx),
1657
- type: $line.type,
1658
- 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),
1659
- 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),
1660
- model: ($line.message.model // null),
1661
- 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)
1662
- }
1663
- else empty end
1664
- ' 2>/dev/null | jq -s '.' 2>/dev/null)
1665
-
1666
- if [ -z "$MESSAGES" ] || [ "$MESSAGES" = "[]" ] || [ "$MESSAGES" = "null" ]; then
1667
- printf '%s' "$TOTAL_LINES" > "$OFFSET_FILE" 2>/dev/null || true
1668
- echo '{}'; exit 0
1669
- fi
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
+ }
1670
1277
 
1671
- BODY=$(jq -n --arg repo "$GIT_REPO" --arg sid "$SESSION_ID" --argjson messages "$MESSAGES" \\
1672
- '{repo: $repo, sessions: [{cc_session_id: $sid, messages: $messages}]}')
1278
+ // \u2500\u2500\u2500 Rule Mode Lookup \u2500\u2500\u2500
1673
1279
 
1674
- (
1675
- curl -sS -X POST "\${GATEWAY_URL}/api/v1/cli/sync-transcripts" \\
1676
- -H "Content-Type: application/json" -H "Authorization: Bearer $JWT" \\
1677
- -d "$BODY" --max-time 10 >/dev/null 2>&1
1678
- ) &
1679
- disown 2>/dev/null || true
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
+ }
1680
1286
 
1681
- printf '%s' "$TOTAL_LINES" > "$OFFSET_FILE" 2>/dev/null || true
1682
- echo '{}'; exit 0
1683
- `;
1684
- CURSOR_BASH_JUDGE_SCRIPT = `#!/bin/bash
1685
- SCRIPT_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
1686
- . "$SCRIPT_DIR/_synkro-common.sh"
1287
+ // \u2500\u2500\u2500 Content Reconstruction \u2500\u2500\u2500
1687
1288
 
1688
- JWT=$(synkro_load_jwt)
1689
- if [ -z "$JWT" ]; then echo '{}'; exit 0; fi
1690
- synkro_ensure_fresh_jwt
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
+ }
1691
1331
 
1692
- PAYLOAD=$(cat)
1693
- if [ -z "$PAYLOAD" ]; then echo '{}'; exit 0; fi
1332
+ // \u2500\u2500\u2500 HTTP with Retry \u2500\u2500\u2500
1694
1333
 
1695
- COMMAND=$(echo "$PAYLOAD" | jq -r '.command // empty' 2>/dev/null)
1696
- if [ -z "$COMMAND" ]; then echo '{}'; exit 0; fi
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
+ }
1697
1347
 
1698
- CWD=$(echo "$PAYLOAD" | jq -r '.cwd // empty' 2>/dev/null)
1699
- SESSION_ID=$(echo "$PAYLOAD" | jq -r '.conversation_id // empty' 2>/dev/null)
1700
- GIT_REPO=$(synkro_detect_repo "\${CWD:-.}")
1348
+ let data: any;
1349
+ try { data = await resp.json(); } catch { return null; }
1701
1350
 
1702
- CMD_SHORT=$(printf '%s' "$COMMAND" | head -c 80)
1703
- synkro_log "bashGuard checking: $CMD_SHORT"
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
+ }
1704
1366
 
1705
- synkro_load_config
1706
- if [ "$SYNKRO_SILENT" = "true" ]; then
1707
- echo '{}'; exit 0
1708
- fi
1367
+ return data;
1368
+ }
1709
1369
 
1710
- BODY=$(jq -n \\
1711
- --arg cmd "$COMMAND" \\
1712
- --arg session_id "$SESSION_ID" \\
1713
- --arg cwd "$CWD" \\
1714
- --arg repo "$GIT_REPO" \\
1715
- '{
1716
- hook_event: "PreToolUse",
1717
- tool_name: "Bash",
1718
- tool_input: {command: $cmd},
1719
- response_format: "cursor",
1720
- session_id: (if ($session_id | length) > 0 then $session_id else null end),
1721
- cwd: (if ($cwd | length) > 0 then $cwd else null end),
1722
- repo: (if ($repo | length) > 0 then $repo else null end)
1723
- }')
1370
+ // \u2500\u2500\u2500 Read Stdin \u2500\u2500\u2500
1724
1371
 
1725
- RESP=$(synkro_post_with_retry "\${GATEWAY_URL}/api/v1/hook/judge" "$BODY" 6)
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
+ }
1726
1379
 
1727
- if [ -z "$RESP" ]; then
1728
- synkro_log "bashGuard $CMD_SHORT \u2192 error (timeout)"
1729
- echo '{}'; exit 0
1730
- fi
1380
+ // \u2500\u2500\u2500 Transcript Extraction \u2500\u2500\u2500
1731
1381
 
1732
- # Server returns cursor-format directly in hook_response
1733
- if echo "$RESP" | jq -e '.hook_response' >/dev/null 2>&1; then
1734
- echo "$RESP" | jq -c '.hook_response'
1735
- else
1736
- echo '{}'
1737
- fi
1738
- exit 0
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
+ }
1739
1591
  `;
1740
- CURSOR_EDIT_PRECHECK_SCRIPT = `#!/bin/bash
1741
- SCRIPT_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
1742
- . "$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; }
1813
+
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
+ }
1820
+
1821
+ const toolInput = payload.tool_input || {};
1822
+ const sessionId = payload.session_id || '';
1823
+ const cwd = payload.cwd || '';
1824
+ const gitRepo = detectRepo(cwd || '.');
1825
+
1826
+ const filePath = toolInput.file_path || toolInput.notebook_path || toolInput.path || '';
1827
+ if (!filePath) { outputEmpty(); return; }
1828
+
1829
+ const fileShort = basename(filePath);
1830
+ const fileExt = extname(filePath); // e.g. ".ts"
1831
+
1832
+ let jwt = loadJwt();
1833
+ if (!jwt) { outputEmpty(); return; }
1834
+ jwt = await ensureFreshJwt(jwt);
1835
+
1836
+ // Reconstruct proposed content
1837
+ const proposed = reconstructContent(toolName, toolInput, filePath);
1838
+ if (!proposed) { outputEmpty(); return; }
1839
+
1840
+ const config = await loadConfig(jwt);
1841
+ const rt = await cweRoute(config);
1842
+
1843
+ if (config.silent) {
1844
+ outputJson({ systemMessage: '[synkro:' + rt + ':cweScan] ' + fileShort + ' \\u2192 skipped (silent mode)' });
1845
+ return;
1846
+ }
1847
+
1848
+ const cweTag = '[synkro:' + rt + ':cweScan]';
1849
+
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 {}
1861
+
1862
+ if (cweRules.length === 0) {
1863
+ outputJson({ systemMessage: cweTag + ' ' + fileShort + ' \\u2192 clean (no CWE rules for ' + fileExt + ')' });
1864
+ return;
1865
+ }
1866
+
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
+ }
1884
+
1885
+ const verdict = parseVerdict(gradeResp);
1886
+
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
+ }
1908
+
1909
+ outputJson({ systemMessage: cweTag + ' ' + fileShort + ' \\u2192 clean' });
1910
+ return;
1911
+ }
1912
+
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
+ }
1921
+
1922
+ main();
1923
+ `;
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
+ }
1944
+
1945
+ async function main() {
1946
+ try {
1947
+ const input = await readStdin();
1948
+ if (!input.trim()) { outputEmpty(); return; }
1949
+
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
+ }
1956
+
1957
+ const toolInput = payload.tool_input || {};
1958
+ const cwd = payload.cwd || '';
1959
+
1960
+ const filePath = toolInput.file_path || toolInput.notebook_path || toolInput.path || '';
1961
+ if (!filePath) { outputEmpty(); return; }
1962
+
1963
+ const fileShort = basename(filePath);
1964
+
1965
+ let jwt = loadJwt();
1966
+ if (!jwt) { outputEmpty(); return; }
1967
+ jwt = await ensureFreshJwt(jwt);
1968
+
1969
+ const config = await loadConfig(jwt);
1970
+ const rt = await route(config);
1971
+
1972
+ if (config.silent) {
1973
+ outputJson({ systemMessage: '[synkro:' + rt + ':cveScan] ' + fileShort + ' \\u2192 skipped (silent mode)' });
1974
+ return;
1975
+ }
1976
+
1977
+ const cveTag = '[synkro:' + rt + ':cveScan]';
1978
+
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
+ }
1985
+
1986
+ const proposedShort = proposed.slice(0, 4000);
1987
+
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
+ }
1993
+
1994
+ // CVE scan via OSV API
1995
+ const cveBody = {
1996
+ file_path: filePath,
1997
+ content: proposedShort,
1998
+ dependencies: deps,
1999
+ };
2000
+
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
+ }
2014
+
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
+ }
2036
+
2037
+ outputJson({ systemMessage: cveTag + ' ' + fileShort + ' \\u2192 clean' });
2038
+ } catch (err) {
2039
+ process.stderr.write('[synkro] cveGuard error: ' + String(err) + '\\n');
2040
+ outputEmpty();
2041
+ }
2042
+ }
2043
+
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';
2054
+
2055
+ async function main() {
2056
+ try {
2057
+ const input = await readStdin();
2058
+ if (!input.trim()) { outputEmpty(); return; }
2059
+
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
+ }
2066
+
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; }
2083
+
2084
+ const cmdShort = command.slice(0, 80);
2085
+ log('bashGuard checking: ' + cmdShort);
2086
+
2087
+ let jwt = loadJwt();
2088
+ if (!jwt) { outputEmpty(); return; }
2089
+ jwt = await ensureFreshJwt(jwt);
2090
+
2091
+ const transcript = extractTranscript(transcriptPath);
2092
+ const lastPrompt = readLastPrompt();
2093
+
2094
+ const config = await loadConfig(jwt);
2095
+ const rt = await route(config);
2096
+ const tagStr = tag(rt, config);
2097
+
2098
+ if (config.silent) {
2099
+ outputJson({ systemMessage: tagStr + ' bashGuard \\u2192 skipped (silent mode)' });
2100
+ return;
2101
+ }
2102
+
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
+ }
2120
+
2121
+ const verdict = parseVerdict(gradeResp);
2122
+ const violatedRules = verdict.ruleId ? [verdict.ruleId] : [];
2123
+
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');
2127
+
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
+ }
2159
+
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
+ };
2183
+
2184
+ const resp = await postWithRetry(GATEWAY_URL + '/api/v1/hook/judge', body, jwt, 8000);
2185
+
2186
+ if (!resp) {
2187
+ log('bashGuard ' + cmdShort + ' \\u2192 error (timeout)');
2188
+ outputEmpty();
2189
+ return;
2190
+ }
2191
+
2192
+ if (!resp.hook_response || typeof resp.hook_response !== 'object') {
2193
+ log('bashGuard ' + cmdShort + ' \\u2192 pass (no hook_response)');
2194
+ outputEmpty();
2195
+ return;
2196
+ }
2197
+
2198
+ outputJson(resp.hook_response);
2199
+ } catch (err) {
2200
+ process.stderr.write('[synkro] bashGuard error: ' + String(err) + '\\n');
2201
+ outputEmpty();
2202
+ }
2203
+ }
2204
+
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
+ }
2230
+
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
+ }
2240
+
2241
+ async function main() {
2242
+ try {
2243
+ const input = await readStdin();
2244
+ if (!input.trim()) { outputEmpty(); return; }
2245
+
2246
+ const payload = JSON.parse(input);
2247
+ const toolName = payload.tool_name || '';
2248
+ if (toolName !== 'ExitPlanMode') { outputEmpty(); return; }
2249
+
2250
+ const planFile = findLatestPlan();
2251
+ if (!planFile) { outputEmpty(); return; }
2252
+ const plan = readFileSync(planFile, 'utf-8');
2253
+ if (plan.length < 20) { outputEmpty(); return; }
2254
+
2255
+ const sessionId = payload.session_id || '';
2256
+ const cwd = payload.cwd || '';
2257
+ const gitRepo = detectRepo(cwd || '.');
2258
+
2259
+ const planShort = plan.slice(0, 80);
2260
+ log('planReview checking: ' + planShort + '...');
2261
+
2262
+ let jwt = loadJwt();
2263
+ if (!jwt) { outputEmpty(); return; }
2264
+ jwt = await ensureFreshJwt(jwt);
2265
+
2266
+ const config = await loadConfig(jwt);
2267
+ const rt = await route(config);
2268
+ const tagStr = tag(rt, config);
2269
+
2270
+ if (config.silent) {
2271
+ outputJson({ systemMessage: tagStr + ' planReview \\u2192 skipped (silent mode)' });
2272
+ return;
2273
+ }
2274
+
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');
2283
+
2284
+ let gradeResp: string;
2285
+ try {
2286
+ gradeResp = await localGrade('plan', graderPrompt);
2287
+ } catch {
2288
+ outputEmpty();
2289
+ return;
2290
+ }
2291
+
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
+ }
2317
+
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
+ };
2327
+
2328
+ const resp = await postWithRetry(GATEWAY_URL + '/api/v1/hook/judge', body, jwt, 12000);
2329
+
2330
+ if (!resp) {
2331
+ log('planReview \\u2192 error (timeout)');
2332
+ outputEmpty();
2333
+ return;
2334
+ }
2335
+
2336
+ const hookResp = resp?.hook_response;
2337
+ if (!hookResp) { outputEmpty(); return; }
2338
+
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
+ }
2354
+
2355
+ main();
2356
+ `;
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';
2362
+
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';
2449
+
2450
+ async function main() {
2451
+ try {
2452
+ const input = await readStdin();
2453
+ if (!input.trim()) { outputEmpty(); return; }
2454
+
2455
+ const payload = JSON.parse(input);
2456
+ const cwd = payload.cwd || '';
2457
+ const sessionId = payload.session_id || '';
2458
+ const gitRepo = detectRepo(cwd || '.');
2459
+
2460
+ let jwt = loadJwt();
2461
+
2462
+ const isChannelUp = await channelUp();
2463
+ const rt = isChannelUp ? 'local' : 'cloud';
2464
+
2465
+ let policyName = '';
2466
+ let silent = false;
2467
+ let openFindings = 0;
2468
+
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
+ }
2482
+
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)');
2486
+
2487
+ if (!jwt) {
2488
+ outputJson({ systemMessage: routeLine });
2489
+ return;
2490
+ }
2491
+
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();
2506
+ `;
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';
1743
2512
 
1744
- JWT=$(synkro_load_jwt)
1745
- if [ -z "$JWT" ]; then echo '{}'; exit 0; fi
1746
- synkro_ensure_fresh_jwt
2513
+ async function main() {
2514
+ try {
2515
+ const input = await readStdin();
2516
+ if (!input.trim()) { outputEmpty(); return; }
1747
2517
 
1748
- PAYLOAD=$(cat)
1749
- 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; }
1750
2521
 
1751
- TOOL_NAME=$(echo "$PAYLOAD" | jq -r '.tool_name // empty' 2>/dev/null)
1752
- CWD=$(echo "$PAYLOAD" | jq -r '.cwd // empty' 2>/dev/null)
1753
- SESSION_ID=$(echo "$PAYLOAD" | jq -r '.conversation_id // empty' 2>/dev/null)
1754
- GIT_REPO=$(synkro_detect_repo "\${CWD:-.}")
2522
+ const jwt = loadJwt();
2523
+ if (!jwt) { outputEmpty(); return; }
1755
2524
 
1756
- FILE_PATH=$(echo "$PAYLOAD" | jq -r '.tool_input.file_path // .tool_input.path // .tool_input.target_file // empty' 2>/dev/null)
1757
- CONTENT=$(echo "$PAYLOAD" | jq -r '.tool_input.content // .tool_input.new_string // .tool_input.code_edit // empty' 2>/dev/null)
1758
- 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; }
1759
2528
 
1760
- BASENAME=$(basename "$FILE_PATH" 2>/dev/null || echo "$FILE_PATH")
1761
- 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) : '';
1762
2532
 
1763
- synkro_load_config
1764
- if [ "$SYNKRO_SILENT" = "true" ]; then
1765
- echo '{}'; exit 0
1766
- 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
+ }
1767
2542
 
1768
- BODY=$(jq -n \\
1769
- --arg file_path "$FILE_PATH" \\
1770
- --arg content "$CONTENT" \\
1771
- --arg session_id "$SESSION_ID" \\
1772
- --arg cwd "$CWD" \\
1773
- --arg repo "$GIT_REPO" \\
1774
- '{
1775
- hook_event: "PreToolUse",
1776
- tool_name: "Edit",
1777
- tool_input: {file_path: $file_path, content: $content},
1778
- file_path: $file_path,
1779
- content: $content,
1780
- response_format: "cursor",
1781
- session_id: (if ($session_id | length) > 0 then $session_id else null end),
1782
- cwd: (if ($cwd | length) > 0 then $cwd else null end),
1783
- repo: (if ($repo | length) > 0 then $repo else null end)
1784
- }')
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
+ };
1785
2550
 
1786
- 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(() => {});
1787
2557
 
1788
- if [ -z "$RESP" ]; then
1789
- synkro_log "editGuard $BASENAME \u2192 error (timeout)"
1790
- echo '{}'; exit 0
1791
- fi
2558
+ outputEmpty();
2559
+ } catch {
2560
+ outputEmpty();
2561
+ }
2562
+ }
1792
2563
 
1793
- if echo "$RESP" | jq -e '.hook_response' >/dev/null 2>&1; then
1794
- echo "$RESP" | jq -c '.hook_response'
1795
- else
1796
- echo '{}'
1797
- fi
1798
- exit 0
2564
+ main();
1799
2565
  `;
1800
- CURSOR_EDIT_CAPTURE_SCRIPT = `#!/bin/bash
1801
- SCRIPT_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
1802
- . "$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';
1803
2574
 
1804
- JWT=$(synkro_load_jwt)
1805
- 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; }
1806
2579
 
1807
- PAYLOAD=$(cat)
1808
- 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 || '';
1809
2584
 
1810
- FILE_PATH=$(echo "$PAYLOAD" | jq -r '.file_path // empty' 2>/dev/null)
1811
- if [ -z "$FILE_PATH" ]; then echo '{}'; exit 0; fi
2585
+ if (!sessionId || !transcriptPath || !existsSync(transcriptPath)) {
2586
+ outputEmpty();
2587
+ return;
2588
+ }
1812
2589
 
1813
- CWD=$(echo "$PAYLOAD" | jq -r '.cwd // .workspace_roots[0] // empty' 2>/dev/null)
1814
- SESSION_ID=$(echo "$PAYLOAD" | jq -r '.conversation_id // empty' 2>/dev/null)
1815
- GIT_REPO=$(synkro_detect_repo "\${CWD:-.}")
1816
- 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
+ }
1817
2618
 
1818
- FULL_PATH="$FILE_PATH"
1819
- [ -n "$CWD" ] && FULL_PATH="$CWD/$FILE_PATH"
1820
- FULL_CONTENT=""
1821
- [ -f "$FULL_PATH" ] && FULL_CONTENT=$(head -c 50000 "$FULL_PATH" 2>/dev/null || true)
2619
+ if (process.env.SYNKRO_TRANSCRIPT_CONSENT === 'no') { outputEmpty(); return; }
1822
2620
 
1823
- DEPS_JSON="{}"
1824
- _PKG_DIR="\${CWD:-.}"
1825
- while [ "$_PKG_DIR" != "/" ]; do
1826
- if [ -f "$_PKG_DIR/package.json" ]; then
1827
- DEPS_JSON=$(jq -c '(.dependencies // {}) + (.devDependencies // {})' "$_PKG_DIR/package.json" 2>/dev/null || echo "{}")
1828
- break
1829
- fi
1830
- _PKG_DIR=$(dirname "$_PKG_DIR")
1831
- done
2621
+ const gitRepo = detectRepo(cwd || '.');
2622
+ if (!gitRepo) { outputEmpty(); return; }
1832
2623
 
1833
- 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
+ }
1834
2643
 
1835
- (
1836
- BODY=$(jq -n \\
1837
- --arg file_path "$FILE_PATH" --arg content "$FULL_CONTENT" \\
1838
- --arg session_id "$SESSION_ID" --arg cwd "$CWD" --arg repo "$GIT_REPO" \\
1839
- --argjson deps "$DEPS_JSON" \\
1840
- '{capture_type:"edit_scan",tool_input:{file_path:$file_path,content:$content},edit_verdict:{ok:true},dependencies:$deps}
1841
- + (if ($session_id | length) > 0 then {session_id:$session_id} else {} end)
1842
- + (if ($cwd | length) > 0 then {cwd:$cwd} else {} end)
1843
- + (if ($repo | length) > 0 then {repo:$repo} else {} end)')
1844
- curl -sS -X POST "\${GATEWAY_URL}/api/v1/hook/capture" \\
1845
- -H "Content-Type: application/json" -H "Authorization: Bearer $JWT" \\
1846
- -d "$BODY" --max-time 10 >/dev/null 2>&1 || true
1847
- ) &
1848
- 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;
1849
2647
 
1850
- echo '{}'
1851
- exit 0
1852
- `;
1853
- CURSOR_BASH_FOLLOWUP_SCRIPT = `#!/bin/bash
1854
- SCRIPT_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
1855
- . "$SCRIPT_DIR/_synkro-common.sh"
2648
+ if (totalLines <= offset) { outputEmpty(); return; }
1856
2649
 
1857
- JWT=$(synkro_load_jwt)
1858
- if [ -z "$JWT" ]; then echo '{}'; exit 0; fi
2650
+ let startIdx = offset;
2651
+ const delta = totalLines - offset;
2652
+ if (delta > 200) startIdx = totalLines - 200;
1859
2653
 
1860
- PAYLOAD=$(cat)
1861
- TOOL_NAME=$(echo "$PAYLOAD" | jq -r '.tool_name // empty' 2>/dev/null)
1862
- 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
+ }
1863
2669
 
1864
- SESSION_ID=$(echo "$PAYLOAD" | jq -r '.conversation_id // empty' 2>/dev/null)
1865
- 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
+ }
1866
2683
 
1867
- IS_ERROR=$(echo "$PAYLOAD" | jq -r '.tool_result.is_error // false' 2>/dev/null)
1868
- CMD=$(echo "$PAYLOAD" | jq -r '.tool_input.command // empty' 2>/dev/null)
1869
- CMD_HASH=""
1870
- if [ -n "$CMD" ]; then
1871
- CMD_HASH=$(printf '%s' "$CMD" | shasum -a 256 | cut -c1-16)
1872
- fi
2684
+ writeFileSync(offsetFile, String(totalLines), 'utf-8');
1873
2685
 
1874
- if [ -n "$CMD_HASH" ] && [ -n "$SESSION_ID" ]; then
1875
- if [ "$IS_ERROR" = "false" ]; then
1876
- synkro_consent_consume "$SESSION_ID" "$CMD_HASH"
1877
- else
1878
- if ! synkro_consent_has_active "$SESSION_ID" "$CMD_HASH"; then
1879
- synkro_consent_grant "$SESSION_ID" "$CMD_HASH"
1880
- fi
1881
- fi
1882
- fi
2686
+ if (messages.length === 0) { outputEmpty(); return; }
1883
2687
 
1884
- if [ -n "$SESSION_ID" ] && [ -n "$TOOL_USE_ID" ]; then
1885
- (
1886
- BODY=$(jq -n --arg sid "$SESSION_ID" --arg tid "$TOOL_USE_ID" \\
1887
- --argjson err "$IS_ERROR" --arg ch "$CMD_HASH" \\
1888
- '{capture_type:"bash_followup",session_id:$sid,tool_use_id:$tid,is_error:$err,command_hash:$ch}')
1889
- curl -sS -X POST "\${GATEWAY_URL}/api/v1/hook/capture" \\
1890
- -H "Content-Type: application/json" -H "Authorization: Bearer $JWT" \\
1891
- -d "$BODY" --max-time 3 >/dev/null 2>&1 || true
1892
- ) &
1893
- disown 2>/dev/null || true
1894
- 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
+ }
1895
2704
 
1896
- echo '{}'
1897
- exit 0
2705
+ main();
2706
+ `;
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();
1898
2728
  `;
1899
2729
  }
1900
2730
  });
@@ -3223,27 +4053,40 @@ function writePluginFiles() {
3223
4053
  PLUGIN_SETTINGS_PATH,
3224
4054
  JSON.stringify({
3225
4055
  fastMode: true,
3226
- // Pre-approve the project-local synkro-local MCP server so claude doesn't
3227
- // block on a consent prompt at startup. Lives in the PROJECT settings so
3228
- // it's still picked up under --setting-sources project,local (which
3229
- // skips user settings to avoid synkro-hook recursion in the grader).
3230
4056
  enabledMcpjsonServers: ["synkro-local"]
3231
4057
  }, null, 2) + "\n",
3232
4058
  "utf-8"
3233
4059
  );
3234
4060
  writeFileSync6(RUN_SCRIPT_PATH, RUN_SCRIPT_SOURCE, "utf-8");
3235
4061
  chmodSync(RUN_SCRIPT_PATH, 493);
4062
+ mkdirSync6(SESSION_DIR_2, { recursive: true });
4063
+ mkdirSync6(PLUGIN_SETTINGS_DIR_2, { recursive: true });
4064
+ writeFileSync6(PLUGIN_PATH_2, CHANNEL_PLUGIN_SOURCE, "utf-8");
4065
+ chmodSync(PLUGIN_PATH_2, 493);
4066
+ writeFileSync6(PLUGIN_PKG_PATH_2, PLUGIN_PACKAGE_JSON, "utf-8");
4067
+ writeFileSync6(
4068
+ PLUGIN_SETTINGS_PATH_2,
4069
+ JSON.stringify({
4070
+ fastMode: true,
4071
+ enabledMcpjsonServers: ["synkro-local"]
4072
+ }, null, 2) + "\n",
4073
+ "utf-8"
4074
+ );
4075
+ writeFileSync6(RUN_SCRIPT_PATH_2, RUN_SCRIPT_SOURCE_2, "utf-8");
4076
+ chmodSync(RUN_SCRIPT_PATH_2, 493);
3236
4077
  }
3237
4078
  function runBunInstall() {
3238
- const r = spawnSync("bun", ["install", "--silent"], {
3239
- cwd: SESSION_DIR,
3240
- encoding: "utf-8",
3241
- timeout: 12e4
3242
- });
3243
- if (r.status !== 0) {
3244
- throw new LocalCCInstallError(
3245
- `bun install failed in ${SESSION_DIR}: ${r.stderr || r.stdout || "unknown"}`
3246
- );
4079
+ for (const dir of [SESSION_DIR, SESSION_DIR_2]) {
4080
+ const r = spawnSync("bun", ["install", "--silent"], {
4081
+ cwd: dir,
4082
+ encoding: "utf-8",
4083
+ timeout: 12e4
4084
+ });
4085
+ if (r.status !== 0) {
4086
+ throw new LocalCCInstallError(
4087
+ `bun install failed in ${dir}: ${r.stderr || r.stdout || "unknown"}`
4088
+ );
4089
+ }
3247
4090
  }
3248
4091
  }
3249
4092
  function safelyMutateClaudeJson(mutator) {
@@ -3315,6 +4158,15 @@ function writeProjectMcpJson() {
3315
4158
  }
3316
4159
  };
3317
4160
  writeFileSync6(PROJECT_MCP_PATH, JSON.stringify(mcp, null, 2) + "\n", "utf-8");
4161
+ const mcp2 = {
4162
+ mcpServers: {
4163
+ [MCP_SERVER_NAME]: {
4164
+ command: "bun",
4165
+ args: [PLUGIN_PATH_2]
4166
+ }
4167
+ }
4168
+ };
4169
+ writeFileSync6(PROJECT_MCP_PATH_2, JSON.stringify(mcp2, null, 2) + "\n", "utf-8");
3318
4170
  }
3319
4171
  function patchClaudeJson() {
3320
4172
  safelyMutateClaudeJson((parsed) => {
@@ -3327,20 +4179,22 @@ function patchClaudeJson() {
3327
4179
  parsed.projects = {};
3328
4180
  }
3329
4181
  const projects = parsed.projects;
3330
- const existing = projects[SESSION_DIR] && typeof projects[SESSION_DIR] === "object" ? projects[SESSION_DIR] : {};
3331
- const wantEnabled = Array.from(/* @__PURE__ */ new Set([
3332
- ...existing.enabledMcpjsonServers ?? [],
3333
- MCP_SERVER_NAME
3334
- ]));
3335
- const next = {
3336
- ...existing,
3337
- hasTrustDialogAccepted: true,
3338
- hasCompletedProjectOnboarding: true,
3339
- enabledMcpjsonServers: wantEnabled
3340
- };
3341
- if (existing.hasTrustDialogAccepted !== true || existing.hasCompletedProjectOnboarding !== true || JSON.stringify(existing.enabledMcpjsonServers ?? []) !== JSON.stringify(wantEnabled)) {
3342
- projects[SESSION_DIR] = next;
3343
- dirty = true;
4182
+ for (const dir of [SESSION_DIR, SESSION_DIR_2]) {
4183
+ const existing = projects[dir] && typeof projects[dir] === "object" ? projects[dir] : {};
4184
+ const wantEnabled = Array.from(/* @__PURE__ */ new Set([
4185
+ ...existing.enabledMcpjsonServers ?? [],
4186
+ MCP_SERVER_NAME
4187
+ ]));
4188
+ const next = {
4189
+ ...existing,
4190
+ hasTrustDialogAccepted: true,
4191
+ hasCompletedProjectOnboarding: true,
4192
+ enabledMcpjsonServers: wantEnabled
4193
+ };
4194
+ if (existing.hasTrustDialogAccepted !== true || existing.hasCompletedProjectOnboarding !== true || JSON.stringify(existing.enabledMcpjsonServers ?? []) !== JSON.stringify(wantEnabled)) {
4195
+ projects[dir] = next;
4196
+ dirty = true;
4197
+ }
3344
4198
  }
3345
4199
  return dirty;
3346
4200
  });
@@ -3375,14 +4229,16 @@ function uninstallLocalCC() {
3375
4229
  delete parsed.mcpServers[MCP_SERVER_NAME];
3376
4230
  dirty = true;
3377
4231
  }
3378
- if (parsed.projects && typeof parsed.projects === "object" && parsed.projects[SESSION_DIR]) {
3379
- delete parsed.projects[SESSION_DIR];
3380
- dirty = true;
4232
+ for (const dir of [SESSION_DIR, SESSION_DIR_2]) {
4233
+ if (parsed.projects && typeof parsed.projects === "object" && parsed.projects[dir]) {
4234
+ delete parsed.projects[dir];
4235
+ dirty = true;
4236
+ }
3381
4237
  }
3382
4238
  return dirty;
3383
4239
  });
3384
4240
  }
3385
- var CLAUDE_JSON_BACKUP_PATH, SESSION_DIR, PLUGIN_PATH, PLUGIN_PKG_PATH, PLUGIN_SETTINGS_DIR, PLUGIN_SETTINGS_PATH, PROJECT_MCP_PATH, CLAUDE_JSON_PATH, RUN_SCRIPT_PATH, TMUX_SESSION_NAME, RUN_SCRIPT_SOURCE, MCP_SERVER_NAME, PLUGIN_PACKAGE_JSON, LocalCCInstallError;
4241
+ var CLAUDE_JSON_BACKUP_PATH, SESSION_DIR, PLUGIN_PATH, PLUGIN_PKG_PATH, PLUGIN_SETTINGS_DIR, PLUGIN_SETTINGS_PATH, PROJECT_MCP_PATH, CLAUDE_JSON_PATH, RUN_SCRIPT_PATH, TMUX_SESSION_NAME, SESSION_DIR_2, PLUGIN_PATH_2, PLUGIN_PKG_PATH_2, PLUGIN_SETTINGS_DIR_2, PLUGIN_SETTINGS_PATH_2, PROJECT_MCP_PATH_2, RUN_SCRIPT_PATH_2, TMUX_SESSION_NAME_2, CHANNEL_2_PORT, RUN_SCRIPT_SOURCE, RUN_SCRIPT_SOURCE_2, MCP_SERVER_NAME, PLUGIN_PACKAGE_JSON, LocalCCInstallError;
3386
4242
  var init_install = __esm({
3387
4243
  "cli/local-cc/install.ts"() {
3388
4244
  "use strict";
@@ -3397,6 +4253,15 @@ var init_install = __esm({
3397
4253
  CLAUDE_JSON_PATH = join7(homedir6(), ".claude.json");
3398
4254
  RUN_SCRIPT_PATH = join7(SESSION_DIR, "run-claude.sh");
3399
4255
  TMUX_SESSION_NAME = "synkro-local-cc";
4256
+ SESSION_DIR_2 = join7(homedir6(), ".synkro", "cc_sessions_2");
4257
+ PLUGIN_PATH_2 = join7(SESSION_DIR_2, "synkro-channel.ts");
4258
+ PLUGIN_PKG_PATH_2 = join7(SESSION_DIR_2, "package.json");
4259
+ PLUGIN_SETTINGS_DIR_2 = join7(SESSION_DIR_2, ".claude");
4260
+ PLUGIN_SETTINGS_PATH_2 = join7(PLUGIN_SETTINGS_DIR_2, "settings.json");
4261
+ PROJECT_MCP_PATH_2 = join7(SESSION_DIR_2, ".mcp.json");
4262
+ RUN_SCRIPT_PATH_2 = join7(SESSION_DIR_2, "run-claude.sh");
4263
+ TMUX_SESSION_NAME_2 = "synkro-local-cc-2";
4264
+ CHANNEL_2_PORT = 8930;
3400
4265
  RUN_SCRIPT_SOURCE = `#!/usr/bin/env bash
3401
4266
  # Auto-generated by \`synkro install\`. Do not edit.
3402
4267
  set -uo pipefail
@@ -3462,6 +4327,62 @@ while tmux has-session -t "$SESSION" 2>/dev/null; do
3462
4327
  sleep 5
3463
4328
  done
3464
4329
 
4330
+ log "tmux session ended."
4331
+ `;
4332
+ RUN_SCRIPT_SOURCE_2 = `#!/usr/bin/env bash
4333
+ # Auto-generated by \`synkro install\`. Channel 2 (CWE scan, port ${CHANNEL_2_PORT}).
4334
+ set -uo pipefail
4335
+
4336
+ SESSION=${TMUX_SESSION_NAME_2}
4337
+ LOG="$HOME/.synkro/cc_sessions_2/run-claude.log"
4338
+
4339
+ log() { echo "[$(date '+%H:%M:%S')] $*" >> "$LOG"; echo "$*"; }
4340
+
4341
+ if ! command -v claude >/dev/null 2>&1; then
4342
+ log "ERROR: claude CLI not found on PATH."
4343
+ exit 1
4344
+ fi
4345
+
4346
+ if ! command -v tmux >/dev/null 2>&1; then
4347
+ log "ERROR: tmux not found on PATH."
4348
+ exit 1
4349
+ fi
4350
+
4351
+ if ! claude --version >/dev/null 2>&1; then
4352
+ log "ERROR: claude --version failed."
4353
+ exit 1
4354
+ fi
4355
+
4356
+ log "Starting local-CC channel 2 (port ${CHANNEL_2_PORT})..."
4357
+ log "claude version: $(claude --version 2>&1 | head -1)"
4358
+
4359
+ tmux kill-session -t "$SESSION" 2>/dev/null || true
4360
+
4361
+ tmux new-session -d -s "$SESSION" \\
4362
+ "SYNKRO_CHANNEL_PORT=${CHANNEL_2_PORT} claude --dangerously-load-development-channels server:synkro-local --dangerously-skip-permissions --setting-sources project,local --model claude-sonnet-4-6 2>>$LOG; echo 'claude exited with code '$'?' >> $LOG"
4363
+
4364
+ sleep 3
4365
+ if tmux has-session -t "$SESSION" 2>/dev/null; then
4366
+ tmux send-keys -t "$SESSION" '1' 2>/dev/null || true
4367
+ sleep 1
4368
+ tmux send-keys -t "$SESSION" Enter 2>/dev/null || true
4369
+ sleep 1
4370
+ tmux send-keys -t "$SESSION" Enter 2>/dev/null || true
4371
+ log "Sent auto-accept keys to channel 2 session."
4372
+ fi
4373
+
4374
+ sleep 2
4375
+ if ! tmux has-session -t "$SESSION" 2>/dev/null; then
4376
+ log "ERROR: tmux session died immediately. Check $LOG for details."
4377
+ exit 1
4378
+ fi
4379
+
4380
+ log "tmux session started successfully (port ${CHANNEL_2_PORT})."
4381
+
4382
+ while tmux has-session -t "$SESSION" 2>/dev/null; do
4383
+ sleep 5
4384
+ done
4385
+
3465
4386
  log "tmux session ended."
3466
4387
  `;
3467
4388
  MCP_SERVER_NAME = "synkro-local";
@@ -3526,10 +4447,10 @@ function statusName(s) {
3526
4447
  }
3527
4448
  return "unknown";
3528
4449
  }
3529
- function findTask() {
4450
+ function findTask(channel = CHANNEL_PRIMARY) {
3530
4451
  const data = statusJson();
3531
4452
  for (const [id, t] of Object.entries(data.tasks)) {
3532
- if (t.label === TASK_LABEL) {
4453
+ if (t.label === channel.taskLabel) {
3533
4454
  return {
3534
4455
  id: Number(id),
3535
4456
  label: t.label,
@@ -3542,8 +4463,9 @@ function findTask() {
3542
4463
  return null;
3543
4464
  }
3544
4465
  function startTask(opts = {}) {
3545
- const cwd = opts.cwd ?? SESSION_DIR2;
3546
- const existing = findTask();
4466
+ const ch = opts.channel ?? CHANNEL_PRIMARY;
4467
+ const cwd = opts.cwd ?? ch.sessionDir;
4468
+ const existing = findTask(ch);
3547
4469
  if (existing) {
3548
4470
  spawnSync2("pueue", ["remove", String(existing.id)], { encoding: "utf-8" });
3549
4471
  }
@@ -3551,7 +4473,7 @@ function startTask(opts = {}) {
3551
4473
  const args2 = [
3552
4474
  "add",
3553
4475
  "--label",
3554
- TASK_LABEL,
4476
+ ch.taskLabel,
3555
4477
  "--working-directory",
3556
4478
  cwd,
3557
4479
  "--",
@@ -3562,27 +4484,28 @@ function startTask(opts = {}) {
3562
4484
  if (r.status !== 0) {
3563
4485
  throw new PueueError(`pueue add failed: ${r.stderr || r.stdout}`);
3564
4486
  }
3565
- const created = findTask();
4487
+ const created = findTask(ch);
3566
4488
  if (!created) {
3567
- throw new PueueError(`pueue add succeeded but no task with label ${TASK_LABEL} found`);
4489
+ throw new PueueError(`pueue add succeeded but no task with label ${ch.taskLabel} found`);
3568
4490
  }
3569
4491
  return created;
3570
4492
  }
3571
- function stopTask() {
3572
- spawnSync2("tmux", ["kill-session", "-t", TMUX_SESSION], { encoding: "utf-8" });
3573
- const t = findTask();
4493
+ function stopTask(channel = CHANNEL_PRIMARY) {
4494
+ spawnSync2("tmux", ["kill-session", "-t", channel.tmuxSession], { encoding: "utf-8" });
4495
+ const t = findTask(channel);
3574
4496
  if (!t) return;
3575
4497
  spawnSync2("pueue", ["kill", String(t.id)], { encoding: "utf-8" });
3576
4498
  spawnSync2("pueue", ["remove", String(t.id)], { encoding: "utf-8" });
3577
4499
  }
3578
- function tailLogs(lines = 80) {
3579
- const t = findTask();
3580
- if (!t) return "(no synkro local-cc task)";
4500
+ function tailLogs(lines = 80, channel = CHANNEL_PRIMARY) {
4501
+ const t = findTask(channel);
4502
+ if (!t) return `(no ${channel.taskLabel} task)`;
3581
4503
  const r = spawnSync2("pueue", ["log", "--lines", String(lines), String(t.id)], { encoding: "utf-8" });
3582
4504
  return r.stdout || r.stderr || "(no output)";
3583
4505
  }
3584
4506
  function ensureRunning(opts = {}) {
3585
- const t = findTask();
4507
+ const ch = opts.channel ?? CHANNEL_PRIMARY;
4508
+ const t = findTask(ch);
3586
4509
  if (t && t.status === "Running") return t;
3587
4510
  return startTask(opts);
3588
4511
  }
@@ -3601,15 +4524,15 @@ function probePort(host, port, timeoutMs = 500) {
3601
4524
  sock.setTimeout(timeoutMs, () => done(false));
3602
4525
  });
3603
4526
  }
3604
- function tmuxDismissPrompts() {
3605
- spawnSync2("tmux", ["send-keys", "-t", TMUX_SESSION, "1"], { encoding: "utf-8" });
3606
- spawnSync2("tmux", ["send-keys", "-t", TMUX_SESSION, "Enter"], { encoding: "utf-8" });
4527
+ function tmuxDismissPrompts(tmuxSession = TMUX_SESSION) {
4528
+ spawnSync2("tmux", ["send-keys", "-t", tmuxSession, "1"], { encoding: "utf-8" });
4529
+ spawnSync2("tmux", ["send-keys", "-t", tmuxSession, "Enter"], { encoding: "utf-8" });
3607
4530
  }
3608
- async function waitForChannelReady(port, timeoutMs = 6e4, host = "127.0.0.1") {
4531
+ async function waitForChannelReady(port, timeoutMs = 6e4, host = "127.0.0.1", tmuxSession = TMUX_SESSION) {
3609
4532
  const deadline = Date.now() + timeoutMs;
3610
4533
  while (Date.now() < deadline) {
3611
4534
  if (await probePort(host, port)) return true;
3612
- tmuxDismissPrompts();
4535
+ tmuxDismissPrompts(tmuxSession);
3613
4536
  await new Promise((r) => setTimeout(r, 1e3));
3614
4537
  }
3615
4538
  return probePort(host, port);
@@ -3642,6 +4565,7 @@ function assertPueueInstalled() {
3642
4565
  throw new PueueError("pueue daemon not reachable after starting pueued. Check `pueued` manually.");
3643
4566
  }
3644
4567
  }
4568
+ spawnSync2("pueue", ["parallel", "2"], { encoding: "utf-8" });
3645
4569
  }
3646
4570
  function assertClaudeInstalled() {
3647
4571
  const r = spawnSync2("claude", ["--version"], { encoding: "utf-8" });
@@ -3660,13 +4584,16 @@ function assertTmuxInstalled() {
3660
4584
  }
3661
4585
  }
3662
4586
  }
3663
- var TASK_LABEL, TMUX_SESSION, SESSION_DIR2, PueueError;
4587
+ var TASK_LABEL, TMUX_SESSION, SESSION_DIR2, TASK_LABEL_2, TMUX_SESSION_2, SESSION_DIR_22, PueueError, CHANNEL_PRIMARY, CHANNEL_SECONDARY;
3664
4588
  var init_pueue = __esm({
3665
4589
  "cli/local-cc/pueue.ts"() {
3666
4590
  "use strict";
3667
4591
  TASK_LABEL = "synkro-local-cc";
3668
4592
  TMUX_SESSION = "synkro-local-cc";
3669
4593
  SESSION_DIR2 = join8(homedir7(), ".synkro", "cc_sessions");
4594
+ TASK_LABEL_2 = "synkro-local-cc-2";
4595
+ TMUX_SESSION_2 = "synkro-local-cc-2";
4596
+ SESSION_DIR_22 = join8(homedir7(), ".synkro", "cc_sessions_2");
3670
4597
  PueueError = class extends Error {
3671
4598
  constructor(message, cause) {
3672
4599
  super(message);
@@ -3675,6 +4602,8 @@ var init_pueue = __esm({
3675
4602
  }
3676
4603
  cause;
3677
4604
  };
4605
+ CHANNEL_PRIMARY = { taskLabel: TASK_LABEL, tmuxSession: TMUX_SESSION, sessionDir: SESSION_DIR2 };
4606
+ CHANNEL_SECONDARY = { taskLabel: TASK_LABEL_2, tmuxSession: TMUX_SESSION_2, sessionDir: SESSION_DIR_22 };
3678
4607
  }
3679
4608
  });
3680
4609
 
@@ -3702,7 +4631,7 @@ async function fetchPrimers() {
3702
4631
  }
3703
4632
  async function getPrimer(role) {
3704
4633
  const prompts = await fetchPrimers();
3705
- const primer = role === "grade-edit" ? prompts.grader_primer_edit : role === "grade-plan" ? prompts.grader_primer_plan : prompts.grader_primer_bash;
4634
+ const primer = role === "grade-edit" ? prompts.grader_primer_edit : role === "grade-plan" ? prompts.grader_primer_plan : role === "grade-cwe" ? prompts.grader_primer_cwe : prompts.grader_primer_bash;
3706
4635
  if (!primer) {
3707
4636
  throw new Error(`No primer for role "${role}" returned from API.`);
3708
4637
  }
@@ -3868,12 +4797,13 @@ async function submitToChannel(role, payload, opts = {}) {
3868
4797
  const content = await buildChannelContent(role, payload);
3869
4798
  const body = JSON.stringify({ role, content });
3870
4799
  const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
4800
+ const port = opts.port ?? CHANNEL_PORT;
3871
4801
  const startedAt = Date.now();
3872
4802
  try {
3873
4803
  const result = await new Promise((resolve2, reject) => {
3874
4804
  const req = httpRequest({
3875
4805
  host: CHANNEL_HOST,
3876
- port: CHANNEL_PORT,
4806
+ port,
3877
4807
  method: "POST",
3878
4808
  path: "/submit",
3879
4809
  headers: {
@@ -3921,9 +4851,9 @@ async function submitToChannel(role, payload, opts = {}) {
3921
4851
  throw err;
3922
4852
  }
3923
4853
  }
3924
- function isChannelAvailable(timeoutMs = 500) {
4854
+ function isChannelAvailable(port = CHANNEL_PORT, timeoutMs = 500) {
3925
4855
  return new Promise((resolve2) => {
3926
- const sock = connect2(CHANNEL_PORT, CHANNEL_HOST);
4856
+ const sock = connect2(port, CHANNEL_HOST);
3927
4857
  const done = (ok) => {
3928
4858
  try {
3929
4859
  sock.destroy();
@@ -4007,30 +4937,36 @@ function ensureSynkroDir() {
4007
4937
  mkdirSync8(OFFSETS_DIR, { recursive: true });
4008
4938
  }
4009
4939
  function writeHookScripts() {
4010
- const bashScriptPath = join11(HOOKS_DIR, "cc-bash-judge.sh");
4011
- const bashFollowupScriptPath = join11(HOOKS_DIR, "cc-bash-followup.sh");
4940
+ const bashScriptPath = join11(HOOKS_DIR, "cc-bash-judge.ts");
4941
+ const bashFollowupScriptPath = join11(HOOKS_DIR, "cc-bash-followup.ts");
4012
4942
  const editCaptureScriptPath = join11(HOOKS_DIR, "cc-edit-capture.sh");
4013
- const editPrecheckScriptPath = join11(HOOKS_DIR, "cc-edit-precheck.sh");
4014
- const cveScanScriptPath = join11(HOOKS_DIR, "cc-cve-scan.sh");
4015
- const planJudgeScriptPath = join11(HOOKS_DIR, "cc-plan-judge.sh");
4016
- const stopSummaryScriptPath = join11(HOOKS_DIR, "cc-stop-summary.sh");
4017
- const sessionStartScriptPath = join11(HOOKS_DIR, "cc-session-start.sh");
4018
- const transcriptSyncScriptPath = join11(HOOKS_DIR, "cc-transcript-sync.sh");
4019
- const commonScriptPath = join11(HOOKS_DIR, "_synkro-common.sh");
4943
+ const editPrecheckScriptPath = join11(HOOKS_DIR, "cc-edit-precheck.ts");
4944
+ const cwePrecheckScriptPath = join11(HOOKS_DIR, "cc-cwe-precheck.ts");
4945
+ const cvePrecheckScriptPath = join11(HOOKS_DIR, "cc-cve-precheck.ts");
4946
+ const planJudgeScriptPath = join11(HOOKS_DIR, "cc-plan-judge.ts");
4947
+ const stopSummaryScriptPath = join11(HOOKS_DIR, "cc-stop-summary.ts");
4948
+ const sessionStartScriptPath = join11(HOOKS_DIR, "cc-session-start.ts");
4949
+ const transcriptSyncScriptPath = join11(HOOKS_DIR, "cc-transcript-sync.ts");
4950
+ const userPromptSubmitScriptPath = join11(HOOKS_DIR, "cc-user-prompt-submit.ts");
4951
+ const commonScriptPath = join11(HOOKS_DIR, "_synkro-common.ts");
4952
+ const commonBashScriptPath = join11(HOOKS_DIR, "_synkro-common.sh");
4020
4953
  const cursorBashJudgePath = join11(HOOKS_DIR, "cursor-bash-judge.sh");
4021
4954
  const cursorEditPrecheckPath = join11(HOOKS_DIR, "cursor-edit-precheck.sh");
4022
4955
  const cursorEditCapturePath = join11(HOOKS_DIR, "cursor-edit-capture.sh");
4023
4956
  const cursorBashFollowupPath = join11(HOOKS_DIR, "cursor-bash-followup.sh");
4024
- writeFileSync7(bashScriptPath, CC_BASH_JUDGE_SCRIPT, "utf-8");
4025
- writeFileSync7(bashFollowupScriptPath, CC_BASH_FOLLOWUP_SCRIPT, "utf-8");
4026
- writeFileSync7(editCaptureScriptPath, CC_EDIT_CAPTURE_SCRIPT, "utf-8");
4027
- writeFileSync7(editPrecheckScriptPath, CC_EDIT_PRECHECK_SCRIPT, "utf-8");
4028
- writeFileSync7(cveScanScriptPath, CC_CVE_SCAN_SCRIPT, "utf-8");
4029
- writeFileSync7(planJudgeScriptPath, CC_PLAN_JUDGE_SCRIPT, "utf-8");
4030
- writeFileSync7(stopSummaryScriptPath, CC_STOP_SUMMARY_SCRIPT, "utf-8");
4031
- writeFileSync7(sessionStartScriptPath, CC_SESSION_START_SCRIPT, "utf-8");
4032
- writeFileSync7(transcriptSyncScriptPath, CC_TRANSCRIPT_SYNC_SCRIPT, "utf-8");
4033
- writeFileSync7(commonScriptPath, SYNKRO_COMMON_SCRIPT, "utf-8");
4957
+ writeFileSync7(bashScriptPath, BASH_JUDGE_TS, "utf-8");
4958
+ writeFileSync7(bashFollowupScriptPath, BASH_FOLLOWUP_TS, "utf-8");
4959
+ writeFileSync7(editCaptureScriptPath, "", "utf-8");
4960
+ writeFileSync7(editPrecheckScriptPath, EDIT_PRECHECK_TS, "utf-8");
4961
+ writeFileSync7(cwePrecheckScriptPath, CWE_PRECHECK_TS, "utf-8");
4962
+ writeFileSync7(cvePrecheckScriptPath, CVE_PRECHECK_TS, "utf-8");
4963
+ writeFileSync7(planJudgeScriptPath, PLAN_JUDGE_TS, "utf-8");
4964
+ writeFileSync7(stopSummaryScriptPath, STOP_SUMMARY_TS, "utf-8");
4965
+ writeFileSync7(sessionStartScriptPath, SESSION_START_TS, "utf-8");
4966
+ writeFileSync7(transcriptSyncScriptPath, TRANSCRIPT_SYNC_TS, "utf-8");
4967
+ writeFileSync7(userPromptSubmitScriptPath, USER_PROMPT_SUBMIT_TS, "utf-8");
4968
+ writeFileSync7(commonScriptPath, SYNKRO_COMMON_TS, "utf-8");
4969
+ writeFileSync7(commonBashScriptPath, SYNKRO_COMMON_SCRIPT, "utf-8");
4034
4970
  writeFileSync7(cursorBashJudgePath, CURSOR_BASH_JUDGE_SCRIPT, "utf-8");
4035
4971
  writeFileSync7(cursorEditPrecheckPath, CURSOR_EDIT_PRECHECK_SCRIPT, "utf-8");
4036
4972
  writeFileSync7(cursorEditCapturePath, CURSOR_EDIT_CAPTURE_SCRIPT, "utf-8");
@@ -4039,12 +4975,15 @@ function writeHookScripts() {
4039
4975
  chmodSync2(bashFollowupScriptPath, 493);
4040
4976
  chmodSync2(editCaptureScriptPath, 493);
4041
4977
  chmodSync2(editPrecheckScriptPath, 493);
4042
- chmodSync2(cveScanScriptPath, 493);
4978
+ chmodSync2(cwePrecheckScriptPath, 493);
4979
+ chmodSync2(cvePrecheckScriptPath, 493);
4043
4980
  chmodSync2(planJudgeScriptPath, 493);
4044
4981
  chmodSync2(stopSummaryScriptPath, 493);
4045
4982
  chmodSync2(sessionStartScriptPath, 493);
4046
4983
  chmodSync2(transcriptSyncScriptPath, 493);
4984
+ chmodSync2(userPromptSubmitScriptPath, 493);
4047
4985
  chmodSync2(commonScriptPath, 493);
4986
+ chmodSync2(commonBashScriptPath, 493);
4048
4987
  chmodSync2(cursorBashJudgePath, 493);
4049
4988
  chmodSync2(cursorEditPrecheckPath, 493);
4050
4989
  chmodSync2(cursorEditCapturePath, 493);
@@ -4054,11 +4993,13 @@ function writeHookScripts() {
4054
4993
  bashFollowupScript: bashFollowupScriptPath,
4055
4994
  editCaptureScript: editCaptureScriptPath,
4056
4995
  editPrecheckScript: editPrecheckScriptPath,
4057
- cveScanScript: cveScanScriptPath,
4996
+ cwePrecheckScript: cwePrecheckScriptPath,
4997
+ cvePrecheckScript: cvePrecheckScriptPath,
4058
4998
  planJudgeScript: planJudgeScriptPath,
4059
4999
  stopSummaryScript: stopSummaryScriptPath,
4060
5000
  sessionStartScript: sessionStartScriptPath,
4061
5001
  transcriptSyncScript: transcriptSyncScriptPath,
5002
+ userPromptSubmitScript: userPromptSubmitScriptPath,
4062
5003
  cursorBashJudgeScript: cursorBashJudgePath,
4063
5004
  cursorEditPrecheckScript: cursorEditPrecheckPath,
4064
5005
  cursorEditCaptureScript: cursorEditCapturePath,
@@ -4094,7 +5035,7 @@ function writeConfigEnv(opts) {
4094
5035
  `SYNKRO_CREDENTIALS_PATH=${shellQuoteSingle(credsPath)}`,
4095
5036
  `SYNKRO_TIER=${shellQuoteSingle(safeTier)}`,
4096
5037
  `SYNKRO_INFERENCE=${shellQuoteSingle(safeInference)}`,
4097
- `SYNKRO_VERSION=${shellQuoteSingle("1.4.43")}`
5038
+ `SYNKRO_VERSION=${shellQuoteSingle("1.4.47")}`
4098
5039
  ];
4099
5040
  if (safeSynkroBin) lines.push(`SYNKRO_CLI_BIN=${shellQuoteSingle(safeSynkroBin)}`);
4100
5041
  if (safeUserId) lines.push(`SYNKRO_USER_ID=${shellQuoteSingle(safeUserId)}`);
@@ -4288,6 +5229,11 @@ async function installCommand(opts = {}) {
4288
5229
  const ready = await waitForChannelReady(CHANNEL_PORT, 6e4, CHANNEL_HOST);
4289
5230
  if (ready) console.log(` channel ready at ${CHANNEL_HOST}:${CHANNEL_PORT}`);
4290
5231
  else console.warn(" \u26A0 channel did not come up within 60s \u2014 check `synkro local-cc logs`");
5232
+ const t2 = ensureRunning({ channel: CHANNEL_SECONDARY });
5233
+ console.log(` CWE channel: id=${t2.id} status=${t2.status}`);
5234
+ const ready2 = await waitForChannelReady(CHANNEL_2_PORT, 6e4, CHANNEL_HOST, CHANNEL_SECONDARY.tmuxSession);
5235
+ if (ready2) console.log(` CWE channel ready at ${CHANNEL_HOST}:${CHANNEL_2_PORT}`);
5236
+ else console.warn(" \u26A0 CWE channel did not come up within 60s");
4291
5237
  updateLocalInferenceFlag(true);
4292
5238
  } catch (err) {
4293
5239
  console.warn(` \u26A0 Local-CC setup skipped: ${err.message}`);
@@ -4397,11 +5343,13 @@ async function installCommand(opts = {}) {
4397
5343
  bashFollowupScriptPath: scripts.bashFollowupScript,
4398
5344
  editCaptureScriptPath: scripts.editCaptureScript,
4399
5345
  editPrecheckScriptPath: scripts.editPrecheckScript,
4400
- cveScanScriptPath: scripts.cveScanScript,
5346
+ cwePrecheckScriptPath: scripts.cwePrecheckScript,
5347
+ cvePrecheckScriptPath: scripts.cvePrecheckScript,
4401
5348
  planJudgeScriptPath: scripts.planJudgeScript,
4402
5349
  stopSummaryScriptPath: scripts.stopSummaryScript,
4403
5350
  sessionStartScriptPath: scripts.sessionStartScript,
4404
5351
  transcriptSyncScriptPath: scripts.transcriptSyncScript,
5352
+ userPromptSubmitScriptPath: scripts.userPromptSubmitScript,
4405
5353
  skipTranscriptSync: !transcriptConsent
4406
5354
  });
4407
5355
  console.log(`Configured ${agent.name} hooks at ${agent.settingsPath}`);
@@ -4542,6 +5490,24 @@ async function installCommand(opts = {}) {
4542
5490
  } catch {
4543
5491
  }
4544
5492
  console.warn(` Run \`synkro local-cc status\` and \`synkro local-cc logs --tmux\` to debug.
5493
+ `);
5494
+ }
5495
+ const t2 = ensureRunning({ channel: CHANNEL_SECONDARY });
5496
+ console.log(`Local-CC CWE channel: id=${t2.id} status=${t2.status}`);
5497
+ console.log("Waiting for CWE channel (up to 60s)...");
5498
+ const ready2 = await waitForChannelReady(CHANNEL_2_PORT, 6e4, CHANNEL_HOST, CHANNEL_SECONDARY.tmuxSession);
5499
+ if (ready2) {
5500
+ console.log(` CWE channel ready at ${CHANNEL_HOST}:${CHANNEL_2_PORT}`);
5501
+ try {
5502
+ console.log(" warming up CWE inference...");
5503
+ 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 });
5504
+ console.log(" CWE inference warm\n");
5505
+ } catch {
5506
+ console.log(" CWE warmup skipped (non-fatal)\n");
5507
+ }
5508
+ } else {
5509
+ console.warn(` \u26A0 CWE channel did not come up within 60s.`);
5510
+ console.warn(` Run \`synkro local-cc status\` to debug.
4545
5511
  `);
4546
5512
  }
4547
5513
  } catch (err) {
@@ -4783,6 +5749,7 @@ var init_install2 = __esm({
4783
5749
  init_cursorHookConfig();
4784
5750
  init_mcpConfig();
4785
5751
  init_hookScripts();
5752
+ init_hookScriptsTs();
4786
5753
  init_stub();
4787
5754
  init_repoConnect();
4788
5755
  init_projects();
@@ -6173,17 +7140,26 @@ async function cmdStatus() {
6173
7140
  console.log(`Pueue: NOT AVAILABLE (${err.message})`);
6174
7141
  return;
6175
7142
  }
6176
- const t = findTask();
7143
+ const t = findTask(CHANNEL_PRIMARY);
6177
7144
  if (!t) {
6178
- console.log("Pueue task: not present");
7145
+ console.log("Channel 1 (judge) pueue task: not present");
6179
7146
  } else {
6180
- console.log(`Pueue task: id=${t.id} status=${t.status} cwd=${t.cwd}`);
6181
- console.log(` command: ${t.command}`);
7147
+ console.log(`Channel 1 (judge) pueue task: id=${t.id} status=${t.status}`);
7148
+ }
7149
+ const ch1Up = await isChannelAvailable();
7150
+ console.log(`Channel 1 ${CHANNEL_HOST}:${CHANNEL_PORT}: ${ch1Up ? "reachable" : "unreachable"}`);
7151
+ const tmux1 = spawnSync3("tmux", ["has-session", "-t", TMUX_SESSION_NAME], { encoding: "utf-8" });
7152
+ console.log(`tmux '${TMUX_SESSION_NAME}': ${tmux1.status === 0 ? "live" : "absent"}`);
7153
+ const t2 = findTask(CHANNEL_SECONDARY);
7154
+ if (!t2) {
7155
+ console.log("Channel 2 (CWE) pueue task: not present");
7156
+ } else {
7157
+ console.log(`Channel 2 (CWE) pueue task: id=${t2.id} status=${t2.status}`);
6182
7158
  }
6183
- const channelUp = await isChannelAvailable();
6184
- console.log(`Channel ${CHANNEL_HOST}:${CHANNEL_PORT}: ${channelUp ? "reachable" : "unreachable"}`);
6185
- const tmuxCheck = spawnSync3("tmux", ["has-session", "-t", TMUX_SESSION_NAME], { encoding: "utf-8" });
6186
- console.log(`tmux session '${TMUX_SESSION_NAME}': ${tmuxCheck.status === 0 ? "live" : "absent"}`);
7159
+ const ch2Up = await isChannelAvailable(CHANNEL_2_PORT);
7160
+ console.log(`Channel 2 ${CHANNEL_HOST}:${CHANNEL_2_PORT}: ${ch2Up ? "reachable" : "unreachable"}`);
7161
+ const tmux2 = spawnSync3("tmux", ["has-session", "-t", TMUX_SESSION_NAME_2], { encoding: "utf-8" });
7162
+ console.log(`tmux '${TMUX_SESSION_NAME_2}': ${tmux2.status === 0 ? "live" : "absent"}`);
6187
7163
  }
6188
7164
  async function cmdEnable() {
6189
7165
  assertClaudeInstalled();
@@ -6193,13 +7169,21 @@ async function cmdEnable() {
6193
7169
  const r = installLocalCC();
6194
7170
  console.log(` plugin: ${r.pluginPath}`);
6195
7171
  console.log(` cwd: ${r.sessionDir}`);
6196
- console.log("Starting pueue task...");
6197
- const t = ensureRunning();
6198
- console.log(` task: id=${t.id} status=${t.status}`);
6199
- console.log("Waiting for channel (auto-confirming any CC prompts)...");
6200
- const ready = await waitForChannelReady(CHANNEL_PORT, 6e4, CHANNEL_HOST);
6201
- if (ready) console.log(` channel ready at ${CHANNEL_HOST}:${CHANNEL_PORT}`);
6202
- else console.warn(` \u26A0 channel did not come up within 60s \u2014 check \`synkro local-cc logs\``);
7172
+ console.log("Starting channel 1 (judge)...");
7173
+ const t1 = ensureRunning({ channel: CHANNEL_PRIMARY });
7174
+ console.log(` task: id=${t1.id} status=${t1.status}`);
7175
+ console.log("Starting channel 2 (CWE)...");
7176
+ const t2 = ensureRunning({ channel: CHANNEL_SECONDARY });
7177
+ console.log(` task: id=${t2.id} status=${t2.status}`);
7178
+ console.log("Waiting for channels (auto-confirming any CC prompts)...");
7179
+ const [ready1, ready2] = await Promise.all([
7180
+ waitForChannelReady(CHANNEL_PORT, 6e4, CHANNEL_HOST, CHANNEL_PRIMARY.tmuxSession),
7181
+ waitForChannelReady(CHANNEL_2_PORT, 6e4, CHANNEL_HOST, CHANNEL_SECONDARY.tmuxSession)
7182
+ ]);
7183
+ if (ready1) console.log(` channel 1 ready at ${CHANNEL_HOST}:${CHANNEL_PORT}`);
7184
+ else console.warn(` \u26A0 channel 1 did not come up within 60s \u2014 check \`synkro local-cc logs\``);
7185
+ if (ready2) console.log(` channel 2 ready at ${CHANNEL_HOST}:${CHANNEL_2_PORT}`);
7186
+ else console.warn(` \u26A0 channel 2 (CWE) did not come up within 60s`);
6203
7187
  console.log("Updating inference settings...");
6204
7188
  await setServerGradingProvider("claude-code");
6205
7189
  updateLocalInferenceFlag2(true);
@@ -6211,25 +7195,58 @@ async function cmdDisable() {
6211
7195
  updateLocalInferenceFlag2(false);
6212
7196
  console.log("Grading provider cleared (remote inference restored). Pueue task left running \u2014 use `synkro local-cc stop` to terminate.");
6213
7197
  }
7198
+ async function warmChannels(ready1, ready2) {
7199
+ const warmups = [];
7200
+ if (ready1) {
7201
+ warmups.push(
7202
+ submitToChannel("grade-bash", "Proposed command: echo hello\nUser intent: warmup\nRecent user messages: []\nRecent actions: []\nOrg rules: []\n", { timeoutMs: 3e4 }).then(() => console.log(" channel 1 warm.")).catch(() => console.log(" channel 1 warmup skipped (non-fatal)."))
7203
+ );
7204
+ }
7205
+ if (ready2) {
7206
+ warmups.push(
7207
+ 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(() => console.log(" channel 2 warm.")).catch(() => console.log(" channel 2 warmup skipped (non-fatal)."))
7208
+ );
7209
+ }
7210
+ if (warmups.length) {
7211
+ console.log("Warming up inference...");
7212
+ await Promise.all(warmups);
7213
+ }
7214
+ }
6214
7215
  async function cmdStart() {
6215
7216
  assertClaudeInstalled();
6216
7217
  assertPueueInstalled();
6217
7218
  assertTmuxInstalled();
6218
- const t = ensureRunning();
6219
- console.log(`Pueue task: id=${t.id} status=${t.status}`);
6220
- const ready = await waitForChannelReady(CHANNEL_PORT, 6e4, CHANNEL_HOST);
6221
- console.log(ready ? "channel ready." : "\u26A0 channel did not come up within 60s.");
7219
+ const t1 = ensureRunning({ channel: CHANNEL_PRIMARY });
7220
+ console.log(`Channel 1 (judge): id=${t1.id} status=${t1.status}`);
7221
+ const t2 = ensureRunning({ channel: CHANNEL_SECONDARY });
7222
+ console.log(`Channel 2 (CWE): id=${t2.id} status=${t2.status}`);
7223
+ const [ready1, ready2] = await Promise.all([
7224
+ waitForChannelReady(CHANNEL_PORT, 6e4, CHANNEL_HOST, CHANNEL_PRIMARY.tmuxSession),
7225
+ waitForChannelReady(CHANNEL_2_PORT, 6e4, CHANNEL_HOST, CHANNEL_SECONDARY.tmuxSession)
7226
+ ]);
7227
+ console.log(ready1 ? `channel 1 ready (${CHANNEL_PORT}).` : "\u26A0 channel 1 did not come up within 60s.");
7228
+ console.log(ready2 ? `channel 2 ready (${CHANNEL_2_PORT}).` : "\u26A0 channel 2 (CWE) did not come up within 60s.");
7229
+ await warmChannels(ready1, ready2);
6222
7230
  }
6223
7231
  function cmdStop() {
6224
- stopTask();
6225
- console.log("Pueue task stopped.");
7232
+ stopTask(CHANNEL_PRIMARY);
7233
+ stopTask(CHANNEL_SECONDARY);
7234
+ console.log("Both channels stopped.");
6226
7235
  }
6227
7236
  async function cmdRestart() {
6228
- stopTask();
6229
- const t = startTask();
6230
- console.log(`Pueue task restarted: id=${t.id} status=${t.status}`);
6231
- const ready = await waitForChannelReady(CHANNEL_PORT, 6e4, CHANNEL_HOST);
6232
- console.log(ready ? "channel ready." : "\u26A0 channel did not come up within 60s.");
7237
+ stopTask(CHANNEL_PRIMARY);
7238
+ stopTask(CHANNEL_SECONDARY);
7239
+ const t1 = startTask({ channel: CHANNEL_PRIMARY });
7240
+ const t2 = startTask({ channel: CHANNEL_SECONDARY });
7241
+ console.log(`Channel 1 restarted: id=${t1.id} status=${t1.status}`);
7242
+ console.log(`Channel 2 restarted: id=${t2.id} status=${t2.status}`);
7243
+ const [ready1, ready2] = await Promise.all([
7244
+ waitForChannelReady(CHANNEL_PORT, 6e4, CHANNEL_HOST, CHANNEL_PRIMARY.tmuxSession),
7245
+ waitForChannelReady(CHANNEL_2_PORT, 6e4, CHANNEL_HOST, CHANNEL_SECONDARY.tmuxSession)
7246
+ ]);
7247
+ console.log(ready1 ? `channel 1 ready (${CHANNEL_PORT}).` : "\u26A0 channel 1 did not come up within 60s.");
7248
+ console.log(ready2 ? `channel 2 ready (${CHANNEL_2_PORT}).` : "\u26A0 channel 2 (CWE) did not come up within 60s.");
7249
+ await warmChannels(ready1, ready2);
6233
7250
  }
6234
7251
  function relativeTime(iso) {
6235
7252
  const ts = new Date(iso).getTime();
@@ -6445,8 +7462,9 @@ async function gradeCommand(args2) {
6445
7462
  if (mode === "edit") role = "grade-edit";
6446
7463
  else if (mode === "bash") role = "grade-bash";
6447
7464
  else if (mode === "plan") role = "grade-plan";
7465
+ else if (mode === "cwe") role = "grade-cwe";
6448
7466
  else {
6449
- console.error("Usage: synkro grade <edit|bash|plan>");
7467
+ console.error("Usage: synkro grade <edit|bash|plan|cwe>");
6450
7468
  process.exit(2);
6451
7469
  }
6452
7470
  const payload = await readStdin();