@synkro-sh/cli 1.5.7 → 1.6.1

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
 
@@ -2971,6 +2944,16 @@ async function main() {
2971
2944
 
2972
2945
  if (isSafeInRepoRead(toolName, command, cwd)) {
2973
2946
  log('bashGuard ' + cmdShort + ' → instant allow (safe in-repo read)');
2947
+ appendLocalTelemetry({
2948
+ capture_type: 'local_verdict',
2949
+ verdict: 'pass',
2950
+ hook_type: 'bash',
2951
+ category: 'safe_read',
2952
+ tool_name: toolName,
2953
+ command: command.slice(0, 200),
2954
+ session_id: sessionId,
2955
+ repo: cwd,
2956
+ });
2974
2957
  outputJson({
2975
2958
  systemMessage: tagStr + ' bashGuard → pass: safe in-repo read',
2976
2959
  hookSpecificOutput: {
@@ -3103,7 +3086,7 @@ async function main() {
3103
3086
  'User intent (last human message): ' + (transcript.userIntent || 'none stated'),
3104
3087
  'Last user prompt: ' + (lastPrompt || 'none'),
3105
3088
  'Org rules: ' + JSON.stringify(config.rules),
3106
- '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.',
3089
+ '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.',
3107
3090
  '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.',
3108
3091
  '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.',
3109
3092
  ].filter(Boolean).join('\\n');
@@ -3121,45 +3104,29 @@ async function main() {
3121
3104
  const violatedRules = verdict.ruleId ? [verdict.ruleId] : [];
3122
3105
 
3123
3106
  if (!verdict.ok) {
3124
- const mode = verdict.ruleMode || ruleMode(verdict.ruleId, config.rules);
3107
+ const mode = normalizeMode(verdict.ruleMode || ruleMode(verdict.ruleId, config.rules));
3125
3108
  const guardReason = (verdict.ruleId ? '(' + verdict.ruleId + ') ' : '') + (verdict.reason || 'policy violation');
3126
3109
 
3127
- const auditRulesInReason = (config.rules || [])
3128
- .filter((r: any) => r.mode === 'audit' && r.rule_id && verdict.reason && verdict.reason.includes(r.rule_id))
3129
- .map((r: any) => r.rule_id);
3130
-
3131
- if (mode === 'audit') {
3132
- const reason = tagStr + ' bashGuard → warning' + (verdict.ruleId ? ' (' + verdict.ruleId + ')' : '') + ': ' + (verdict.reason || 'policy violation') + '. Fix this before proceeding — do not ask the user.';
3133
- const combined = (installScanMsg ? installScanMsg + '\\n' : '') + reason;
3134
- outputJson({ systemMessage: combined, hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: combined } });
3135
- dispatchCapture(jwt, 'bash', 'warning', verdict.severity || 'medium', verdict.category || 'security',
3136
- toolName, gitRepo, sessionId, config.captureDepth, {
3137
- command, reasoning: guardReason, rulesChecked: config.rules, violatedRules,
3138
- recentUserMessages: transcript.recentUserMessages, ccModel: transcript.ccModel,
3139
- });
3140
- } else {
3141
- let blockMsg = tagStr + ' bashGuard → blocked' + (verdict.ruleId ? ' (' + verdict.ruleId + ')' : '') + ': ' + (verdict.reason || 'policy violation') + '. Ask the user for explicit consent before retrying.';
3142
- if (auditRulesInReason.length > 0) {
3143
- blockMsg += '\\nAudit violations (' + auditRulesInReason.join(', ') + '): fix these automatically before retrying — do not ask the user, just resolve them (e.g. run the required precondition).';
3144
- }
3145
- const combined = (installScanMsg ? installScanMsg + '\\n' : '') + blockMsg;
3146
- outputJson({
3147
- systemMessage: combined,
3148
- hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: blockMsg, additionalContext: combined },
3110
+ const blockMsg = mode === 'fix'
3111
+ ? tagStr + ' bashGuard → blocked' + (verdict.ruleId ? ' (' + verdict.ruleId + ')' : '') + ': ' + (verdict.reason || 'policy violation') + '. Fix this before retrying — do not ask the user.'
3112
+ : tagStr + ' bashGuard → blocked' + (verdict.ruleId ? ' (' + verdict.ruleId + ')' : '') + ': ' + (verdict.reason || 'policy violation') + '. Ask the user for explicit consent before retrying.';
3113
+ const combined = (installScanMsg ? installScanMsg + '\\n' : '') + blockMsg;
3114
+ outputJson({
3115
+ systemMessage: combined,
3116
+ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: blockMsg, additionalContext: combined },
3117
+ });
3118
+ dispatchCapture(jwt, 'bash', 'block', verdict.severity || 'critical', verdict.category || 'security',
3119
+ toolName, gitRepo, sessionId, config.captureDepth, {
3120
+ command, reasoning: guardReason, rulesChecked: config.rules, violatedRules,
3121
+ recentUserMessages: transcript.recentUserMessages, ccModel: transcript.ccModel,
3149
3122
  });
3150
- dispatchCapture(jwt, 'bash', 'block', verdict.severity || 'critical', verdict.category || 'security',
3151
- toolName, gitRepo, sessionId, config.captureDepth, {
3152
- command, reasoning: guardReason, rulesChecked: config.rules, violatedRules,
3153
- recentUserMessages: transcript.recentUserMessages, ccModel: transcript.ccModel,
3154
- });
3155
- }
3156
3123
  } else {
3157
- const auditRuleIds = (config.rules || []).filter((r: any) => r.mode === 'audit').map((r: any) => r.rule_id || r.id).filter(Boolean);
3158
- const auditNote = auditRuleIds.length > 0 ? ' (audit rules checked: ' + auditRuleIds.join(', ') + ')' : '';
3159
- const reason = tagStr + ' bashGuard → pass: ' + (verdict.reason || 'no policy violations detected') + auditNote;
3124
+ const fixRuleIds = (config.rules || []).filter((r: any) => r.mode === 'fix' || r.mode === 'audit').map((r: any) => r.rule_id || r.id).filter(Boolean);
3125
+ const fixNote = fixRuleIds.length > 0 ? ' (fix rules checked: ' + fixRuleIds.join(', ') + ')' : '';
3126
+ const reason = tagStr + ' bashGuard → pass: ' + (verdict.reason || 'no policy violations detected') + fixNote;
3160
3127
  const combined = (installScanMsg ? installScanMsg + '\\n' : '') + reason;
3161
3128
  outputJson({ systemMessage: combined, hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: combined } });
3162
- dispatchCapture(jwt, 'bash', 'pass', 'audit', verdict.category || 'trivial_utility',
3129
+ dispatchCapture(jwt, 'bash', 'pass', 'clean', verdict.category || 'trivial_utility',
3163
3130
  toolName, gitRepo, sessionId, config.captureDepth, {
3164
3131
  command, reasoning: verdict.reason || 'no policy violations detected',
3165
3132
  rulesChecked: config.rules, violatedRules: [],
@@ -3302,7 +3269,7 @@ async function main() {
3302
3269
  'User intent (last human message): ' + (transcript.userIntent || 'none stated'),
3303
3270
  'Last user prompt: ' + (lastPrompt || 'none'),
3304
3271
  'Org rules: ' + JSON.stringify(config.rules),
3305
- '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.',
3272
+ '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.',
3306
3273
  ].filter(Boolean).join('\\n');
3307
3274
 
3308
3275
  let gradeResp: string;
@@ -3319,33 +3286,25 @@ async function main() {
3319
3286
  const violatedRules = verdict.ruleId ? [verdict.ruleId] : [];
3320
3287
 
3321
3288
  if (!verdict.ok) {
3322
- const mode = verdict.ruleMode || ruleMode(verdict.ruleId, config.rules);
3289
+ const mode = normalizeMode(verdict.ruleMode || ruleMode(verdict.ruleId, config.rules));
3323
3290
  const guardReason = (verdict.ruleId ? '(' + verdict.ruleId + ') ' : '') + (verdict.reason || 'policy violation');
3324
3291
 
3325
- if (mode === 'audit') {
3326
- const reason = tagStr + ' agentGuard \u2192 warning' + (verdict.ruleId ? ' (' + verdict.ruleId + ')' : '') + ': ' + (verdict.reason || 'policy violation');
3327
- outputJson({ systemMessage: reason, hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: reason } });
3328
- dispatchCapture(jwt, 'agent', 'warning', verdict.severity || 'medium', verdict.category || 'security',
3329
- toolName, gitRepo, sessionId, config.captureDepth, {
3330
- command: agentContent, reasoning: guardReason, rulesChecked: config.rules, violatedRules,
3331
- recentUserMessages: transcript.recentUserMessages, ccModel: transcript.ccModel,
3332
- });
3333
- } else {
3334
- const reason = tagStr + ' agentGuard \u2192 blocked' + (verdict.ruleId ? ' (' + verdict.ruleId + ')' : '') + ': ' + (verdict.reason || 'policy violation') + '. Ask the user for explicit consent before retrying.';
3335
- outputJson({
3336
- systemMessage: reason,
3337
- hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: reason, additionalContext: reason },
3292
+ const reason = mode === 'fix'
3293
+ ? tagStr + ' agentGuard \u2192 blocked' + (verdict.ruleId ? ' (' + verdict.ruleId + ')' : '') + ': ' + (verdict.reason || 'policy violation') + '. Fix this before retrying \u2014 do not ask the user.'
3294
+ : tagStr + ' agentGuard \u2192 blocked' + (verdict.ruleId ? ' (' + verdict.ruleId + ')' : '') + ': ' + (verdict.reason || 'policy violation') + '. Ask the user for explicit consent before retrying.';
3295
+ outputJson({
3296
+ systemMessage: reason,
3297
+ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: reason, additionalContext: reason },
3298
+ });
3299
+ dispatchCapture(jwt, 'agent', 'block', verdict.severity || 'critical', verdict.category || 'security',
3300
+ toolName, gitRepo, sessionId, config.captureDepth, {
3301
+ command: agentContent, reasoning: guardReason, rulesChecked: config.rules, violatedRules,
3302
+ recentUserMessages: transcript.recentUserMessages, ccModel: transcript.ccModel,
3338
3303
  });
3339
- dispatchCapture(jwt, 'agent', 'block', verdict.severity || 'critical', verdict.category || 'security',
3340
- toolName, gitRepo, sessionId, config.captureDepth, {
3341
- command: agentContent, reasoning: guardReason, rulesChecked: config.rules, violatedRules,
3342
- recentUserMessages: transcript.recentUserMessages, ccModel: transcript.ccModel,
3343
- });
3344
- }
3345
3304
  } else {
3346
3305
  const reason = tagStr + ' agentGuard \u2192 pass: ' + (verdict.reason || 'no policy violations detected');
3347
3306
  outputJson({ systemMessage: reason, hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: reason } });
3348
- dispatchCapture(jwt, 'agent', 'pass', 'audit', verdict.category || 'subagent_spawn',
3307
+ dispatchCapture(jwt, 'agent', 'pass', 'clean', verdict.category || 'subagent_spawn',
3349
3308
  toolName, gitRepo, sessionId, config.captureDepth, {
3350
3309
  command: agentContent, reasoning: verdict.reason || 'no policy violations detected',
3351
3310
  rulesChecked: config.rules, violatedRules: [],
@@ -3527,7 +3486,7 @@ async function main() {
3527
3486
  appendReviewToPlan(planFile, '\\u2705 Clean \\u2014 ' + reviewMsg);
3528
3487
  const cleanLine = tagStr + ' planReview \u2192 clean: ' + reviewMsg;
3529
3488
  outputJson({ systemMessage: cleanLine, hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: 'Synkro local plan judge. ' + reviewMsg } });
3530
- dispatchCapture(jwt, 'plan_review', 'clean', 'audit', verdict.category || 'general',
3489
+ dispatchCapture(jwt, 'plan_review', 'clean', 'clean', verdict.category || 'general',
3531
3490
  'ExitPlanMode', gitRepo, sessionId, config.captureDepth, {
3532
3491
  command: planContent, reasoning: reviewMsg,
3533
3492
  rulesChecked: config.rules, violatedRules: [],
@@ -4155,33 +4114,25 @@ async function main() {
4155
4114
  const verdict = parseVerdict(gradeResp);
4156
4115
 
4157
4116
  if (!verdict.ok) {
4158
- const mode = verdict.ruleMode || ruleMode(verdict.ruleId, config.rules);
4117
+ const mode = normalizeMode(verdict.ruleMode || ruleMode(verdict.ruleId, config.rules));
4159
4118
  const guardReason = (verdict.ruleId ? '(' + verdict.ruleId + ') ' : '') + (verdict.reason || 'policy violation');
4160
4119
 
4161
- if (mode !== 'audit') {
4162
- dispatchCapture(jwt, 'bash', 'block', verdict.severity || 'critical', verdict.category || 'security',
4163
- 'Bash', gitRepo, sessionId, config.captureDepth, {
4164
- command, reasoning: guardReason,
4165
- rulesChecked: config.rules, violatedRules: verdict.ruleId ? [verdict.ruleId] : [],
4166
- ccModel: model,
4167
- });
4168
- finishWith({
4169
- permission: 'deny',
4170
- user_message: tagStr + ' bashGuard \u2192 block: ' + guardReason,
4171
- agent_message: 'Synkro safety judge. Reasoning: ' + (verdict.reason || guardReason),
4172
- });
4173
- }
4174
-
4175
- dispatchCapture(jwt, 'bash', 'warning', verdict.severity || 'medium', verdict.category || 'security',
4120
+ const agentMsg = mode === 'fix'
4121
+ ? 'Synkro safety judge. Fix this before retrying \u2014 do not ask the user. Reasoning: ' + (verdict.reason || guardReason)
4122
+ : 'Synkro safety judge. Ask the user for explicit consent before retrying. Reasoning: ' + (verdict.reason || guardReason);
4123
+ dispatchCapture(jwt, 'bash', 'block', verdict.severity || 'critical', verdict.category || 'security',
4176
4124
  'Bash', gitRepo, sessionId, config.captureDepth, {
4177
4125
  command, reasoning: guardReason,
4178
4126
  rulesChecked: config.rules, violatedRules: verdict.ruleId ? [verdict.ruleId] : [],
4179
4127
  ccModel: model,
4180
4128
  });
4181
- log('bashGuard ' + cmdShort + ' \u2192 audit warning');
4182
- finishWith({ permission: 'allow' });
4129
+ finishWith({
4130
+ permission: 'deny',
4131
+ user_message: tagStr + ' bashGuard \u2192 block: ' + guardReason,
4132
+ agent_message: agentMsg,
4133
+ });
4183
4134
  } else {
4184
- dispatchCapture(jwt, 'bash', 'pass', 'audit', verdict.category || 'clean',
4135
+ dispatchCapture(jwt, 'bash', 'pass', 'clean', verdict.category || 'clean',
4185
4136
  'Bash', gitRepo, sessionId, config.captureDepth, {
4186
4137
  command, reasoning: verdict.reason || 'no policy violations detected',
4187
4138
  rulesChecked: config.rules, violatedRules: [],
@@ -4315,18 +4266,13 @@ async function main() {
4315
4266
  if (cwd) captureBody.cwd = cwd;
4316
4267
  if (repo) captureBody.repo = repo;
4317
4268
 
4318
- const rulesPath = join(homedir(), '.synkro', 'rules.json');
4319
- if (existsSync(rulesPath)) {
4320
- appendLocalTelemetry(captureBody);
4321
- } else {
4322
- fetch(GATEWAY_URL + '/api/v1/hook/capture', {
4323
- method: 'POST',
4324
- headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + jwt },
4325
- body: JSON.stringify(captureBody),
4326
- signal: AbortSignal.timeout(10000),
4327
- }).catch(() => {});
4328
- appendLocalTelemetry(captureBody);
4329
- }
4269
+ appendLocalTelemetry(captureBody);
4270
+ fetch(GATEWAY_URL + '/api/v1/hook/capture', {
4271
+ method: 'POST',
4272
+ headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + jwt },
4273
+ body: JSON.stringify(captureBody),
4274
+ signal: AbortSignal.timeout(10000),
4275
+ }).catch(() => {});
4330
4276
 
4331
4277
  finish();
4332
4278
  } catch (e) {
@@ -5594,13 +5540,17 @@ __export(dockerInstall_exports, {
5594
5540
  SYNKRO_DIR: () => SYNKRO_DIR3,
5595
5541
  assertDockerAvailable: () => assertDockerAvailable,
5596
5542
  dockerInstall: () => dockerInstall,
5543
+ dockerRemove: () => dockerRemove,
5544
+ dockerSafeRestart: () => dockerSafeRestart,
5545
+ dockerSafeStart: () => dockerSafeStart,
5546
+ dockerSafeStop: () => dockerSafeStop,
5597
5547
  dockerStatus: () => dockerStatus,
5598
5548
  dockerStop: () => dockerStop,
5599
5549
  dockerUpdate: () => dockerUpdate,
5600
5550
  imageTag: () => imageTag,
5601
5551
  waitForContainerReady: () => waitForContainerReady
5602
5552
  });
5603
- import { copyFileSync, existsSync as existsSync8, mkdirSync as mkdirSync7 } from "fs";
5553
+ import { copyFileSync, existsSync as existsSync8, mkdirSync as mkdirSync7, readdirSync } from "fs";
5604
5554
  import { homedir as homedir7 } from "os";
5605
5555
  import { join as join7 } from "path";
5606
5556
  import { spawnSync as spawnSync2 } from "child_process";
@@ -5629,6 +5579,7 @@ async function dockerInstall(opts = {}) {
5629
5579
  const image = imageTag();
5630
5580
  const workers = String(opts.workersPerPool ?? 8);
5631
5581
  mkdirSync7(PGDATA_PATH, { recursive: true });
5582
+ mkdirSync7(BACKUP_DIR, { recursive: true });
5632
5583
  mkdirSync7(CLAUDE_HOST_STATE_DIR, { recursive: true });
5633
5584
  const hostClaudeJson = join7(homedir7(), ".claude.json");
5634
5585
  if (existsSync8(hostClaudeJson)) {
@@ -5661,7 +5612,12 @@ async function dockerInstall(opts = {}) {
5661
5612
  if (pull.status !== 0) {
5662
5613
  throw new DockerInstallError(`docker pull ${image} failed`);
5663
5614
  }
5664
- spawnSync2("docker", ["rm", "-f", CONTAINER_NAME], { encoding: "utf-8", timeout: 3e4 });
5615
+ const existing = dockerStatus();
5616
+ if (existing.running) {
5617
+ console.log(" Stopping existing container gracefully...");
5618
+ await dockerSafeStop();
5619
+ }
5620
+ spawnSync2("docker", ["rm", CONTAINER_NAME], { encoding: "utf-8", timeout: 3e4 });
5665
5621
  const credsDir = claudeCredsHostDir();
5666
5622
  const args2 = [
5667
5623
  "run",
@@ -5681,6 +5637,8 @@ async function dockerInstall(opts = {}) {
5681
5637
  "-v",
5682
5638
  `${PGDATA_PATH}:/data/pgdata`,
5683
5639
  "-v",
5640
+ `${BACKUP_DIR}:/data/backups`,
5641
+ "-v",
5684
5642
  `${MCP_JWT_PATH}:/data/.mcp-jwt:ro`,
5685
5643
  "-v",
5686
5644
  `${SYNKRO_CREDS_PATH}:/data/credentials.json:ro`,
@@ -5716,12 +5674,18 @@ async function waitForContainerReady(timeoutMs = 6e4) {
5716
5674
  }
5717
5675
  return false;
5718
5676
  }
5677
+ function dockerRemove() {
5678
+ spawnSync2("docker", ["rm", CONTAINER_NAME], { encoding: "utf-8", timeout: 3e4 });
5679
+ }
5719
5680
  function dockerStop() {
5720
- spawnSync2("docker", ["stop", CONTAINER_NAME], { encoding: "utf-8", timeout: 3e4 });
5721
- spawnSync2("docker", ["rm", "-f", CONTAINER_NAME], { encoding: "utf-8", timeout: 3e4 });
5681
+ spawnSync2("docker", ["stop", "--timeout=30", CONTAINER_NAME], { encoding: "utf-8", timeout: 45e3 });
5682
+ spawnSync2("docker", ["rm", CONTAINER_NAME], { encoding: "utf-8", timeout: 3e4 });
5722
5683
  }
5723
5684
  async function dockerUpdate(workersPerPool) {
5724
- dockerStop();
5685
+ if (dockerStatus().running) {
5686
+ await dockerSafeStop();
5687
+ }
5688
+ dockerRemove();
5725
5689
  await dockerInstall({ workersPerPool });
5726
5690
  }
5727
5691
  function dockerStatus() {
@@ -5737,7 +5701,121 @@ function dockerStatus() {
5737
5701
  healthz: `http://127.0.0.1:${HOST_MCP_PORT}/`
5738
5702
  };
5739
5703
  }
5740
- 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;
5704
+ async function dockerSafeStop() {
5705
+ const status = dockerStatus();
5706
+ if (!status.running) {
5707
+ console.log(" Container is not running.");
5708
+ return { ok: true, pgdataCheck: checkPgdata() };
5709
+ }
5710
+ console.log(" Requesting data snapshot before shutdown...");
5711
+ let snapshot = { ok: false, error: "not attempted" };
5712
+ try {
5713
+ const resp = await fetch(`http://127.0.0.1:${HOST_MCP_PORT}/api/local/snapshot`, {
5714
+ method: "POST",
5715
+ headers: { "Content-Type": "application/json" },
5716
+ body: JSON.stringify({ reason: "cli-stop" }),
5717
+ signal: AbortSignal.timeout(2e4)
5718
+ });
5719
+ if (resp.ok) {
5720
+ snapshot = { ok: true };
5721
+ console.log(" \u2713 Snapshot saved.");
5722
+ } else {
5723
+ snapshot = { ok: false, error: `HTTP ${resp.status}` };
5724
+ console.warn(` \u26A0 Snapshot request failed (HTTP ${resp.status}). Proceeding with stop.`);
5725
+ }
5726
+ } catch (e) {
5727
+ snapshot = { ok: false, error: String(e).slice(0, 100) };
5728
+ console.warn(` \u26A0 Snapshot request failed: ${snapshot.error}. Proceeding with stop.`);
5729
+ }
5730
+ console.log(" Stopping container (30s grace for CHECKPOINT + WAL flush)...");
5731
+ const stop = spawnSync2("docker", ["stop", "--timeout=30", CONTAINER_NAME], {
5732
+ encoding: "utf-8",
5733
+ timeout: 45e3
5734
+ });
5735
+ const inspect = spawnSync2("docker", ["inspect", "--format", "{{.State.ExitCode}}", CONTAINER_NAME], {
5736
+ encoding: "utf-8",
5737
+ timeout: 5e3
5738
+ });
5739
+ const exitCode = parseInt((inspect.stdout || "").trim(), 10);
5740
+ if (exitCode === 0) {
5741
+ console.log(" \u2713 Container stopped cleanly (exit 0).");
5742
+ } else {
5743
+ console.warn(` \u26A0 Container exited with code ${exitCode}.`);
5744
+ }
5745
+ const pgCheck = checkPgdata();
5746
+ if (pgCheck.healthy) {
5747
+ console.log(` \u2713 pgdata looks healthy: ${pgCheck.details}`);
5748
+ } else {
5749
+ console.warn(` \u26A0 pgdata check: ${pgCheck.details}`);
5750
+ }
5751
+ return { ok: stop.status === 0, snapshot, exitCode, pgdataCheck: pgCheck };
5752
+ }
5753
+ async function dockerSafeStart() {
5754
+ const status = dockerStatus();
5755
+ if (status.running) {
5756
+ console.log(" Container is already running.");
5757
+ return { ok: true, pgdataState: "running" };
5758
+ }
5759
+ const exists = spawnSync2("docker", ["inspect", "--format", "{{.State.Status}}", CONTAINER_NAME], {
5760
+ encoding: "utf-8",
5761
+ timeout: 5e3
5762
+ });
5763
+ if (exists.status !== 0) {
5764
+ return { ok: false, pgdataState: "no_container", error: "No synkro-server container found. Run `synkro install` first." };
5765
+ }
5766
+ const pgCheck = checkPgdata();
5767
+ if (existsSync8(PGDATA_PATH) && readdirSync(PGDATA_PATH).length > 0) {
5768
+ if (pgCheck.healthy) {
5769
+ console.log(` pgdata: existing data found \u2014 ${pgCheck.details}`);
5770
+ } else {
5771
+ console.warn(` \u26A0 pgdata: ${pgCheck.details}`);
5772
+ console.log(" Starting anyway \u2014 entrypoint will attempt recovery from snapshots if needed.");
5773
+ }
5774
+ } else {
5775
+ console.log(" pgdata: no existing data \u2014 fresh start.");
5776
+ mkdirSync7(PGDATA_PATH, { recursive: true });
5777
+ }
5778
+ console.log(" Starting container...");
5779
+ const start = spawnSync2("docker", ["start", CONTAINER_NAME], {
5780
+ encoding: "utf-8",
5781
+ timeout: 3e4
5782
+ });
5783
+ if (start.status !== 0) {
5784
+ return { ok: false, pgdataState: "start_failed", error: `docker start failed: ${(start.stderr || "").slice(0, 200)}` };
5785
+ }
5786
+ console.log(" Waiting for server to become healthy...");
5787
+ const ready = await waitForContainerReady(6e4);
5788
+ if (ready) {
5789
+ console.log(" \u2713 Server is healthy and ready.");
5790
+ return { ok: true, pgdataState: pgCheck.healthy ? "existing" : "recovered" };
5791
+ } else {
5792
+ return { ok: false, pgdataState: "unhealthy", error: "Server did not become healthy within 60s. Check: docker logs synkro-server" };
5793
+ }
5794
+ }
5795
+ async function dockerSafeRestart() {
5796
+ console.log(" === Stop ===");
5797
+ const stopResult = await dockerSafeStop();
5798
+ if (!stopResult.ok) {
5799
+ console.error(" Stop failed. Aborting restart.");
5800
+ return { ok: false, stop: stopResult, start: { ok: false, pgdataState: "not_started", error: "stop failed" } };
5801
+ }
5802
+ console.log("\n === Start ===");
5803
+ const startResult = await dockerSafeStart();
5804
+ return { ok: startResult.ok, stop: stopResult, start: startResult };
5805
+ }
5806
+ function checkPgdata() {
5807
+ if (!existsSync8(PGDATA_PATH)) return { healthy: false, details: "pgdata directory does not exist" };
5808
+ const entries = readdirSync(PGDATA_PATH);
5809
+ if (entries.length === 0) return { healthy: true, details: "empty (fresh start)" };
5810
+ const hasPidFile = entries.includes("postmaster.pid");
5811
+ const hasWalDir = entries.includes("pg_wal");
5812
+ const hasPgControl = entries.includes("global") || entries.includes("pg_control");
5813
+ if (hasPidFile) return { healthy: false, details: "stale postmaster.pid present (unclean shutdown)" };
5814
+ if (!hasWalDir) return { healthy: false, details: "pg_wal directory missing" };
5815
+ if (!hasPgControl) return { healthy: false, details: "pg_control/global directory missing" };
5816
+ return { healthy: true, details: `${entries.length} entries, WAL present, no stale PID` };
5817
+ }
5818
+ 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;
5741
5819
  var init_dockerInstall = __esm({
5742
5820
  "cli/local-cc/dockerInstall.ts"() {
5743
5821
  "use strict";
@@ -5762,6 +5840,7 @@ var init_dockerInstall = __esm({
5762
5840
  }
5763
5841
  cause;
5764
5842
  };
5843
+ BACKUP_DIR = join7(SYNKRO_DIR3, "pgdata-backups");
5765
5844
  }
5766
5845
  });
5767
5846
 
@@ -5771,7 +5850,7 @@ __export(install_exports, {
5771
5850
  installCommand: () => installCommand,
5772
5851
  parseArgs: () => parseArgs
5773
5852
  });
5774
- import { existsSync as existsSync9, mkdirSync as mkdirSync8, writeFileSync as writeFileSync7, chmodSync as chmodSync2, readFileSync as readFileSync7, readdirSync } from "fs";
5853
+ import { existsSync as existsSync9, mkdirSync as mkdirSync8, writeFileSync as writeFileSync7, chmodSync as chmodSync2, readFileSync as readFileSync7, readdirSync as readdirSync2 } from "fs";
5775
5854
  import { homedir as homedir8 } from "os";
5776
5855
  import { join as join8 } from "path";
5777
5856
  import { execSync as execSync5 } from "child_process";
@@ -5921,7 +6000,7 @@ function writeConfigEnv(opts) {
5921
6000
  `SYNKRO_CREDENTIALS_PATH=${shellQuoteSingle(credsPath)}`,
5922
6001
  `SYNKRO_TIER=${shellQuoteSingle(safeTier)}`,
5923
6002
  `SYNKRO_INFERENCE=${shellQuoteSingle(safeInference)}`,
5924
- `SYNKRO_VERSION=${shellQuoteSingle("1.5.7")}`
6003
+ `SYNKRO_VERSION=${shellQuoteSingle("1.6.1")}`
5925
6004
  ];
5926
6005
  if (safeSynkroBin) lines.push(`SYNKRO_CLI_BIN=${shellQuoteSingle(safeSynkroBin)}`);
5927
6006
  if (safeUserId) lines.push(`SYNKRO_USER_ID=${shellQuoteSingle(safeUserId)}`);
@@ -5990,7 +6069,7 @@ function collectLocalMetadata() {
5990
6069
  }
5991
6070
  try {
5992
6071
  const sessionsDir = join8(claudeDir, "sessions");
5993
- const files = readdirSync(sessionsDir).filter((f) => f.endsWith(".json")).slice(-5);
6072
+ const files = readdirSync2(sessionsDir).filter((f) => f.endsWith(".json")).slice(-5);
5994
6073
  for (const f of files) {
5995
6074
  const s = JSON.parse(readFileSync7(join8(sessionsDir, f), "utf-8"));
5996
6075
  if (s.version) {
@@ -6396,7 +6475,7 @@ function getClaudeProjectsFolder() {
6396
6475
  }
6397
6476
  function extractSessionInsights(projectsDir) {
6398
6477
  const insights = [];
6399
- const files = readdirSync(projectsDir).filter((f) => f.endsWith(".jsonl"));
6478
+ const files = readdirSync2(projectsDir).filter((f) => f.endsWith(".jsonl"));
6400
6479
  for (const file of files) {
6401
6480
  const sessionId = file.replace(".jsonl", "");
6402
6481
  const filePath = join8(projectsDir, file);
@@ -6517,7 +6596,7 @@ function parseTranscriptFile(filePath) {
6517
6596
  async function syncTranscriptsBulk(gatewayUrl, token, repo) {
6518
6597
  const projectsDir = getClaudeProjectsFolder();
6519
6598
  if (!projectsDir) return { sessions: 0, messages: 0 };
6520
- const files = readdirSync(projectsDir).filter((f) => f.endsWith(".jsonl"));
6599
+ const files = readdirSync2(projectsDir).filter((f) => f.endsWith(".jsonl"));
6521
6600
  if (files.length === 0) return { sessions: 0, messages: 0 };
6522
6601
  console.log(`Found ${files.length} CC session transcripts, syncing...`);
6523
6602
  const maxMessagesPerSession = 500;
@@ -7031,18 +7110,19 @@ var disconnect_exports = {};
7031
7110
  __export(disconnect_exports, {
7032
7111
  disconnectCommand: () => disconnectCommand
7033
7112
  });
7034
- import { existsSync as existsSync11, rmSync } from "fs";
7113
+ import { existsSync as existsSync11, rmSync, readdirSync as readdirSync3 } from "fs";
7035
7114
  import { homedir as homedir10 } from "os";
7036
7115
  import { join as join10 } from "path";
7037
7116
  import { spawnSync as spawnSync4 } from "child_process";
7038
- function tearDownLocalCC(purge) {
7117
+ async function tearDownLocalCC(purge) {
7039
7118
  const docker = dockerStatus();
7040
7119
  if (docker.running) {
7041
- dockerStop();
7042
- console.log("\u2713 stopped synkro-server container");
7120
+ await dockerSafeStop();
7121
+ console.log("\u2713 stopped synkro-server container (data snapshot saved)");
7043
7122
  } else {
7044
7123
  console.log("\xB7 no synkro-server container running");
7045
7124
  }
7125
+ dockerRemove();
7046
7126
  if (purge) {
7047
7127
  try {
7048
7128
  const image = imageTag();
@@ -7061,10 +7141,10 @@ function tearDownLocalCC(purge) {
7061
7141
  uninstallLocalCC();
7062
7142
  console.log("\u2713 cleaned ~/.claude.json entries");
7063
7143
  }
7064
- function disconnectCommand(args2 = []) {
7144
+ async function disconnectCommand(args2 = []) {
7065
7145
  const purge = args2.includes("--purge");
7066
7146
  console.log("Synkro disconnect starting...\n");
7067
- tearDownLocalCC(purge);
7147
+ await tearDownLocalCC(purge);
7068
7148
  const agents = detectAgents();
7069
7149
  let sawClaudeCode = false;
7070
7150
  for (const agent of agents) {
@@ -7087,8 +7167,22 @@ function disconnectCommand(args2 = []) {
7087
7167
  }
7088
7168
  if (purge) {
7089
7169
  if (existsSync11(SYNKRO_DIR5)) {
7090
- rmSync(SYNKRO_DIR5, { recursive: true, force: true });
7091
- console.log(`\u2713 Removed ${SYNKRO_DIR5}`);
7170
+ const pgdataPath = join10(SYNKRO_DIR5, "pgdata");
7171
+ const backupsPath = join10(SYNKRO_DIR5, "pgdata-backups");
7172
+ const preserved = [];
7173
+ for (const entry of readdirSync3(SYNKRO_DIR5)) {
7174
+ const full = join10(SYNKRO_DIR5, entry);
7175
+ if (full === pgdataPath || full === backupsPath) {
7176
+ preserved.push(entry);
7177
+ continue;
7178
+ }
7179
+ rmSync(full, { recursive: true, force: true });
7180
+ }
7181
+ if (preserved.length > 0) {
7182
+ console.log(`\u2713 Removed ${SYNKRO_DIR5} config (preserved: ${preserved.join(", ")} \u2014 your data is safe)`);
7183
+ } else {
7184
+ console.log(`\u2713 Removed ${SYNKRO_DIR5}`);
7185
+ }
7092
7186
  } else {
7093
7187
  console.log(`\xB7 ${SYNKRO_DIR5} already gone, nothing to remove`);
7094
7188
  }
@@ -7287,6 +7381,53 @@ var init_grade = __esm({
7287
7381
  }
7288
7382
  });
7289
7383
 
7384
+ // cli/commands/lifecycle.ts
7385
+ var lifecycle_exports = {};
7386
+ __export(lifecycle_exports, {
7387
+ restartCommand: () => restartCommand,
7388
+ startCommand: () => startCommand,
7389
+ stopCommand: () => stopCommand
7390
+ });
7391
+ async function stopCommand() {
7392
+ assertDockerAvailable();
7393
+ console.log("Synkro: stopping server\n");
7394
+ const result = await dockerSafeStop();
7395
+ if (!result.ok) {
7396
+ console.error("\nStop failed. Check: docker logs synkro-server");
7397
+ process.exit(1);
7398
+ }
7399
+ console.log("\nServer stopped.");
7400
+ }
7401
+ async function startCommand() {
7402
+ assertDockerAvailable();
7403
+ console.log("Synkro: starting server\n");
7404
+ const result = await dockerSafeStart();
7405
+ if (!result.ok) {
7406
+ console.error(`
7407
+ Start failed: ${result.error}`);
7408
+ process.exit(1);
7409
+ }
7410
+ console.log("\nServer is running.");
7411
+ }
7412
+ async function restartCommand() {
7413
+ assertDockerAvailable();
7414
+ console.log("Synkro: restarting server\n");
7415
+ const result = await dockerSafeRestart();
7416
+ if (!result.ok) {
7417
+ if (!result.stop.ok) console.error("\nStop phase failed.");
7418
+ if (!result.start.ok) console.error(`
7419
+ Start phase failed: ${result.start.error}`);
7420
+ process.exit(1);
7421
+ }
7422
+ console.log("\nServer restarted successfully.");
7423
+ }
7424
+ var init_lifecycle = __esm({
7425
+ "cli/commands/lifecycle.ts"() {
7426
+ "use strict";
7427
+ init_dockerInstall();
7428
+ }
7429
+ });
7430
+
7290
7431
  // cli/bootstrap.js
7291
7432
  import { readFileSync as readFileSync10, existsSync as existsSync13 } from "fs";
7292
7433
  import { resolve as resolve2 } from "path";
@@ -7311,7 +7452,7 @@ var args = process.argv.slice(2);
7311
7452
  var cmd = args[0] || "";
7312
7453
  var subArgs = args.slice(1);
7313
7454
  function printVersion() {
7314
- console.log("1.5.7");
7455
+ console.log("1.6.1");
7315
7456
  }
7316
7457
  function printHelp() {
7317
7458
  console.log(`Synkro CLI \u2014 runtime safety for AI coding agents
@@ -7322,6 +7463,9 @@ Usage:
7322
7463
  Commands:
7323
7464
  install [--force] Install or update Synkro
7324
7465
  uninstall [--purge] Remove Synkro hooks (--purge also removes ~/.synkro)
7466
+ stop Gracefully stop the server (snapshot + checkpoint)
7467
+ start Start the server (with pgdata integrity check)
7468
+ restart Safe restart (stop \u2192 start, data preserved)
7325
7469
  version Show version
7326
7470
 
7327
7471
  Quick start:
@@ -7360,6 +7504,21 @@ async function main() {
7360
7504
  printHelp();
7361
7505
  break;
7362
7506
  }
7507
+ case "stop": {
7508
+ const { stopCommand: stopCommand2 } = await Promise.resolve().then(() => (init_lifecycle(), lifecycle_exports));
7509
+ await stopCommand2();
7510
+ break;
7511
+ }
7512
+ case "start": {
7513
+ const { startCommand: startCommand2 } = await Promise.resolve().then(() => (init_lifecycle(), lifecycle_exports));
7514
+ await startCommand2();
7515
+ break;
7516
+ }
7517
+ case "restart": {
7518
+ const { restartCommand: restartCommand2 } = await Promise.resolve().then(() => (init_lifecycle(), lifecycle_exports));
7519
+ await restartCommand2();
7520
+ break;
7521
+ }
7363
7522
  default: {
7364
7523
  console.error(`Unknown command: ${cmd}`);
7365
7524
  printHelp();