@synkro-sh/cli 1.5.7 → 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
 
@@ -3103,7 +3076,7 @@ async function main() {
3103
3076
  'User intent (last human message): ' + (transcript.userIntent || 'none stated'),
3104
3077
  'Last user prompt: ' + (lastPrompt || 'none'),
3105
3078
  '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.',
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.',
3107
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.',
3108
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.',
3109
3082
  ].filter(Boolean).join('\\n');
@@ -3121,45 +3094,29 @@ async function main() {
3121
3094
  const violatedRules = verdict.ruleId ? [verdict.ruleId] : [];
3122
3095
 
3123
3096
  if (!verdict.ok) {
3124
- const mode = verdict.ruleMode || ruleMode(verdict.ruleId, config.rules);
3097
+ const mode = normalizeMode(verdict.ruleMode || ruleMode(verdict.ruleId, config.rules));
3125
3098
  const guardReason = (verdict.ruleId ? '(' + verdict.ruleId + ') ' : '') + (verdict.reason || 'policy violation');
3126
3099
 
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 },
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,
3149
3112
  });
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
3113
  } 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;
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;
3160
3117
  const combined = (installScanMsg ? installScanMsg + '\\n' : '') + reason;
3161
3118
  outputJson({ systemMessage: combined, hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: combined } });
3162
- dispatchCapture(jwt, 'bash', 'pass', 'audit', verdict.category || 'trivial_utility',
3119
+ dispatchCapture(jwt, 'bash', 'pass', 'clean', verdict.category || 'trivial_utility',
3163
3120
  toolName, gitRepo, sessionId, config.captureDepth, {
3164
3121
  command, reasoning: verdict.reason || 'no policy violations detected',
3165
3122
  rulesChecked: config.rules, violatedRules: [],
@@ -3302,7 +3259,7 @@ async function main() {
3302
3259
  'User intent (last human message): ' + (transcript.userIntent || 'none stated'),
3303
3260
  'Last user prompt: ' + (lastPrompt || 'none'),
3304
3261
  '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.',
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.',
3306
3263
  ].filter(Boolean).join('\\n');
3307
3264
 
3308
3265
  let gradeResp: string;
@@ -3319,33 +3276,25 @@ async function main() {
3319
3276
  const violatedRules = verdict.ruleId ? [verdict.ruleId] : [];
3320
3277
 
3321
3278
  if (!verdict.ok) {
3322
- const mode = verdict.ruleMode || ruleMode(verdict.ruleId, config.rules);
3279
+ const mode = normalizeMode(verdict.ruleMode || ruleMode(verdict.ruleId, config.rules));
3323
3280
  const guardReason = (verdict.ruleId ? '(' + verdict.ruleId + ') ' : '') + (verdict.reason || 'policy violation');
3324
3281
 
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 },
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,
3338
3293
  });
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
3294
  } else {
3346
3295
  const reason = tagStr + ' agentGuard \u2192 pass: ' + (verdict.reason || 'no policy violations detected');
3347
3296
  outputJson({ systemMessage: reason, hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: reason } });
3348
- dispatchCapture(jwt, 'agent', 'pass', 'audit', verdict.category || 'subagent_spawn',
3297
+ dispatchCapture(jwt, 'agent', 'pass', 'clean', verdict.category || 'subagent_spawn',
3349
3298
  toolName, gitRepo, sessionId, config.captureDepth, {
3350
3299
  command: agentContent, reasoning: verdict.reason || 'no policy violations detected',
3351
3300
  rulesChecked: config.rules, violatedRules: [],
@@ -3527,7 +3476,7 @@ async function main() {
3527
3476
  appendReviewToPlan(planFile, '\\u2705 Clean \\u2014 ' + reviewMsg);
3528
3477
  const cleanLine = tagStr + ' planReview \u2192 clean: ' + reviewMsg;
3529
3478
  outputJson({ systemMessage: cleanLine, hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: 'Synkro local plan judge. ' + reviewMsg } });
3530
- dispatchCapture(jwt, 'plan_review', 'clean', 'audit', verdict.category || 'general',
3479
+ dispatchCapture(jwt, 'plan_review', 'clean', 'clean', verdict.category || 'general',
3531
3480
  'ExitPlanMode', gitRepo, sessionId, config.captureDepth, {
3532
3481
  command: planContent, reasoning: reviewMsg,
3533
3482
  rulesChecked: config.rules, violatedRules: [],
@@ -4155,33 +4104,25 @@ async function main() {
4155
4104
  const verdict = parseVerdict(gradeResp);
4156
4105
 
4157
4106
  if (!verdict.ok) {
4158
- const mode = verdict.ruleMode || ruleMode(verdict.ruleId, config.rules);
4107
+ const mode = normalizeMode(verdict.ruleMode || ruleMode(verdict.ruleId, config.rules));
4159
4108
  const guardReason = (verdict.ruleId ? '(' + verdict.ruleId + ') ' : '') + (verdict.reason || 'policy violation');
4160
4109
 
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',
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',
4176
4114
  'Bash', gitRepo, sessionId, config.captureDepth, {
4177
4115
  command, reasoning: guardReason,
4178
4116
  rulesChecked: config.rules, violatedRules: verdict.ruleId ? [verdict.ruleId] : [],
4179
4117
  ccModel: model,
4180
4118
  });
4181
- log('bashGuard ' + cmdShort + ' \u2192 audit warning');
4182
- finishWith({ permission: 'allow' });
4119
+ finishWith({
4120
+ permission: 'deny',
4121
+ user_message: tagStr + ' bashGuard \u2192 block: ' + guardReason,
4122
+ agent_message: agentMsg,
4123
+ });
4183
4124
  } else {
4184
- dispatchCapture(jwt, 'bash', 'pass', 'audit', verdict.category || 'clean',
4125
+ dispatchCapture(jwt, 'bash', 'pass', 'clean', verdict.category || 'clean',
4185
4126
  'Bash', gitRepo, sessionId, config.captureDepth, {
4186
4127
  command, reasoning: verdict.reason || 'no policy violations detected',
4187
4128
  rulesChecked: config.rules, violatedRules: [],
@@ -4315,18 +4256,13 @@ async function main() {
4315
4256
  if (cwd) captureBody.cwd = cwd;
4316
4257
  if (repo) captureBody.repo = repo;
4317
4258
 
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
- }
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(() => {});
4330
4266
 
4331
4267
  finish();
4332
4268
  } catch (e) {
@@ -5594,13 +5530,17 @@ __export(dockerInstall_exports, {
5594
5530
  SYNKRO_DIR: () => SYNKRO_DIR3,
5595
5531
  assertDockerAvailable: () => assertDockerAvailable,
5596
5532
  dockerInstall: () => dockerInstall,
5533
+ dockerRemove: () => dockerRemove,
5534
+ dockerSafeRestart: () => dockerSafeRestart,
5535
+ dockerSafeStart: () => dockerSafeStart,
5536
+ dockerSafeStop: () => dockerSafeStop,
5597
5537
  dockerStatus: () => dockerStatus,
5598
5538
  dockerStop: () => dockerStop,
5599
5539
  dockerUpdate: () => dockerUpdate,
5600
5540
  imageTag: () => imageTag,
5601
5541
  waitForContainerReady: () => waitForContainerReady
5602
5542
  });
5603
- import { copyFileSync, existsSync as existsSync8, mkdirSync as mkdirSync7 } from "fs";
5543
+ import { copyFileSync, existsSync as existsSync8, mkdirSync as mkdirSync7, readdirSync } from "fs";
5604
5544
  import { homedir as homedir7 } from "os";
5605
5545
  import { join as join7 } from "path";
5606
5546
  import { spawnSync as spawnSync2 } from "child_process";
@@ -5629,6 +5569,7 @@ async function dockerInstall(opts = {}) {
5629
5569
  const image = imageTag();
5630
5570
  const workers = String(opts.workersPerPool ?? 8);
5631
5571
  mkdirSync7(PGDATA_PATH, { recursive: true });
5572
+ mkdirSync7(BACKUP_DIR, { recursive: true });
5632
5573
  mkdirSync7(CLAUDE_HOST_STATE_DIR, { recursive: true });
5633
5574
  const hostClaudeJson = join7(homedir7(), ".claude.json");
5634
5575
  if (existsSync8(hostClaudeJson)) {
@@ -5661,7 +5602,12 @@ async function dockerInstall(opts = {}) {
5661
5602
  if (pull.status !== 0) {
5662
5603
  throw new DockerInstallError(`docker pull ${image} failed`);
5663
5604
  }
5664
- 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 });
5665
5611
  const credsDir = claudeCredsHostDir();
5666
5612
  const args2 = [
5667
5613
  "run",
@@ -5681,6 +5627,8 @@ async function dockerInstall(opts = {}) {
5681
5627
  "-v",
5682
5628
  `${PGDATA_PATH}:/data/pgdata`,
5683
5629
  "-v",
5630
+ `${BACKUP_DIR}:/data/backups`,
5631
+ "-v",
5684
5632
  `${MCP_JWT_PATH}:/data/.mcp-jwt:ro`,
5685
5633
  "-v",
5686
5634
  `${SYNKRO_CREDS_PATH}:/data/credentials.json:ro`,
@@ -5716,12 +5664,18 @@ async function waitForContainerReady(timeoutMs = 6e4) {
5716
5664
  }
5717
5665
  return false;
5718
5666
  }
5667
+ function dockerRemove() {
5668
+ spawnSync2("docker", ["rm", CONTAINER_NAME], { encoding: "utf-8", timeout: 3e4 });
5669
+ }
5719
5670
  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 });
5671
+ spawnSync2("docker", ["stop", "--timeout=30", CONTAINER_NAME], { encoding: "utf-8", timeout: 45e3 });
5672
+ spawnSync2("docker", ["rm", CONTAINER_NAME], { encoding: "utf-8", timeout: 3e4 });
5722
5673
  }
5723
5674
  async function dockerUpdate(workersPerPool) {
5724
- dockerStop();
5675
+ if (dockerStatus().running) {
5676
+ await dockerSafeStop();
5677
+ }
5678
+ dockerRemove();
5725
5679
  await dockerInstall({ workersPerPool });
5726
5680
  }
5727
5681
  function dockerStatus() {
@@ -5737,7 +5691,121 @@ function dockerStatus() {
5737
5691
  healthz: `http://127.0.0.1:${HOST_MCP_PORT}/`
5738
5692
  };
5739
5693
  }
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;
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;
5741
5809
  var init_dockerInstall = __esm({
5742
5810
  "cli/local-cc/dockerInstall.ts"() {
5743
5811
  "use strict";
@@ -5762,6 +5830,7 @@ var init_dockerInstall = __esm({
5762
5830
  }
5763
5831
  cause;
5764
5832
  };
5833
+ BACKUP_DIR = join7(SYNKRO_DIR3, "pgdata-backups");
5765
5834
  }
5766
5835
  });
5767
5836
 
@@ -5771,7 +5840,7 @@ __export(install_exports, {
5771
5840
  installCommand: () => installCommand,
5772
5841
  parseArgs: () => parseArgs
5773
5842
  });
5774
- 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";
5775
5844
  import { homedir as homedir8 } from "os";
5776
5845
  import { join as join8 } from "path";
5777
5846
  import { execSync as execSync5 } from "child_process";
@@ -5921,7 +5990,7 @@ function writeConfigEnv(opts) {
5921
5990
  `SYNKRO_CREDENTIALS_PATH=${shellQuoteSingle(credsPath)}`,
5922
5991
  `SYNKRO_TIER=${shellQuoteSingle(safeTier)}`,
5923
5992
  `SYNKRO_INFERENCE=${shellQuoteSingle(safeInference)}`,
5924
- `SYNKRO_VERSION=${shellQuoteSingle("1.5.7")}`
5993
+ `SYNKRO_VERSION=${shellQuoteSingle("1.6.0")}`
5925
5994
  ];
5926
5995
  if (safeSynkroBin) lines.push(`SYNKRO_CLI_BIN=${shellQuoteSingle(safeSynkroBin)}`);
5927
5996
  if (safeUserId) lines.push(`SYNKRO_USER_ID=${shellQuoteSingle(safeUserId)}`);
@@ -5990,7 +6059,7 @@ function collectLocalMetadata() {
5990
6059
  }
5991
6060
  try {
5992
6061
  const sessionsDir = join8(claudeDir, "sessions");
5993
- const files = readdirSync(sessionsDir).filter((f) => f.endsWith(".json")).slice(-5);
6062
+ const files = readdirSync2(sessionsDir).filter((f) => f.endsWith(".json")).slice(-5);
5994
6063
  for (const f of files) {
5995
6064
  const s = JSON.parse(readFileSync7(join8(sessionsDir, f), "utf-8"));
5996
6065
  if (s.version) {
@@ -6396,7 +6465,7 @@ function getClaudeProjectsFolder() {
6396
6465
  }
6397
6466
  function extractSessionInsights(projectsDir) {
6398
6467
  const insights = [];
6399
- const files = readdirSync(projectsDir).filter((f) => f.endsWith(".jsonl"));
6468
+ const files = readdirSync2(projectsDir).filter((f) => f.endsWith(".jsonl"));
6400
6469
  for (const file of files) {
6401
6470
  const sessionId = file.replace(".jsonl", "");
6402
6471
  const filePath = join8(projectsDir, file);
@@ -6517,7 +6586,7 @@ function parseTranscriptFile(filePath) {
6517
6586
  async function syncTranscriptsBulk(gatewayUrl, token, repo) {
6518
6587
  const projectsDir = getClaudeProjectsFolder();
6519
6588
  if (!projectsDir) return { sessions: 0, messages: 0 };
6520
- const files = readdirSync(projectsDir).filter((f) => f.endsWith(".jsonl"));
6589
+ const files = readdirSync2(projectsDir).filter((f) => f.endsWith(".jsonl"));
6521
6590
  if (files.length === 0) return { sessions: 0, messages: 0 };
6522
6591
  console.log(`Found ${files.length} CC session transcripts, syncing...`);
6523
6592
  const maxMessagesPerSession = 500;
@@ -7031,18 +7100,19 @@ var disconnect_exports = {};
7031
7100
  __export(disconnect_exports, {
7032
7101
  disconnectCommand: () => disconnectCommand
7033
7102
  });
7034
- import { existsSync as existsSync11, rmSync } from "fs";
7103
+ import { existsSync as existsSync11, rmSync, readdirSync as readdirSync3 } from "fs";
7035
7104
  import { homedir as homedir10 } from "os";
7036
7105
  import { join as join10 } from "path";
7037
7106
  import { spawnSync as spawnSync4 } from "child_process";
7038
- function tearDownLocalCC(purge) {
7107
+ async function tearDownLocalCC(purge) {
7039
7108
  const docker = dockerStatus();
7040
7109
  if (docker.running) {
7041
- dockerStop();
7042
- console.log("\u2713 stopped synkro-server container");
7110
+ await dockerSafeStop();
7111
+ console.log("\u2713 stopped synkro-server container (data snapshot saved)");
7043
7112
  } else {
7044
7113
  console.log("\xB7 no synkro-server container running");
7045
7114
  }
7115
+ dockerRemove();
7046
7116
  if (purge) {
7047
7117
  try {
7048
7118
  const image = imageTag();
@@ -7061,10 +7131,10 @@ function tearDownLocalCC(purge) {
7061
7131
  uninstallLocalCC();
7062
7132
  console.log("\u2713 cleaned ~/.claude.json entries");
7063
7133
  }
7064
- function disconnectCommand(args2 = []) {
7134
+ async function disconnectCommand(args2 = []) {
7065
7135
  const purge = args2.includes("--purge");
7066
7136
  console.log("Synkro disconnect starting...\n");
7067
- tearDownLocalCC(purge);
7137
+ await tearDownLocalCC(purge);
7068
7138
  const agents = detectAgents();
7069
7139
  let sawClaudeCode = false;
7070
7140
  for (const agent of agents) {
@@ -7087,8 +7157,22 @@ function disconnectCommand(args2 = []) {
7087
7157
  }
7088
7158
  if (purge) {
7089
7159
  if (existsSync11(SYNKRO_DIR5)) {
7090
- rmSync(SYNKRO_DIR5, { recursive: true, force: true });
7091
- 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
+ }
7092
7176
  } else {
7093
7177
  console.log(`\xB7 ${SYNKRO_DIR5} already gone, nothing to remove`);
7094
7178
  }
@@ -7287,6 +7371,53 @@ var init_grade = __esm({
7287
7371
  }
7288
7372
  });
7289
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
+
7290
7421
  // cli/bootstrap.js
7291
7422
  import { readFileSync as readFileSync10, existsSync as existsSync13 } from "fs";
7292
7423
  import { resolve as resolve2 } from "path";
@@ -7311,7 +7442,7 @@ var args = process.argv.slice(2);
7311
7442
  var cmd = args[0] || "";
7312
7443
  var subArgs = args.slice(1);
7313
7444
  function printVersion() {
7314
- console.log("1.5.7");
7445
+ console.log("1.6.0");
7315
7446
  }
7316
7447
  function printHelp() {
7317
7448
  console.log(`Synkro CLI \u2014 runtime safety for AI coding agents
@@ -7322,6 +7453,9 @@ Usage:
7322
7453
  Commands:
7323
7454
  install [--force] Install or update Synkro
7324
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)
7325
7459
  version Show version
7326
7460
 
7327
7461
  Quick start:
@@ -7360,6 +7494,21 @@ async function main() {
7360
7494
  printHelp();
7361
7495
  break;
7362
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
+ }
7363
7512
  default: {
7364
7513
  console.error(`Unknown command: ${cmd}`);
7365
7514
  printHelp();