@synkro-sh/cli 1.5.6 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/bootstrap.js CHANGED
@@ -641,32 +641,9 @@ synkro_channel_up() {
641
641
  }
642
642
 
643
643
  # Fetch hook config. Sets SYNKRO_CAPTURE_DEPTH, SYNKRO_TIER, SYNKRO_RULES, SYNKRO_SILENT, SYNKRO_POLICY_NAME.
644
- _SYNKRO_RULES_FILE="$HOME/.synkro/rules.json"
645
644
  _SYNKRO_MCP_JWT_FILE="$HOME/.synkro/.mcp-jwt"
646
645
 
647
646
  synkro_load_config() {
648
- # Local-first: read from ~/.synkro/rules.json if it exists (zero latency, no network)
649
- if [ -f "$_SYNKRO_RULES_FILE" ]; then
650
- local rdata
651
- rdata=$(cat "$_SYNKRO_RULES_FILE" 2>/dev/null)
652
- if [ -n "$rdata" ]; then
653
- SYNKRO_CAPTURE_DEPTH="local_only"
654
- SYNKRO_TIER="standard"
655
- SYNKRO_SILENT=$(echo "$rdata" | jq -r '.config.silent // false' 2>/dev/null)
656
- local active_id
657
- active_id=$(echo "$rdata" | jq -r '.config.activePolicyId // empty' 2>/dev/null)
658
- if [ -n "$active_id" ]; then
659
- SYNKRO_POLICY_NAME=$(echo "$rdata" | jq -r --arg id "$active_id" '.policies[]? | select(.id == $id) | .name // empty' 2>/dev/null)
660
- SYNKRO_RULES=$(echo "$rdata" | jq -c --arg id "$active_id" '[.policies[]? | select(.id == $id) | .rules[]? | select(.hook_stage == "pre" or .hook_stage == "both" or .hook_stage == null) | {rule_id,text,severity,category,mode}]' 2>/dev/null || echo "[]")
661
- else
662
- SYNKRO_POLICY_NAME=$(echo "$rdata" | jq -r '.policies[0]?.name // empty' 2>/dev/null)
663
- SYNKRO_RULES=$(echo "$rdata" | jq -c '[.policies[0]?.rules[]? | select(.hook_stage == "pre" or .hook_stage == "both" or .hook_stage == null) | {rule_id,text,severity,category,mode}]' 2>/dev/null || echo "[]")
664
- fi
665
- return
666
- fi
667
- fi
668
-
669
- # Fallback: fetch from cloud API
670
647
  local resp
671
648
  resp=$(curl -sS "\${GATEWAY_URL}/api/v1/hook/config\${1:+?$1}" -H "Authorization: Bearer $JWT" --max-time 4 2>/dev/null || echo "")
672
649
  if [ -z "$resp" ]; then return; fi
@@ -674,7 +651,7 @@ synkro_load_config() {
674
651
  SYNKRO_TIER=$(echo "$resp" | jq -r '.tier // "standard"' 2>/dev/null)
675
652
  SYNKRO_SILENT=$(echo "$resp" | jq -r '.silent_mode // false' 2>/dev/null)
676
653
  SYNKRO_POLICY_NAME=$(echo "$resp" | jq -r '.active_policy_name // empty' 2>/dev/null)
677
- 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 "[]")
654
+ SYNKRO_RULES=$(echo "$resp" | jq -c '[.rules[]? | {rule_id,text,severity,category,mode}]' 2>/dev/null || echo "[]")
678
655
  }
679
656
 
680
657
  synkro_local_capture() {
@@ -988,6 +965,14 @@ export async function cweChannelUp(): Promise<boolean> {
988
965
  return channelUp(18930);
989
966
  }
990
967
 
968
+ // \u2500\u2500\u2500 Mode Normalization \u2500\u2500\u2500
969
+
970
+ function normalizeMode(m?: string): 'ask' | 'fix' {
971
+ if (m === 'blocking' || m === 'ask') return 'ask';
972
+ if (m === 'audit' || m === 'fix') return 'fix';
973
+ return 'ask';
974
+ }
975
+
991
976
  // \u2500\u2500\u2500 Config Loading \u2500\u2500\u2500
992
977
 
993
978
  export interface Rule {
@@ -1029,13 +1014,12 @@ export async function loadConfig(jwt: string, query?: string): Promise<HookConfi
1029
1014
  if (policy) {
1030
1015
  config.policyName = policy.name || '';
1031
1016
  config.rules = (policy.rules || [])
1032
- .filter((r: any) => r.hook_stage === 'pre' || r.hook_stage === 'both' || r.hook_stage == null)
1033
1017
  .map((r: any) => ({
1034
1018
  rule_id: r.rule_id || '',
1035
1019
  text: r.text || '',
1036
1020
  severity: r.severity || '',
1037
1021
  category: r.category || '',
1038
- mode: r.mode || 'blocking',
1022
+ mode: normalizeMode(r.mode),
1039
1023
  }));
1040
1024
  }
1041
1025
  config.silent = raw.silent === true;
@@ -1067,13 +1051,12 @@ export async function loadConfig(jwt: string, query?: string): Promise<HookConfi
1067
1051
  }
1068
1052
  if (Array.isArray(data.rules)) {
1069
1053
  config.rules = data.rules
1070
- .filter((r: any) => r.hook_stage === 'pre' || r.hook_stage === 'both' || r.hook_stage == null)
1071
1054
  .map((r: any) => ({
1072
1055
  rule_id: r.rule_id || '',
1073
1056
  text: r.text || '',
1074
1057
  severity: r.severity || '',
1075
1058
  category: r.category || '',
1076
- mode: r.mode || 'blocking',
1059
+ mode: normalizeMode(r.mode),
1077
1060
  }));
1078
1061
  }
1079
1062
  } catch {}
@@ -1410,11 +1393,11 @@ export function appendLocalTelemetry(body: Record<string, any>): void {
1410
1393
 
1411
1394
  // \u2500\u2500\u2500 Rule Mode Lookup \u2500\u2500\u2500
1412
1395
 
1413
- export function ruleMode(ruleId: string, rules: Rule[]): 'blocking' | 'audit' {
1414
- if (!ruleId || !rules.length) return 'blocking';
1396
+ export function ruleMode(ruleId: string, rules: Rule[]): 'ask' | 'fix' {
1397
+ if (!ruleId || !rules.length) return 'ask';
1415
1398
  const matched = rules.filter(r => r.rule_id === ruleId);
1416
- if (matched.some(r => r.mode === 'blocking')) return 'blocking';
1417
- return (matched[0]?.mode as 'blocking' | 'audit') || 'blocking';
1399
+ if (matched.some(r => r.mode === 'blocking' || r.mode === 'ask')) return 'ask';
1400
+ return normalizeMode(matched[0]?.mode);
1418
1401
  }
1419
1402
 
1420
1403
  // \u2500\u2500\u2500 Content Reconstruction \u2500\u2500\u2500
@@ -2027,7 +2010,7 @@ async function main() {
2027
2010
  'User intent (last human message): ' + (transcript.userIntent || 'none stated'),
2028
2011
  'Last user prompt: ' + (lastPrompt || 'none'),
2029
2012
  'Org rules: ' + JSON.stringify(config.rules),
2030
- 'IMPORTANT: If a rule is violated, ALWAYS return ok=false with the rule_id and reason, regardless of the rule mode. Do NOT pass a command just because the rule mode is "audit". The enforcement layer handles audit vs blocking \u2014 your job is only to detect violations.',
2013
+ 'IMPORTANT: If a rule is violated, ALWAYS return ok=false with the rule_id and reason, regardless of the rule mode. Do NOT pass a command just because the rule mode is "fix". The enforcement layer handles ask vs fix \u2014 your job is only to detect violations.',
2031
2014
  ].join('\\n');
2032
2015
 
2033
2016
  let gradeResp: string;
@@ -2044,37 +2027,27 @@ async function main() {
2044
2027
  const violatedRules = verdict.ruleId ? [verdict.ruleId] : [];
2045
2028
 
2046
2029
  if (!verdict.ok) {
2047
- const mode = verdict.ruleMode || ruleMode(verdict.ruleId, config.rules);
2030
+ const mode = normalizeMode(verdict.ruleMode || ruleMode(verdict.ruleId, config.rules));
2048
2031
  const guardReason = (verdict.ruleId ? '(' + verdict.ruleId + ') ' : '') + (verdict.reason || 'policy violation');
2049
2032
 
2050
- if (mode !== 'audit') {
2051
- const denyReason = 'Guard: ' + guardReason + '\\nFix all issues before retrying. Do NOT ask the user to make the edit manually \u2014 resolve the violation in code yourself.';
2052
- dispatchCapture(jwt, 'edit', 'block', verdict.severity || 'critical', verdict.category || 'security',
2053
- toolName, gitRepo, sessionId, config.captureDepth, {
2054
- command: editContent, reasoning: guardReason,
2055
- rulesChecked: config.rules, violatedRules,
2056
- ccModel: transcript.ccModel,
2057
- });
2058
- outputJson({
2059
- systemMessage: tagStr + ' editGuard ' + fileShort + ' \u2192 blocked: ' + guardReason,
2060
- hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: denyReason, additionalContext: denyReason },
2061
- });
2062
- return;
2063
- }
2064
-
2065
- // Audit mode \u2014 warn but allow
2066
- dispatchCapture(jwt, 'edit', 'warning', verdict.severity || 'medium', verdict.category || 'security',
2033
+ const denyReason = mode === 'fix'
2034
+ ? 'Guard: ' + guardReason + '\\nFix all issues before retrying. Do NOT ask the user to make the edit manually \u2014 resolve the violation in code yourself.'
2035
+ : 'Guard: ' + guardReason + '\\nAsk the user for explicit consent before retrying.';
2036
+ dispatchCapture(jwt, 'edit', 'block', verdict.severity || 'critical', verdict.category || 'security',
2067
2037
  toolName, gitRepo, sessionId, config.captureDepth, {
2068
2038
  command: editContent, reasoning: guardReason,
2069
2039
  rulesChecked: config.rules, violatedRules,
2070
2040
  ccModel: transcript.ccModel,
2071
2041
  });
2072
- outputJson({ systemMessage: tagStr + ' editGuard ' + fileShort + ' \u2192 warning: ' + guardReason, hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: 'Synkro local edit judge (audit). ' + guardReason } });
2042
+ outputJson({
2043
+ systemMessage: tagStr + ' editGuard ' + fileShort + ' \u2192 blocked: ' + guardReason,
2044
+ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: denyReason, additionalContext: denyReason },
2045
+ });
2073
2046
  return;
2074
2047
  }
2075
2048
 
2076
2049
  // Clean
2077
- dispatchCapture(jwt, 'edit', 'pass', 'audit', verdict.category || 'trivial_edit',
2050
+ dispatchCapture(jwt, 'edit', 'pass', 'clean', verdict.category || 'trivial_edit',
2078
2051
  toolName, gitRepo, sessionId, config.captureDepth, {
2079
2052
  command: editContent, reasoning: verdict.reason || 'no policy violations detected',
2080
2053
  rulesChecked: config.rules, violatedRules: [],
@@ -2572,7 +2545,7 @@ async function main() {
2572
2545
  const findings = Array.isArray(cweResp?.findings) ? cweResp.findings : [];
2573
2546
  if (cweResp?.action === 'deny' && findings.length > 0) {
2574
2547
  const activeCweIds = findings
2575
- .filter((f: any) => f.mode === 'blocking')
2548
+ .filter((f: any) => f.mode === 'blocking' || f.mode === 'ask')
2576
2549
  .map((f: any) => f.cwe)
2577
2550
  .filter((id: string) => !exemptedCwes.has(id.toUpperCase()));
2578
2551
 
@@ -2804,7 +2777,7 @@ import {
2804
2777
  parseVerdict, dispatchCapture, dispatchFinding, ruleMode, postWithRetry, readStdin,
2805
2778
  extractTranscript, readLastPrompt, appendSessionAction, readSessionLog, compressSessionLog, log,
2806
2779
  outputJson, outputEmpty, setupCursorHookSignals, isShellTool, hookSessionId, GATEWAY_URL,
2807
- logGraderUnavailable,
2780
+ logGraderUnavailable, isPathUnder,
2808
2781
  type HookConfig, type Rule,
2809
2782
  } from './_synkro-common.ts';
2810
2783
 
@@ -2849,6 +2822,24 @@ async function main() {
2849
2822
  const cmdShort = command.slice(0, 80);
2850
2823
  log('bashGuard checking: ' + cmdShort);
2851
2824
 
2825
+ // Load JWT + routing config eagerly so even the short-circuit message
2826
+ // carries the live pack name + local/cloud tag. Cost: ~200-500ms for the
2827
+ // config fetch (network call, no caching). The fetch is unavoidable for
2828
+ // the LLM path anyway — we just pay it sooner so the short-circuit can
2829
+ // produce a properly-tagged system message.
2830
+ let jwt = loadJwt();
2831
+ if (!jwt) { outputEmpty(); return; }
2832
+ jwt = await ensureFreshJwt(jwt);
2833
+
2834
+ const config = await loadConfig(jwt);
2835
+ const rt = await route(config);
2836
+ const tagStr = tag(rt, config);
2837
+
2838
+ if (config.silent) {
2839
+ outputJson({ systemMessage: tagStr + ' bashGuard → skipped (silent mode)' });
2840
+ return;
2841
+ }
2842
+
2852
2843
  // ─── Hook-side short-circuit for safe in-repo reads ───
2853
2844
  // The judge primer already deterministically allows these, but the round
2854
2845
  // trip + batch queue still costs 1–25s per call. Skipping the grade for
@@ -2953,14 +2944,16 @@ async function main() {
2953
2944
 
2954
2945
  if (isSafeInRepoRead(toolName, command, cwd)) {
2955
2946
  log('bashGuard ' + cmdShort + ' → instant allow (safe in-repo read)');
2956
- outputJson({ hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: 'Synkro: safe in-repo read, deterministic allow.' } });
2947
+ outputJson({
2948
+ systemMessage: tagStr + ' bashGuard → pass: safe in-repo read',
2949
+ hookSpecificOutput: {
2950
+ hookEventName: 'PreToolUse',
2951
+ additionalContext: tagStr + ' bashGuard pass: safe in-repo read.',
2952
+ },
2953
+ });
2957
2954
  return;
2958
2955
  }
2959
2956
 
2960
- let jwt = loadJwt();
2961
- if (!jwt) { outputEmpty(); return; }
2962
- jwt = await ensureFreshJwt(jwt);
2963
-
2964
2957
  // ─── Install protection: server-side pkg-scan (CVE + typosquat + tarball + reputation) ───
2965
2958
  let installScanMsg = '';
2966
2959
  if (toolName === 'Bash') {
@@ -3069,15 +3062,9 @@ async function main() {
3069
3062
 
3070
3063
  const lastPrompt = readLastPrompt(sessionId);
3071
3064
 
3072
- const config = await loadConfig(jwt);
3073
- const rt = await route(config);
3074
- const tagStr = tag(rt, config);
3075
-
3076
- if (config.silent) {
3077
- const msg = (installScanMsg ? installScanMsg + '\\n' : '') + tagStr + ' bashGuard → skipped (silent mode)';
3078
- outputJson({ systemMessage: msg, hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: msg } });
3079
- return;
3080
- }
3065
+ // jwt + config + rt + tagStr already loaded eagerly at top of main
3066
+ // (so the short-circuit could emit a properly-tagged message). Silent
3067
+ // mode was also checked up there.
3081
3068
 
3082
3069
  if (rt === 'local') {
3083
3070
  const sessionLog = compressSessionLog(readSessionLog(sessionId));
@@ -3089,7 +3076,7 @@ async function main() {
3089
3076
  'User intent (last human message): ' + (transcript.userIntent || 'none stated'),
3090
3077
  'Last user prompt: ' + (lastPrompt || 'none'),
3091
3078
  'Org rules: ' + JSON.stringify(config.rules),
3092
- 'IMPORTANT: If a rule is violated, ALWAYS return ok=false with the rule_id and reason, regardless of the rule mode. Do NOT pass a command just because the rule mode is "audit". The enforcement layer handles audit vs blocking — your job is only to detect violations.',
3079
+ 'IMPORTANT: If a rule is violated, ALWAYS return ok=false with the rule_id and reason, regardless of the rule mode. Do NOT pass a command just because the rule mode is "fix". The enforcement layer handles ask vs fix — your job is only to detect violations.',
3093
3080
  'When passing (ok=true), cite which rules you checked and why they passed (e.g. "R006 satisfied: typecheck found in session history"). Be specific, not generic.',
3094
3081
  'Rules with preconditions (e.g. "run X before Y") are CONSUMED after the protected action completes. Use the session history timestamps to determine ordering: a precondition satisfied before the last occurrence of the protected action does NOT satisfy the next occurrence. Each new protected action needs its precondition re-satisfied.',
3095
3082
  ].filter(Boolean).join('\\n');
@@ -3107,45 +3094,29 @@ async function main() {
3107
3094
  const violatedRules = verdict.ruleId ? [verdict.ruleId] : [];
3108
3095
 
3109
3096
  if (!verdict.ok) {
3110
- const mode = verdict.ruleMode || ruleMode(verdict.ruleId, config.rules);
3097
+ const mode = normalizeMode(verdict.ruleMode || ruleMode(verdict.ruleId, config.rules));
3111
3098
  const guardReason = (verdict.ruleId ? '(' + verdict.ruleId + ') ' : '') + (verdict.reason || 'policy violation');
3112
3099
 
3113
- const auditRulesInReason = (config.rules || [])
3114
- .filter((r: any) => r.mode === 'audit' && r.rule_id && verdict.reason && verdict.reason.includes(r.rule_id))
3115
- .map((r: any) => r.rule_id);
3116
-
3117
- if (mode === 'audit') {
3118
- const reason = tagStr + ' bashGuard → warning' + (verdict.ruleId ? ' (' + verdict.ruleId + ')' : '') + ': ' + (verdict.reason || 'policy violation') + '. Fix this before proceeding — do not ask the user.';
3119
- const combined = (installScanMsg ? installScanMsg + '\\n' : '') + reason;
3120
- outputJson({ systemMessage: combined, hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: combined } });
3121
- dispatchCapture(jwt, 'bash', 'warning', verdict.severity || 'medium', verdict.category || 'security',
3122
- toolName, gitRepo, sessionId, config.captureDepth, {
3123
- command, reasoning: guardReason, rulesChecked: config.rules, violatedRules,
3124
- recentUserMessages: transcript.recentUserMessages, ccModel: transcript.ccModel,
3125
- });
3126
- } else {
3127
- let blockMsg = tagStr + ' bashGuard → blocked' + (verdict.ruleId ? ' (' + verdict.ruleId + ')' : '') + ': ' + (verdict.reason || 'policy violation') + '. Ask the user for explicit consent before retrying.';
3128
- if (auditRulesInReason.length > 0) {
3129
- blockMsg += '\\nAudit violations (' + auditRulesInReason.join(', ') + '): fix these automatically before retrying — do not ask the user, just resolve them (e.g. run the required precondition).';
3130
- }
3131
- const combined = (installScanMsg ? installScanMsg + '\\n' : '') + blockMsg;
3132
- outputJson({
3133
- systemMessage: combined,
3134
- hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: blockMsg, additionalContext: combined },
3100
+ const blockMsg = mode === 'fix'
3101
+ ? tagStr + ' bashGuard → blocked' + (verdict.ruleId ? ' (' + verdict.ruleId + ')' : '') + ': ' + (verdict.reason || 'policy violation') + '. Fix this before retrying — do not ask the user.'
3102
+ : tagStr + ' bashGuard → blocked' + (verdict.ruleId ? ' (' + verdict.ruleId + ')' : '') + ': ' + (verdict.reason || 'policy violation') + '. Ask the user for explicit consent before retrying.';
3103
+ const combined = (installScanMsg ? installScanMsg + '\\n' : '') + blockMsg;
3104
+ outputJson({
3105
+ systemMessage: combined,
3106
+ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: blockMsg, additionalContext: combined },
3107
+ });
3108
+ dispatchCapture(jwt, 'bash', 'block', verdict.severity || 'critical', verdict.category || 'security',
3109
+ toolName, gitRepo, sessionId, config.captureDepth, {
3110
+ command, reasoning: guardReason, rulesChecked: config.rules, violatedRules,
3111
+ recentUserMessages: transcript.recentUserMessages, ccModel: transcript.ccModel,
3135
3112
  });
3136
- dispatchCapture(jwt, 'bash', 'block', verdict.severity || 'critical', verdict.category || 'security',
3137
- toolName, gitRepo, sessionId, config.captureDepth, {
3138
- command, reasoning: guardReason, rulesChecked: config.rules, violatedRules,
3139
- recentUserMessages: transcript.recentUserMessages, ccModel: transcript.ccModel,
3140
- });
3141
- }
3142
3113
  } else {
3143
- const auditRuleIds = (config.rules || []).filter((r: any) => r.mode === 'audit').map((r: any) => r.rule_id || r.id).filter(Boolean);
3144
- const auditNote = auditRuleIds.length > 0 ? ' (audit rules checked: ' + auditRuleIds.join(', ') + ')' : '';
3145
- const reason = tagStr + ' bashGuard → pass: ' + (verdict.reason || 'no policy violations detected') + auditNote;
3114
+ const fixRuleIds = (config.rules || []).filter((r: any) => r.mode === 'fix' || r.mode === 'audit').map((r: any) => r.rule_id || r.id).filter(Boolean);
3115
+ const fixNote = fixRuleIds.length > 0 ? ' (fix rules checked: ' + fixRuleIds.join(', ') + ')' : '';
3116
+ const reason = tagStr + ' bashGuard → pass: ' + (verdict.reason || 'no policy violations detected') + fixNote;
3146
3117
  const combined = (installScanMsg ? installScanMsg + '\\n' : '') + reason;
3147
3118
  outputJson({ systemMessage: combined, hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: combined } });
3148
- dispatchCapture(jwt, 'bash', 'pass', 'audit', verdict.category || 'trivial_utility',
3119
+ dispatchCapture(jwt, 'bash', 'pass', 'clean', verdict.category || 'trivial_utility',
3149
3120
  toolName, gitRepo, sessionId, config.captureDepth, {
3150
3121
  command, reasoning: verdict.reason || 'no policy violations detected',
3151
3122
  rulesChecked: config.rules, violatedRules: [],
@@ -3288,7 +3259,7 @@ async function main() {
3288
3259
  'User intent (last human message): ' + (transcript.userIntent || 'none stated'),
3289
3260
  'Last user prompt: ' + (lastPrompt || 'none'),
3290
3261
  'Org rules: ' + JSON.stringify(config.rules),
3291
- 'IMPORTANT: If a rule is violated, ALWAYS return ok=false with the rule_id and reason, regardless of the rule mode. Do NOT pass a command just because the rule mode is "audit". The enforcement layer handles audit vs blocking \u2014 your job is only to detect violations.',
3262
+ 'IMPORTANT: If a rule is violated, ALWAYS return ok=false with the rule_id and reason, regardless of the rule mode. Do NOT pass a command just because the rule mode is "fix". The enforcement layer handles ask vs fix \u2014 your job is only to detect violations.',
3292
3263
  ].filter(Boolean).join('\\n');
3293
3264
 
3294
3265
  let gradeResp: string;
@@ -3305,33 +3276,25 @@ async function main() {
3305
3276
  const violatedRules = verdict.ruleId ? [verdict.ruleId] : [];
3306
3277
 
3307
3278
  if (!verdict.ok) {
3308
- const mode = verdict.ruleMode || ruleMode(verdict.ruleId, config.rules);
3279
+ const mode = normalizeMode(verdict.ruleMode || ruleMode(verdict.ruleId, config.rules));
3309
3280
  const guardReason = (verdict.ruleId ? '(' + verdict.ruleId + ') ' : '') + (verdict.reason || 'policy violation');
3310
3281
 
3311
- if (mode === 'audit') {
3312
- const reason = tagStr + ' agentGuard \u2192 warning' + (verdict.ruleId ? ' (' + verdict.ruleId + ')' : '') + ': ' + (verdict.reason || 'policy violation');
3313
- outputJson({ systemMessage: reason, hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: reason } });
3314
- dispatchCapture(jwt, 'agent', 'warning', verdict.severity || 'medium', verdict.category || 'security',
3315
- toolName, gitRepo, sessionId, config.captureDepth, {
3316
- command: agentContent, reasoning: guardReason, rulesChecked: config.rules, violatedRules,
3317
- recentUserMessages: transcript.recentUserMessages, ccModel: transcript.ccModel,
3318
- });
3319
- } else {
3320
- const reason = tagStr + ' agentGuard \u2192 blocked' + (verdict.ruleId ? ' (' + verdict.ruleId + ')' : '') + ': ' + (verdict.reason || 'policy violation') + '. Ask the user for explicit consent before retrying.';
3321
- outputJson({
3322
- systemMessage: reason,
3323
- hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: reason, additionalContext: reason },
3282
+ const reason = mode === 'fix'
3283
+ ? tagStr + ' agentGuard \u2192 blocked' + (verdict.ruleId ? ' (' + verdict.ruleId + ')' : '') + ': ' + (verdict.reason || 'policy violation') + '. Fix this before retrying \u2014 do not ask the user.'
3284
+ : tagStr + ' agentGuard \u2192 blocked' + (verdict.ruleId ? ' (' + verdict.ruleId + ')' : '') + ': ' + (verdict.reason || 'policy violation') + '. Ask the user for explicit consent before retrying.';
3285
+ outputJson({
3286
+ systemMessage: reason,
3287
+ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: reason, additionalContext: reason },
3288
+ });
3289
+ dispatchCapture(jwt, 'agent', 'block', verdict.severity || 'critical', verdict.category || 'security',
3290
+ toolName, gitRepo, sessionId, config.captureDepth, {
3291
+ command: agentContent, reasoning: guardReason, rulesChecked: config.rules, violatedRules,
3292
+ recentUserMessages: transcript.recentUserMessages, ccModel: transcript.ccModel,
3324
3293
  });
3325
- dispatchCapture(jwt, 'agent', 'block', verdict.severity || 'critical', verdict.category || 'security',
3326
- toolName, gitRepo, sessionId, config.captureDepth, {
3327
- command: agentContent, reasoning: guardReason, rulesChecked: config.rules, violatedRules,
3328
- recentUserMessages: transcript.recentUserMessages, ccModel: transcript.ccModel,
3329
- });
3330
- }
3331
3294
  } else {
3332
3295
  const reason = tagStr + ' agentGuard \u2192 pass: ' + (verdict.reason || 'no policy violations detected');
3333
3296
  outputJson({ systemMessage: reason, hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: reason } });
3334
- dispatchCapture(jwt, 'agent', 'pass', 'audit', verdict.category || 'subagent_spawn',
3297
+ dispatchCapture(jwt, 'agent', 'pass', 'clean', verdict.category || 'subagent_spawn',
3335
3298
  toolName, gitRepo, sessionId, config.captureDepth, {
3336
3299
  command: agentContent, reasoning: verdict.reason || 'no policy violations detected',
3337
3300
  rulesChecked: config.rules, violatedRules: [],
@@ -3513,7 +3476,7 @@ async function main() {
3513
3476
  appendReviewToPlan(planFile, '\\u2705 Clean \\u2014 ' + reviewMsg);
3514
3477
  const cleanLine = tagStr + ' planReview \u2192 clean: ' + reviewMsg;
3515
3478
  outputJson({ systemMessage: cleanLine, hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: 'Synkro local plan judge. ' + reviewMsg } });
3516
- dispatchCapture(jwt, 'plan_review', 'clean', 'audit', verdict.category || 'general',
3479
+ dispatchCapture(jwt, 'plan_review', 'clean', 'clean', verdict.category || 'general',
3517
3480
  'ExitPlanMode', gitRepo, sessionId, config.captureDepth, {
3518
3481
  command: planContent, reasoning: reviewMsg,
3519
3482
  rulesChecked: config.rules, violatedRules: [],
@@ -4141,33 +4104,25 @@ async function main() {
4141
4104
  const verdict = parseVerdict(gradeResp);
4142
4105
 
4143
4106
  if (!verdict.ok) {
4144
- const mode = verdict.ruleMode || ruleMode(verdict.ruleId, config.rules);
4107
+ const mode = normalizeMode(verdict.ruleMode || ruleMode(verdict.ruleId, config.rules));
4145
4108
  const guardReason = (verdict.ruleId ? '(' + verdict.ruleId + ') ' : '') + (verdict.reason || 'policy violation');
4146
4109
 
4147
- if (mode !== 'audit') {
4148
- dispatchCapture(jwt, 'bash', 'block', verdict.severity || 'critical', verdict.category || 'security',
4149
- 'Bash', gitRepo, sessionId, config.captureDepth, {
4150
- command, reasoning: guardReason,
4151
- rulesChecked: config.rules, violatedRules: verdict.ruleId ? [verdict.ruleId] : [],
4152
- ccModel: model,
4153
- });
4154
- finishWith({
4155
- permission: 'deny',
4156
- user_message: tagStr + ' bashGuard \u2192 block: ' + guardReason,
4157
- agent_message: 'Synkro safety judge. Reasoning: ' + (verdict.reason || guardReason),
4158
- });
4159
- }
4160
-
4161
- dispatchCapture(jwt, 'bash', 'warning', verdict.severity || 'medium', verdict.category || 'security',
4110
+ const agentMsg = mode === 'fix'
4111
+ ? 'Synkro safety judge. Fix this before retrying \u2014 do not ask the user. Reasoning: ' + (verdict.reason || guardReason)
4112
+ : 'Synkro safety judge. Ask the user for explicit consent before retrying. Reasoning: ' + (verdict.reason || guardReason);
4113
+ dispatchCapture(jwt, 'bash', 'block', verdict.severity || 'critical', verdict.category || 'security',
4162
4114
  'Bash', gitRepo, sessionId, config.captureDepth, {
4163
4115
  command, reasoning: guardReason,
4164
4116
  rulesChecked: config.rules, violatedRules: verdict.ruleId ? [verdict.ruleId] : [],
4165
4117
  ccModel: model,
4166
4118
  });
4167
- log('bashGuard ' + cmdShort + ' \u2192 audit warning');
4168
- finishWith({ permission: 'allow' });
4119
+ finishWith({
4120
+ permission: 'deny',
4121
+ user_message: tagStr + ' bashGuard \u2192 block: ' + guardReason,
4122
+ agent_message: agentMsg,
4123
+ });
4169
4124
  } else {
4170
- dispatchCapture(jwt, 'bash', 'pass', 'audit', verdict.category || 'clean',
4125
+ dispatchCapture(jwt, 'bash', 'pass', 'clean', verdict.category || 'clean',
4171
4126
  'Bash', gitRepo, sessionId, config.captureDepth, {
4172
4127
  command, reasoning: verdict.reason || 'no policy violations detected',
4173
4128
  rulesChecked: config.rules, violatedRules: [],
@@ -4301,18 +4256,13 @@ async function main() {
4301
4256
  if (cwd) captureBody.cwd = cwd;
4302
4257
  if (repo) captureBody.repo = repo;
4303
4258
 
4304
- const rulesPath = join(homedir(), '.synkro', 'rules.json');
4305
- if (existsSync(rulesPath)) {
4306
- appendLocalTelemetry(captureBody);
4307
- } else {
4308
- fetch(GATEWAY_URL + '/api/v1/hook/capture', {
4309
- method: 'POST',
4310
- headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + jwt },
4311
- body: JSON.stringify(captureBody),
4312
- signal: AbortSignal.timeout(10000),
4313
- }).catch(() => {});
4314
- appendLocalTelemetry(captureBody);
4315
- }
4259
+ appendLocalTelemetry(captureBody);
4260
+ fetch(GATEWAY_URL + '/api/v1/hook/capture', {
4261
+ method: 'POST',
4262
+ headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + jwt },
4263
+ body: JSON.stringify(captureBody),
4264
+ signal: AbortSignal.timeout(10000),
4265
+ }).catch(() => {});
4316
4266
 
4317
4267
  finish();
4318
4268
  } catch (e) {
@@ -5580,13 +5530,17 @@ __export(dockerInstall_exports, {
5580
5530
  SYNKRO_DIR: () => SYNKRO_DIR3,
5581
5531
  assertDockerAvailable: () => assertDockerAvailable,
5582
5532
  dockerInstall: () => dockerInstall,
5533
+ dockerRemove: () => dockerRemove,
5534
+ dockerSafeRestart: () => dockerSafeRestart,
5535
+ dockerSafeStart: () => dockerSafeStart,
5536
+ dockerSafeStop: () => dockerSafeStop,
5583
5537
  dockerStatus: () => dockerStatus,
5584
5538
  dockerStop: () => dockerStop,
5585
5539
  dockerUpdate: () => dockerUpdate,
5586
5540
  imageTag: () => imageTag,
5587
5541
  waitForContainerReady: () => waitForContainerReady
5588
5542
  });
5589
- import { copyFileSync, existsSync as existsSync8, mkdirSync as mkdirSync7 } from "fs";
5543
+ import { copyFileSync, existsSync as existsSync8, mkdirSync as mkdirSync7, readdirSync } from "fs";
5590
5544
  import { homedir as homedir7 } from "os";
5591
5545
  import { join as join7 } from "path";
5592
5546
  import { spawnSync as spawnSync2 } from "child_process";
@@ -5615,6 +5569,7 @@ async function dockerInstall(opts = {}) {
5615
5569
  const image = imageTag();
5616
5570
  const workers = String(opts.workersPerPool ?? 8);
5617
5571
  mkdirSync7(PGDATA_PATH, { recursive: true });
5572
+ mkdirSync7(BACKUP_DIR, { recursive: true });
5618
5573
  mkdirSync7(CLAUDE_HOST_STATE_DIR, { recursive: true });
5619
5574
  const hostClaudeJson = join7(homedir7(), ".claude.json");
5620
5575
  if (existsSync8(hostClaudeJson)) {
@@ -5647,7 +5602,12 @@ async function dockerInstall(opts = {}) {
5647
5602
  if (pull.status !== 0) {
5648
5603
  throw new DockerInstallError(`docker pull ${image} failed`);
5649
5604
  }
5650
- spawnSync2("docker", ["rm", "-f", CONTAINER_NAME], { encoding: "utf-8", timeout: 3e4 });
5605
+ const existing = dockerStatus();
5606
+ if (existing.running) {
5607
+ console.log(" Stopping existing container gracefully...");
5608
+ await dockerSafeStop();
5609
+ }
5610
+ spawnSync2("docker", ["rm", CONTAINER_NAME], { encoding: "utf-8", timeout: 3e4 });
5651
5611
  const credsDir = claudeCredsHostDir();
5652
5612
  const args2 = [
5653
5613
  "run",
@@ -5667,6 +5627,8 @@ async function dockerInstall(opts = {}) {
5667
5627
  "-v",
5668
5628
  `${PGDATA_PATH}:/data/pgdata`,
5669
5629
  "-v",
5630
+ `${BACKUP_DIR}:/data/backups`,
5631
+ "-v",
5670
5632
  `${MCP_JWT_PATH}:/data/.mcp-jwt:ro`,
5671
5633
  "-v",
5672
5634
  `${SYNKRO_CREDS_PATH}:/data/credentials.json:ro`,
@@ -5702,12 +5664,18 @@ async function waitForContainerReady(timeoutMs = 6e4) {
5702
5664
  }
5703
5665
  return false;
5704
5666
  }
5667
+ function dockerRemove() {
5668
+ spawnSync2("docker", ["rm", CONTAINER_NAME], { encoding: "utf-8", timeout: 3e4 });
5669
+ }
5705
5670
  function dockerStop() {
5706
- spawnSync2("docker", ["stop", CONTAINER_NAME], { encoding: "utf-8", timeout: 3e4 });
5707
- spawnSync2("docker", ["rm", "-f", CONTAINER_NAME], { encoding: "utf-8", timeout: 3e4 });
5671
+ spawnSync2("docker", ["stop", "--timeout=30", CONTAINER_NAME], { encoding: "utf-8", timeout: 45e3 });
5672
+ spawnSync2("docker", ["rm", CONTAINER_NAME], { encoding: "utf-8", timeout: 3e4 });
5708
5673
  }
5709
5674
  async function dockerUpdate(workersPerPool) {
5710
- dockerStop();
5675
+ if (dockerStatus().running) {
5676
+ await dockerSafeStop();
5677
+ }
5678
+ dockerRemove();
5711
5679
  await dockerInstall({ workersPerPool });
5712
5680
  }
5713
5681
  function dockerStatus() {
@@ -5723,7 +5691,121 @@ function dockerStatus() {
5723
5691
  healthz: `http://127.0.0.1:${HOST_MCP_PORT}/`
5724
5692
  };
5725
5693
  }
5726
- var SYNKRO_DIR3, MCP_JWT_PATH, SYNKRO_CREDS_PATH, PGDATA_PATH, CLAUDE_HOST_STATE_DIR, CLAUDE_HOST_STATE_FILE, HOST_MCP_PORT, HOST_GRADER_PORT, HOST_CWE_PORT, HOST_PG_PORT, CONTAINER_NAME, DEFAULT_IMAGE, DockerInstallError;
5694
+ async function dockerSafeStop() {
5695
+ const status = dockerStatus();
5696
+ if (!status.running) {
5697
+ console.log(" Container is not running.");
5698
+ return { ok: true, pgdataCheck: checkPgdata() };
5699
+ }
5700
+ console.log(" Requesting data snapshot before shutdown...");
5701
+ let snapshot = { ok: false, error: "not attempted" };
5702
+ try {
5703
+ const resp = await fetch(`http://127.0.0.1:${HOST_MCP_PORT}/api/local/snapshot`, {
5704
+ method: "POST",
5705
+ headers: { "Content-Type": "application/json" },
5706
+ body: JSON.stringify({ reason: "cli-stop" }),
5707
+ signal: AbortSignal.timeout(2e4)
5708
+ });
5709
+ if (resp.ok) {
5710
+ snapshot = { ok: true };
5711
+ console.log(" \u2713 Snapshot saved.");
5712
+ } else {
5713
+ snapshot = { ok: false, error: `HTTP ${resp.status}` };
5714
+ console.warn(` \u26A0 Snapshot request failed (HTTP ${resp.status}). Proceeding with stop.`);
5715
+ }
5716
+ } catch (e) {
5717
+ snapshot = { ok: false, error: String(e).slice(0, 100) };
5718
+ console.warn(` \u26A0 Snapshot request failed: ${snapshot.error}. Proceeding with stop.`);
5719
+ }
5720
+ console.log(" Stopping container (30s grace for CHECKPOINT + WAL flush)...");
5721
+ const stop = spawnSync2("docker", ["stop", "--timeout=30", CONTAINER_NAME], {
5722
+ encoding: "utf-8",
5723
+ timeout: 45e3
5724
+ });
5725
+ const inspect = spawnSync2("docker", ["inspect", "--format", "{{.State.ExitCode}}", CONTAINER_NAME], {
5726
+ encoding: "utf-8",
5727
+ timeout: 5e3
5728
+ });
5729
+ const exitCode = parseInt((inspect.stdout || "").trim(), 10);
5730
+ if (exitCode === 0) {
5731
+ console.log(" \u2713 Container stopped cleanly (exit 0).");
5732
+ } else {
5733
+ console.warn(` \u26A0 Container exited with code ${exitCode}.`);
5734
+ }
5735
+ const pgCheck = checkPgdata();
5736
+ if (pgCheck.healthy) {
5737
+ console.log(` \u2713 pgdata looks healthy: ${pgCheck.details}`);
5738
+ } else {
5739
+ console.warn(` \u26A0 pgdata check: ${pgCheck.details}`);
5740
+ }
5741
+ return { ok: stop.status === 0, snapshot, exitCode, pgdataCheck: pgCheck };
5742
+ }
5743
+ async function dockerSafeStart() {
5744
+ const status = dockerStatus();
5745
+ if (status.running) {
5746
+ console.log(" Container is already running.");
5747
+ return { ok: true, pgdataState: "running" };
5748
+ }
5749
+ const exists = spawnSync2("docker", ["inspect", "--format", "{{.State.Status}}", CONTAINER_NAME], {
5750
+ encoding: "utf-8",
5751
+ timeout: 5e3
5752
+ });
5753
+ if (exists.status !== 0) {
5754
+ return { ok: false, pgdataState: "no_container", error: "No synkro-server container found. Run `synkro install` first." };
5755
+ }
5756
+ const pgCheck = checkPgdata();
5757
+ if (existsSync8(PGDATA_PATH) && readdirSync(PGDATA_PATH).length > 0) {
5758
+ if (pgCheck.healthy) {
5759
+ console.log(` pgdata: existing data found \u2014 ${pgCheck.details}`);
5760
+ } else {
5761
+ console.warn(` \u26A0 pgdata: ${pgCheck.details}`);
5762
+ console.log(" Starting anyway \u2014 entrypoint will attempt recovery from snapshots if needed.");
5763
+ }
5764
+ } else {
5765
+ console.log(" pgdata: no existing data \u2014 fresh start.");
5766
+ mkdirSync7(PGDATA_PATH, { recursive: true });
5767
+ }
5768
+ console.log(" Starting container...");
5769
+ const start = spawnSync2("docker", ["start", CONTAINER_NAME], {
5770
+ encoding: "utf-8",
5771
+ timeout: 3e4
5772
+ });
5773
+ if (start.status !== 0) {
5774
+ return { ok: false, pgdataState: "start_failed", error: `docker start failed: ${(start.stderr || "").slice(0, 200)}` };
5775
+ }
5776
+ console.log(" Waiting for server to become healthy...");
5777
+ const ready = await waitForContainerReady(6e4);
5778
+ if (ready) {
5779
+ console.log(" \u2713 Server is healthy and ready.");
5780
+ return { ok: true, pgdataState: pgCheck.healthy ? "existing" : "recovered" };
5781
+ } else {
5782
+ return { ok: false, pgdataState: "unhealthy", error: "Server did not become healthy within 60s. Check: docker logs synkro-server" };
5783
+ }
5784
+ }
5785
+ async function dockerSafeRestart() {
5786
+ console.log(" === Stop ===");
5787
+ const stopResult = await dockerSafeStop();
5788
+ if (!stopResult.ok) {
5789
+ console.error(" Stop failed. Aborting restart.");
5790
+ return { ok: false, stop: stopResult, start: { ok: false, pgdataState: "not_started", error: "stop failed" } };
5791
+ }
5792
+ console.log("\n === Start ===");
5793
+ const startResult = await dockerSafeStart();
5794
+ return { ok: startResult.ok, stop: stopResult, start: startResult };
5795
+ }
5796
+ function checkPgdata() {
5797
+ if (!existsSync8(PGDATA_PATH)) return { healthy: false, details: "pgdata directory does not exist" };
5798
+ const entries = readdirSync(PGDATA_PATH);
5799
+ if (entries.length === 0) return { healthy: true, details: "empty (fresh start)" };
5800
+ const hasPidFile = entries.includes("postmaster.pid");
5801
+ const hasWalDir = entries.includes("pg_wal");
5802
+ const hasPgControl = entries.includes("global") || entries.includes("pg_control");
5803
+ if (hasPidFile) return { healthy: false, details: "stale postmaster.pid present (unclean shutdown)" };
5804
+ if (!hasWalDir) return { healthy: false, details: "pg_wal directory missing" };
5805
+ if (!hasPgControl) return { healthy: false, details: "pg_control/global directory missing" };
5806
+ return { healthy: true, details: `${entries.length} entries, WAL present, no stale PID` };
5807
+ }
5808
+ var SYNKRO_DIR3, MCP_JWT_PATH, SYNKRO_CREDS_PATH, PGDATA_PATH, CLAUDE_HOST_STATE_DIR, CLAUDE_HOST_STATE_FILE, HOST_MCP_PORT, HOST_GRADER_PORT, HOST_CWE_PORT, HOST_PG_PORT, CONTAINER_NAME, DEFAULT_IMAGE, DockerInstallError, BACKUP_DIR;
5727
5809
  var init_dockerInstall = __esm({
5728
5810
  "cli/local-cc/dockerInstall.ts"() {
5729
5811
  "use strict";
@@ -5748,6 +5830,7 @@ var init_dockerInstall = __esm({
5748
5830
  }
5749
5831
  cause;
5750
5832
  };
5833
+ BACKUP_DIR = join7(SYNKRO_DIR3, "pgdata-backups");
5751
5834
  }
5752
5835
  });
5753
5836
 
@@ -5757,7 +5840,7 @@ __export(install_exports, {
5757
5840
  installCommand: () => installCommand,
5758
5841
  parseArgs: () => parseArgs
5759
5842
  });
5760
- import { existsSync as existsSync9, mkdirSync as mkdirSync8, writeFileSync as writeFileSync7, chmodSync as chmodSync2, readFileSync as readFileSync7, readdirSync } from "fs";
5843
+ import { existsSync as existsSync9, mkdirSync as mkdirSync8, writeFileSync as writeFileSync7, chmodSync as chmodSync2, readFileSync as readFileSync7, readdirSync as readdirSync2 } from "fs";
5761
5844
  import { homedir as homedir8 } from "os";
5762
5845
  import { join as join8 } from "path";
5763
5846
  import { execSync as execSync5 } from "child_process";
@@ -5907,7 +5990,7 @@ function writeConfigEnv(opts) {
5907
5990
  `SYNKRO_CREDENTIALS_PATH=${shellQuoteSingle(credsPath)}`,
5908
5991
  `SYNKRO_TIER=${shellQuoteSingle(safeTier)}`,
5909
5992
  `SYNKRO_INFERENCE=${shellQuoteSingle(safeInference)}`,
5910
- `SYNKRO_VERSION=${shellQuoteSingle("1.5.6")}`
5993
+ `SYNKRO_VERSION=${shellQuoteSingle("1.6.0")}`
5911
5994
  ];
5912
5995
  if (safeSynkroBin) lines.push(`SYNKRO_CLI_BIN=${shellQuoteSingle(safeSynkroBin)}`);
5913
5996
  if (safeUserId) lines.push(`SYNKRO_USER_ID=${shellQuoteSingle(safeUserId)}`);
@@ -5976,7 +6059,7 @@ function collectLocalMetadata() {
5976
6059
  }
5977
6060
  try {
5978
6061
  const sessionsDir = join8(claudeDir, "sessions");
5979
- const files = readdirSync(sessionsDir).filter((f) => f.endsWith(".json")).slice(-5);
6062
+ const files = readdirSync2(sessionsDir).filter((f) => f.endsWith(".json")).slice(-5);
5980
6063
  for (const f of files) {
5981
6064
  const s = JSON.parse(readFileSync7(join8(sessionsDir, f), "utf-8"));
5982
6065
  if (s.version) {
@@ -6382,7 +6465,7 @@ function getClaudeProjectsFolder() {
6382
6465
  }
6383
6466
  function extractSessionInsights(projectsDir) {
6384
6467
  const insights = [];
6385
- const files = readdirSync(projectsDir).filter((f) => f.endsWith(".jsonl"));
6468
+ const files = readdirSync2(projectsDir).filter((f) => f.endsWith(".jsonl"));
6386
6469
  for (const file of files) {
6387
6470
  const sessionId = file.replace(".jsonl", "");
6388
6471
  const filePath = join8(projectsDir, file);
@@ -6503,7 +6586,7 @@ function parseTranscriptFile(filePath) {
6503
6586
  async function syncTranscriptsBulk(gatewayUrl, token, repo) {
6504
6587
  const projectsDir = getClaudeProjectsFolder();
6505
6588
  if (!projectsDir) return { sessions: 0, messages: 0 };
6506
- const files = readdirSync(projectsDir).filter((f) => f.endsWith(".jsonl"));
6589
+ const files = readdirSync2(projectsDir).filter((f) => f.endsWith(".jsonl"));
6507
6590
  if (files.length === 0) return { sessions: 0, messages: 0 };
6508
6591
  console.log(`Found ${files.length} CC session transcripts, syncing...`);
6509
6592
  const maxMessagesPerSession = 500;
@@ -7017,18 +7100,19 @@ var disconnect_exports = {};
7017
7100
  __export(disconnect_exports, {
7018
7101
  disconnectCommand: () => disconnectCommand
7019
7102
  });
7020
- import { existsSync as existsSync11, rmSync } from "fs";
7103
+ import { existsSync as existsSync11, rmSync, readdirSync as readdirSync3 } from "fs";
7021
7104
  import { homedir as homedir10 } from "os";
7022
7105
  import { join as join10 } from "path";
7023
7106
  import { spawnSync as spawnSync4 } from "child_process";
7024
- function tearDownLocalCC(purge) {
7107
+ async function tearDownLocalCC(purge) {
7025
7108
  const docker = dockerStatus();
7026
7109
  if (docker.running) {
7027
- dockerStop();
7028
- console.log("\u2713 stopped synkro-server container");
7110
+ await dockerSafeStop();
7111
+ console.log("\u2713 stopped synkro-server container (data snapshot saved)");
7029
7112
  } else {
7030
7113
  console.log("\xB7 no synkro-server container running");
7031
7114
  }
7115
+ dockerRemove();
7032
7116
  if (purge) {
7033
7117
  try {
7034
7118
  const image = imageTag();
@@ -7047,10 +7131,10 @@ function tearDownLocalCC(purge) {
7047
7131
  uninstallLocalCC();
7048
7132
  console.log("\u2713 cleaned ~/.claude.json entries");
7049
7133
  }
7050
- function disconnectCommand(args2 = []) {
7134
+ async function disconnectCommand(args2 = []) {
7051
7135
  const purge = args2.includes("--purge");
7052
7136
  console.log("Synkro disconnect starting...\n");
7053
- tearDownLocalCC(purge);
7137
+ await tearDownLocalCC(purge);
7054
7138
  const agents = detectAgents();
7055
7139
  let sawClaudeCode = false;
7056
7140
  for (const agent of agents) {
@@ -7073,8 +7157,22 @@ function disconnectCommand(args2 = []) {
7073
7157
  }
7074
7158
  if (purge) {
7075
7159
  if (existsSync11(SYNKRO_DIR5)) {
7076
- rmSync(SYNKRO_DIR5, { recursive: true, force: true });
7077
- console.log(`\u2713 Removed ${SYNKRO_DIR5}`);
7160
+ const pgdataPath = join10(SYNKRO_DIR5, "pgdata");
7161
+ const backupsPath = join10(SYNKRO_DIR5, "pgdata-backups");
7162
+ const preserved = [];
7163
+ for (const entry of readdirSync3(SYNKRO_DIR5)) {
7164
+ const full = join10(SYNKRO_DIR5, entry);
7165
+ if (full === pgdataPath || full === backupsPath) {
7166
+ preserved.push(entry);
7167
+ continue;
7168
+ }
7169
+ rmSync(full, { recursive: true, force: true });
7170
+ }
7171
+ if (preserved.length > 0) {
7172
+ console.log(`\u2713 Removed ${SYNKRO_DIR5} config (preserved: ${preserved.join(", ")} \u2014 your data is safe)`);
7173
+ } else {
7174
+ console.log(`\u2713 Removed ${SYNKRO_DIR5}`);
7175
+ }
7078
7176
  } else {
7079
7177
  console.log(`\xB7 ${SYNKRO_DIR5} already gone, nothing to remove`);
7080
7178
  }
@@ -7273,6 +7371,53 @@ var init_grade = __esm({
7273
7371
  }
7274
7372
  });
7275
7373
 
7374
+ // cli/commands/lifecycle.ts
7375
+ var lifecycle_exports = {};
7376
+ __export(lifecycle_exports, {
7377
+ restartCommand: () => restartCommand,
7378
+ startCommand: () => startCommand,
7379
+ stopCommand: () => stopCommand
7380
+ });
7381
+ async function stopCommand() {
7382
+ assertDockerAvailable();
7383
+ console.log("Synkro: stopping server\n");
7384
+ const result = await dockerSafeStop();
7385
+ if (!result.ok) {
7386
+ console.error("\nStop failed. Check: docker logs synkro-server");
7387
+ process.exit(1);
7388
+ }
7389
+ console.log("\nServer stopped.");
7390
+ }
7391
+ async function startCommand() {
7392
+ assertDockerAvailable();
7393
+ console.log("Synkro: starting server\n");
7394
+ const result = await dockerSafeStart();
7395
+ if (!result.ok) {
7396
+ console.error(`
7397
+ Start failed: ${result.error}`);
7398
+ process.exit(1);
7399
+ }
7400
+ console.log("\nServer is running.");
7401
+ }
7402
+ async function restartCommand() {
7403
+ assertDockerAvailable();
7404
+ console.log("Synkro: restarting server\n");
7405
+ const result = await dockerSafeRestart();
7406
+ if (!result.ok) {
7407
+ if (!result.stop.ok) console.error("\nStop phase failed.");
7408
+ if (!result.start.ok) console.error(`
7409
+ Start phase failed: ${result.start.error}`);
7410
+ process.exit(1);
7411
+ }
7412
+ console.log("\nServer restarted successfully.");
7413
+ }
7414
+ var init_lifecycle = __esm({
7415
+ "cli/commands/lifecycle.ts"() {
7416
+ "use strict";
7417
+ init_dockerInstall();
7418
+ }
7419
+ });
7420
+
7276
7421
  // cli/bootstrap.js
7277
7422
  import { readFileSync as readFileSync10, existsSync as existsSync13 } from "fs";
7278
7423
  import { resolve as resolve2 } from "path";
@@ -7297,7 +7442,7 @@ var args = process.argv.slice(2);
7297
7442
  var cmd = args[0] || "";
7298
7443
  var subArgs = args.slice(1);
7299
7444
  function printVersion() {
7300
- console.log("1.5.6");
7445
+ console.log("1.6.0");
7301
7446
  }
7302
7447
  function printHelp() {
7303
7448
  console.log(`Synkro CLI \u2014 runtime safety for AI coding agents
@@ -7308,6 +7453,9 @@ Usage:
7308
7453
  Commands:
7309
7454
  install [--force] Install or update Synkro
7310
7455
  uninstall [--purge] Remove Synkro hooks (--purge also removes ~/.synkro)
7456
+ stop Gracefully stop the server (snapshot + checkpoint)
7457
+ start Start the server (with pgdata integrity check)
7458
+ restart Safe restart (stop \u2192 start, data preserved)
7311
7459
  version Show version
7312
7460
 
7313
7461
  Quick start:
@@ -7346,6 +7494,21 @@ async function main() {
7346
7494
  printHelp();
7347
7495
  break;
7348
7496
  }
7497
+ case "stop": {
7498
+ const { stopCommand: stopCommand2 } = await Promise.resolve().then(() => (init_lifecycle(), lifecycle_exports));
7499
+ await stopCommand2();
7500
+ break;
7501
+ }
7502
+ case "start": {
7503
+ const { startCommand: startCommand2 } = await Promise.resolve().then(() => (init_lifecycle(), lifecycle_exports));
7504
+ await startCommand2();
7505
+ break;
7506
+ }
7507
+ case "restart": {
7508
+ const { restartCommand: restartCommand2 } = await Promise.resolve().then(() => (init_lifecycle(), lifecycle_exports));
7509
+ await restartCommand2();
7510
+ break;
7511
+ }
7349
7512
  default: {
7350
7513
  console.error(`Unknown command: ${cmd}`);
7351
7514
  printHelp();