@mcgrapeng/ccg 3.1.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/ccg.sh ADDED
@@ -0,0 +1,928 @@
1
+ #!/usr/bin/env bash
2
+ # ccg.sh v3 — Helper functions for /ccg slash command.
3
+ # Sourced by Claude when executing the /ccg protocol.
4
+ # Pure bash (3.2+), POSIX awk/sed/find/git. Safe under set -u.
5
+ #
6
+ # v3 identity: divergence detector, not consensus reporter.
7
+ # - Pillar 1: synthesizer outputs AGREEMENT / DIVERGENCE / BLINDSPOT
8
+ # - Pillar 2: ccg_risk_score → auto-pick cost|balanced|quality
9
+ # - Pillar 3: ccg_ledger_record → append-only review history
10
+ #
11
+ # Public API:
12
+ # ccg_init → echo CCG_DIR=... CCG_*_PROMPT=... etc.
13
+ # ccg_preflight → echo CCG_PREFLIGHT_{CODEX,GEMINI}={ok|...}
14
+ # ccg_diff_capture <out_file> → 4-level fallback; emits CCG_DIFF_SOURCE
15
+ # ccg_risk_score <diff_file> → deterministic 0..100+ score → suggested mode
16
+ # ccg_codex <prompt> <out> → call codex; honors cache; logs usage
17
+ # ccg_gemini <prompt> <out> → call gemini; honors cache; logs usage
18
+ # ccg_actual <prompt_f> <result_f> <provider> → echo USD actual cost
19
+ # ccg_usage [--this-month|--all] → summarize $XDG_DATA_HOME/ccg/usage.log
20
+ # ccg_ledger_record <workdir> → append JSONL row to $XDG_DATA_HOME/ccg/ledger.jsonl
21
+ # ccg_ledger_query [path-substring] → find prior reviews touching path
22
+ # ccg_cleanup <dir> → rm -rf the workdir (skipped if CCG_KEEP_ARTIFACTS=1)
23
+ #
24
+ # Configuration via environment variables:
25
+ # CCG_MODE — cost | balanced | quality (default: balanced)
26
+ # (empty/auto + risk score available → auto-pick)
27
+ # CCG_CODEX_MODEL — Override codex model (wins over CCG_MODE)
28
+ # CCG_GEMINI_MODEL — Override gemini model (wins over CCG_MODE)
29
+ # CCG_CODEX_TIMEOUT — Codex hard timeout seconds (default: 240)
30
+ # CCG_GEMINI_TIMEOUT — Gemini hard timeout seconds (default: 120)
31
+ # CCG_KEEP_ARTIFACTS — Set to 1 to keep workdir for debugging
32
+ # CCG_NO_CACHE — Set to 1 to bypass prompt-hash cache
33
+ # CCG_CACHE_TTL_HOURS — Cache TTL in hours (default: 24)
34
+ # CCG_CACHE_DIR — Cache directory (default: $XDG_CACHE_HOME/ccg/cache)
35
+ # CCG_MAX_PROMPT_KB — Reject prompts larger than this (default: 100)
36
+ # CCG_USAGE_LOG — Override usage log path (default: $XDG_DATA_HOME/ccg/usage.log)
37
+ # CCG_LEDGER_LOG — Override ledger path (default: $XDG_DATA_HOME/ccg/ledger.jsonl)
38
+ #
39
+ # Storage paths follow XDG Base Directory Specification:
40
+ # * Cache: $XDG_CACHE_HOME/ccg (fallback: ~/.cache/ccg)
41
+ # * Data: $XDG_DATA_HOME/ccg (fallback: ~/.local/share/ccg)
42
+ # * Config: $XDG_CONFIG_HOME/ccg (fallback: ~/.config/ccg)
43
+ # Legacy ~/.ccg/* is auto-migrated on first run; deletion is non-destructive.
44
+ #
45
+ # Pricing snapshot: 2026-05 (USD per 1M tokens, official API rates).
46
+ # Update _ccg_price() to refresh.
47
+
48
+ # ============================================================
49
+ # Internal: XDG path resolution + one-time legacy migration
50
+ # ============================================================
51
+ _ccg_xdg_data_dir() { printf '%s/ccg\n' "${XDG_DATA_HOME:-$HOME/.local/share}"; }
52
+ _ccg_xdg_cache_dir() { printf '%s/ccg\n' "${XDG_CACHE_HOME:-$HOME/.cache}"; }
53
+ _ccg_xdg_config_dir() { printf '%s/ccg\n' "${XDG_CONFIG_HOME:-$HOME/.config}"; }
54
+
55
+ # Migrate ~/.ccg/* to XDG locations on first encounter.
56
+ # Idempotent: only moves if source exists AND destination does NOT.
57
+ _ccg_migrate_legacy() {
58
+ local legacy="$HOME/.ccg"
59
+ [ -d "$legacy" ] || return 0
60
+
61
+ local data_dir cache_dir
62
+ data_dir=$(_ccg_xdg_data_dir)
63
+ cache_dir=$(_ccg_xdg_cache_dir)
64
+ mkdir -p "$data_dir" "$cache_dir"
65
+
66
+ [ -f "$legacy/usage.log" ] && [ ! -e "$data_dir/usage.log" ] && mv "$legacy/usage.log" "$data_dir/usage.log" 2>/dev/null
67
+ [ -f "$legacy/ledger.jsonl" ] && [ ! -e "$data_dir/ledger.jsonl" ] && mv "$legacy/ledger.jsonl" "$data_dir/ledger.jsonl" 2>/dev/null
68
+ [ -d "$legacy/cache" ] && [ ! -e "$cache_dir/cache" ] && mv "$legacy/cache" "$cache_dir/cache" 2>/dev/null
69
+
70
+ # Drop empty legacy dir, keep non-empty for user inspection
71
+ rmdir "$legacy" 2>/dev/null || true
72
+ }
73
+ _ccg_migrate_legacy
74
+
75
+ # ============================================================
76
+ # Internal: portable timeout (sub-second polling, wall-clock deadline)
77
+ # ============================================================
78
+ _ccg_run_with_timeout() {
79
+ local seconds="$1"; shift
80
+ local start_ts ec=0
81
+ start_ts=$(date +%s)
82
+
83
+ if command -v timeout >/dev/null 2>&1; then
84
+ timeout "${seconds}s" "$@" || ec=$?
85
+ elif command -v gtimeout >/dev/null 2>&1; then
86
+ gtimeout "${seconds}s" "$@" || ec=$?
87
+ else
88
+ # CRITICAL: explicit `<&0` preserves stdin into backgrounded child.
89
+ # Bash auto-redirects async stdin to /dev/null when job control is off.
90
+ "$@" <&0 &
91
+ local pid=$!
92
+ local now elapsed_sec timed_out=0
93
+ local sleep_unit
94
+ if sleep 0.1 2>/dev/null; then sleep_unit=0.1; else sleep_unit=1; fi
95
+ while kill -0 "$pid" 2>/dev/null; do
96
+ now=$(date +%s)
97
+ elapsed_sec=$((now - start_ts))
98
+ if [ "$elapsed_sec" -ge "$seconds" ]; then
99
+ timed_out=1
100
+ kill -TERM "$pid" 2>/dev/null || true
101
+ sleep "$sleep_unit" 2>/dev/null || true
102
+ kill -KILL "$pid" 2>/dev/null || true
103
+ break
104
+ fi
105
+ sleep "$sleep_unit" 2>/dev/null || break
106
+ done
107
+ if wait "$pid" 2>/dev/null; then ec=0; else ec=$?; fi
108
+ if [ "$timed_out" = "1" ]; then return 124; fi
109
+ if [ "$ec" -eq 127 ]; then ec=0; fi
110
+ return "$ec"
111
+ fi
112
+
113
+ local end_ts elapsed
114
+ end_ts=$(date +%s)
115
+ elapsed=$((end_ts - start_ts))
116
+ if [ "$ec" -eq 124 ]; then return 124; fi
117
+ if [ "$ec" -eq 137 ] || [ "$ec" -eq 143 ]; then
118
+ if [ "$elapsed" -ge $((seconds - 2)) ]; then return 124; fi
119
+ fi
120
+ return "$ec"
121
+ }
122
+
123
+ # ============================================================
124
+ # Internal: redact secrets
125
+ # ============================================================
126
+ _ccg_redact() {
127
+ LC_ALL=C sed -E \
128
+ -e 's/(sk-[A-Za-z0-9_-]{1,12})[A-Za-z0-9_-]{8,}/\1***REDACTED***/g' \
129
+ -e 's/(AIza[A-Za-z0-9_-]{0,6})[A-Za-z0-9_-]{15,}/\1***REDACTED***/g' \
130
+ -e 's/(gh[opsu]_)[A-Za-z0-9]+/\1***REDACTED***/g' \
131
+ -e 's/(xox[abporst]-)[A-Za-z0-9-]+/\1***REDACTED***/g' \
132
+ -e 's/(AKIA[A-Z0-9]{4})[A-Z0-9]+/\1***REDACTED***/g' \
133
+ -e 's/(eyJ[A-Za-z0-9_-]{8})[A-Za-z0-9._\/+=-]+/\1***REDACTED***/g' \
134
+ -e 's/(Bearer +)[A-Za-z0-9._\/+=-]+/\1***REDACTED***/g' \
135
+ -e 's#(https?://[^/[:space:]]+/[^?[:space:]]*)\?[^[:space:]]+#\1?***REDACTED***#g'
136
+ }
137
+
138
+ # ============================================================
139
+ # Internal: shell launcher detection (for Gemini API key resolution)
140
+ # ============================================================
141
+ _ccg_pick_launcher() {
142
+ local need_var="$1"
143
+ if [ -n "$(printenv "$need_var" 2>/dev/null)" ]; then echo ""; return 0; fi
144
+ if command -v zsh >/dev/null 2>&1 && \
145
+ zsh -i -c "printenv $need_var" 2>/dev/null | grep -q .; then
146
+ echo "zsh -i -c "; return 0
147
+ fi
148
+ if command -v bash >/dev/null 2>&1 && \
149
+ bash -i -c "printenv $need_var" 2>/dev/null | grep -q .; then
150
+ echo "bash -i -c "; return 0
151
+ fi
152
+ echo ""; return 1
153
+ }
154
+
155
+ # ============================================================
156
+ # Internal: pricing table (USD per 1M tokens, as of 2026-05)
157
+ # ============================================================
158
+ _ccg_price() {
159
+ local model="$1" field="${2:-input}"
160
+ local in_price out_price
161
+ case "$model" in
162
+ gpt-5|gpt-5-2025-*) in_price=1.25; out_price=10.00 ;;
163
+ gpt-5-mini|gpt-5-mini-*) in_price=0.25; out_price=2.00 ;;
164
+ gpt-5-nano|gpt-5-nano-*) in_price=0.05; out_price=0.40 ;;
165
+ gpt-4.1) in_price=2.00; out_price=8.00 ;;
166
+ gpt-4.1-mini) in_price=0.40; out_price=1.60 ;;
167
+ gpt-4o) in_price=2.50; out_price=10.00 ;;
168
+ gpt-4o-mini) in_price=0.15; out_price=0.60 ;;
169
+ o3|o3-*) in_price=2.00; out_price=8.00 ;;
170
+ o4-mini|o4-mini-*) in_price=1.10; out_price=4.40 ;;
171
+ gemini-2.5-pro|gemini-2.5-pro-*) in_price=1.25; out_price=10.00 ;;
172
+ gemini-2.5-flash|gemini-2.5-flash-2025-*) in_price=0.075; out_price=0.30 ;;
173
+ gemini-2.5-flash-lite|gemini-2.5-flash-lite-*) in_price=0.05; out_price=0.20 ;;
174
+ gemini-1.5-pro|gemini-1.5-pro-*) in_price=1.25; out_price=5.00 ;;
175
+ gemini-1.5-flash|gemini-1.5-flash-*) in_price=0.075; out_price=0.30 ;;
176
+ *) in_price=0; out_price=0 ;;
177
+ esac
178
+ case "$field" in
179
+ input) echo "$in_price" ;;
180
+ output) echo "$out_price" ;;
181
+ *) echo "0" ;;
182
+ esac
183
+ }
184
+
185
+ _ccg_tokens_from_chars() {
186
+ awk -v c="$1" 'BEGIN { r=3.0; printf "%d", (c + r - 1) / r }'
187
+ }
188
+
189
+ # ============================================================
190
+ # Internal: mode → model resolution (silent)
191
+ # ============================================================
192
+ _ccg_resolve_codex_model() {
193
+ if [ -n "${CCG_CODEX_MODEL:-}" ]; then echo "$CCG_CODEX_MODEL"; return; fi
194
+ case "${CCG_MODE:-balanced}" in
195
+ cost) echo "gpt-5-nano" ;;
196
+ quality) echo "gpt-5" ;;
197
+ *) echo "gpt-5-mini" ;;
198
+ esac
199
+ }
200
+ _ccg_resolve_gemini_model() {
201
+ if [ -n "${CCG_GEMINI_MODEL:-}" ]; then echo "$CCG_GEMINI_MODEL"; return; fi
202
+ case "${CCG_MODE:-balanced}" in
203
+ cost) echo "gemini-2.5-flash-lite" ;;
204
+ quality) echo "gemini-2.5-pro" ;;
205
+ *) echo "gemini-2.5-flash" ;;
206
+ esac
207
+ }
208
+
209
+ # ============================================================
210
+ # Internal: cache key + path
211
+ # ============================================================
212
+ _ccg_cache_dir() {
213
+ local d="${CCG_CACHE_DIR:-$(_ccg_xdg_cache_dir)/cache}"
214
+ mkdir -p "$d" 2>/dev/null
215
+ echo "$d"
216
+ }
217
+
218
+ _ccg_cache_key() {
219
+ local prompt_file="$1" model="$2"
220
+ local hasher
221
+ if command -v shasum >/dev/null 2>&1; then hasher="shasum -a 256"
222
+ elif command -v sha256sum >/dev/null 2>&1; then hasher="sha256sum"
223
+ else echo ""; return 1; fi
224
+ ( printf 'model=%s\n' "$model"; cat "$prompt_file" ) | $hasher | awk '{print $1}'
225
+ }
226
+
227
+ _ccg_cache_lookup() {
228
+ if [ "${CCG_NO_CACHE:-0}" = "1" ]; then return 1; fi
229
+ local key="$1" cache_dir
230
+ cache_dir=$(_ccg_cache_dir) || return 1
231
+ local cache_file="$cache_dir/$key"
232
+ [ -s "$cache_file" ] || return 1
233
+ local ttl_hours="${CCG_CACHE_TTL_HOURS:-24}"
234
+ local ttl_min=$((ttl_hours * 60))
235
+ if find -L "$cache_file" -mmin +"$ttl_min" 2>/dev/null | grep -q .; then
236
+ return 1
237
+ fi
238
+ echo "$cache_file"
239
+ return 0
240
+ }
241
+
242
+ _ccg_cache_store() {
243
+ local key="$1" result_file="$2" cache_dir
244
+ cache_dir=$(_ccg_cache_dir) || return 1
245
+ cp "$result_file" "$cache_dir/$key" 2>/dev/null || true
246
+ }
247
+
248
+ # ============================================================
249
+ # Internal: usage log
250
+ # ============================================================
251
+ _ccg_log_usage() {
252
+ local provider="$1" model="$2" in_tokens="$3" out_tokens="$4" usd="$5" cached="$6"
253
+ local log="${CCG_USAGE_LOG:-$(_ccg_xdg_data_dir)/usage.log}"
254
+ mkdir -p "$(dirname "$log")" 2>/dev/null
255
+ local ts
256
+ ts=$(date -u +'%Y-%m-%dT%H:%M:%SZ')
257
+ printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \
258
+ "$ts" "$provider" "$model" "$in_tokens" "$out_tokens" "$usd" "$cached" \
259
+ >> "$log"
260
+ }
261
+
262
+ # ============================================================
263
+ # Public: ccg_actual <prompt_file> <result_file> <provider>
264
+ # ============================================================
265
+ ccg_actual() {
266
+ local prompt_file="$1" result_file="$2" provider="$3"
267
+ if [ ! -f "$prompt_file" ] || [ ! -f "$result_file" ]; then
268
+ echo "CCG_ACTUAL_FAIL=missing-file"
269
+ return 2
270
+ fi
271
+ local in_chars out_chars in_tokens out_tokens model
272
+ in_chars=$(wc -c <"$prompt_file" | tr -d ' ')
273
+ out_chars=$(wc -c <"$result_file" | tr -d ' ')
274
+ in_tokens=$(_ccg_tokens_from_chars "$in_chars")
275
+ out_tokens=$(_ccg_tokens_from_chars "$out_chars")
276
+ case "$provider" in
277
+ codex) model=$(_ccg_resolve_codex_model) ;;
278
+ gemini) model=$(_ccg_resolve_gemini_model) ;;
279
+ *) echo "CCG_ACTUAL_FAIL=unknown-provider"; return 2 ;;
280
+ esac
281
+ local in_price out_price usd
282
+ in_price=$(_ccg_price "$model" input)
283
+ out_price=$(_ccg_price "$model" output)
284
+ usd=$(awk -v ip="$in_price" -v op="$out_price" -v it="$in_tokens" -v ot="$out_tokens" \
285
+ 'BEGIN { printf "%.6f", (ip * it + op * ot) / 1000000 }')
286
+ local upper
287
+ upper=$(echo "$provider" | tr '[:lower:]' '[:upper:]')
288
+ printf 'CCG_ACT_%s_MODEL=%s\n' "$upper" "$model"
289
+ printf 'CCG_ACT_%s_TOKENS=in=%s,out=%s\n' "$upper" "$in_tokens" "$out_tokens"
290
+ printf 'CCG_ACT_%s_COST=USD=%.4f\n' "$upper" "$usd"
291
+ }
292
+
293
+ # ============================================================
294
+ # Public: ccg_usage [--this-month|--all]
295
+ # ============================================================
296
+ ccg_usage() {
297
+ local mode="${1:---this-month}"
298
+ local log="${CCG_USAGE_LOG:-$(_ccg_xdg_data_dir)/usage.log}"
299
+ if [ ! -s "$log" ]; then
300
+ echo "CCG_USAGE=no-data (log: $log)"
301
+ return 0
302
+ fi
303
+ local cutoff=""
304
+ case "$mode" in
305
+ --this-month) cutoff=$(date -u +'%Y-%m') ;;
306
+ --all|"") cutoff="" ;;
307
+ --since=*) cutoff="${mode#--since=}" ;;
308
+ *) echo "CCG_USAGE_FAIL=unknown-flag: $mode"; return 2 ;;
309
+ esac
310
+ awk -v cutoff="$cutoff" '
311
+ BEGIN { OFS="\t" }
312
+ cutoff != "" && index($1, cutoff) != 1 { next }
313
+ {
314
+ total_calls++
315
+ cost[$2] += $6
316
+ tokens_in[$2] += $4
317
+ tokens_out[$2] += $5
318
+ if ($7 == "1") cached_calls++
319
+ }
320
+ END {
321
+ printf "CCG_USAGE_RANGE=%s\n", (cutoff == "" ? "all-time" : cutoff)
322
+ printf "CCG_USAGE_TOTAL_CALLS=%d\n", total_calls
323
+ printf "CCG_USAGE_CACHED_CALLS=%d\n", cached_calls + 0
324
+ total_usd = 0
325
+ for (p in cost) {
326
+ printf "CCG_USAGE_%s_COST=USD=%.4f tokens=in=%d,out=%d\n", \
327
+ toupper(p), cost[p], tokens_in[p], tokens_out[p]
328
+ total_usd += cost[p]
329
+ }
330
+ printf "CCG_USAGE_TOTAL_COST=USD=%.4f\n", total_usd
331
+ }
332
+ ' "$log"
333
+ }
334
+
335
+ # ============================================================
336
+ # Public: ccg_diff_capture <out_file>
337
+ # Fallback chain: worktree → staged → upstream(branch) → origin-head
338
+ # Emits CCG_DIFF_SOURCE so callers know what scope was captured.
339
+ # ============================================================
340
+ ccg_diff_capture() {
341
+ local out_file="$1"
342
+ if ! command -v git >/dev/null 2>&1; then
343
+ echo "CCG_DIFF_FAIL=git-missing"; return 127
344
+ fi
345
+ if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
346
+ echo "CCG_DIFF_FAIL=not-a-git-repo"; return 2
347
+ fi
348
+
349
+ local diff_text="" source=""
350
+
351
+ diff_text=$(git diff HEAD 2>/dev/null)
352
+ [ -n "$diff_text" ] && source="worktree"
353
+
354
+ if [ -z "$diff_text" ]; then
355
+ diff_text=$(git diff --cached 2>/dev/null)
356
+ [ -n "$diff_text" ] && source="staged"
357
+ fi
358
+
359
+ if [ -z "$diff_text" ]; then
360
+ local upstream
361
+ upstream=$(git rev-parse --abbrev-ref --symbolic-full-name '@{u}' 2>/dev/null)
362
+ if [ -n "$upstream" ] && [ "$upstream" != "@{u}" ]; then
363
+ diff_text=$(git diff "${upstream}...HEAD" 2>/dev/null)
364
+ [ -n "$diff_text" ] && source="upstream:${upstream}"
365
+ fi
366
+ fi
367
+
368
+ if [ -z "$diff_text" ]; then
369
+ if git rev-parse --verify --quiet origin/HEAD >/dev/null 2>&1; then
370
+ diff_text=$(git diff "origin/HEAD...HEAD" 2>/dev/null)
371
+ [ -n "$diff_text" ] && source="origin-head"
372
+ fi
373
+ fi
374
+
375
+ if [ -z "$diff_text" ]; then
376
+ echo "CCG_DIFF_FAIL=empty-diff"; return 1
377
+ fi
378
+
379
+ printf '%s\n' "$diff_text" > "$out_file"
380
+ local sz
381
+ sz=$(wc -c <"$out_file" | tr -d ' ')
382
+ echo "CCG_DIFF_OK=${sz}b"
383
+ echo "CCG_DIFF_SOURCE=${source}"
384
+ }
385
+
386
+ # ============================================================
387
+ # Public: ccg_risk_score <diff_file>
388
+ # Deterministic risk scoring → suggested mode (cost|balanced|quality).
389
+ # Pure rules, no LLM. Output is parseable KEY=VAL lines.
390
+ # Score scale: 0..100+. Thresholds: <20 cost, <60 balanced, else quality.
391
+ # ============================================================
392
+ ccg_risk_score() {
393
+ local diff_file="$1"
394
+ if [ ! -s "$diff_file" ]; then
395
+ echo "CCG_RISK_FAIL=empty-diff"; return 2
396
+ fi
397
+
398
+ local score=0 reasons=""
399
+ local files_changed lines_added lines_removed total_changed
400
+
401
+ files_changed=$(grep -cE '^diff --git ' "$diff_file" 2>/dev/null | head -1)
402
+ lines_added=$(grep -cE '^\+[^+]' "$diff_file" 2>/dev/null | head -1)
403
+ lines_removed=$(grep -cE '^-[^-]' "$diff_file" 2>/dev/null | head -1)
404
+ : "${files_changed:=0}"; : "${lines_added:=0}"; : "${lines_removed:=0}"
405
+ total_changed=$((lines_added + lines_removed))
406
+
407
+ # File path signals (high-risk areas)
408
+ local path_block
409
+ path_block=$(grep -E '^diff --git ' "$diff_file" 2>/dev/null || true)
410
+
411
+ _risk_path_match() {
412
+ local pat="$1" weight="$2" label="$3"
413
+ if printf '%s\n' "$path_block" | grep -qiE -- "$pat"; then
414
+ score=$((score + weight))
415
+ reasons="${reasons}${reasons:+ }${label}+${weight}"
416
+ fi
417
+ }
418
+
419
+ _risk_path_match '/(auth|authn|authz|login|session|oauth|jwt|token)' 35 "auth"
420
+ _risk_path_match '/(payment|billing|invoice|stripe|checkout|charge)' 40 "payment"
421
+ _risk_path_match '/(migration|migrate|schema)' 30 "migration"
422
+ _risk_path_match '/(crypto|encrypt|decrypt|hash|secret)' 30 "crypto"
423
+ _risk_path_match '/(security|permission|acl|rbac)' 25 "security"
424
+ _risk_path_match '/(infra|terraform|kubernetes|k8s|helm|deploy)' 20 "infra"
425
+ _risk_path_match '/(\.github/workflows|ci|pipeline)' 15 "ci"
426
+
427
+ # Low-risk path signals (subtract)
428
+ if printf '%s\n' "$path_block" \
429
+ | grep -qE '\.(md|txt|rst|adoc)$|/docs?/|/CHANGELOG'; then
430
+ if ! printf '%s\n' "$path_block" | grep -qvE '\.(md|txt|rst|adoc)$|/docs?/|/CHANGELOG'; then
431
+ score=$((score - 40))
432
+ reasons="${reasons}${reasons:+ }docs_only-40"
433
+ fi
434
+ fi
435
+
436
+ # Content signals (operate on the diff body, +lines only)
437
+ local added_body
438
+ added_body=$(grep -E '^\+[^+]' "$diff_file" 2>/dev/null || true)
439
+
440
+ _risk_body_match() {
441
+ local pat="$1" weight="$2" label="$3"
442
+ if printf '%s' "$added_body" | grep -qiE -- "$pat"; then
443
+ score=$((score + weight))
444
+ reasons="${reasons}${reasons:+ }${label}+${weight}"
445
+ fi
446
+ }
447
+
448
+ _risk_body_match '\b(exec|eval|system|popen|shell_exec|child_process|spawn[^a-z])' 25 "shell_exec"
449
+ _risk_body_match '\b(SELECT|INSERT|UPDATE|DELETE|DROP|ALTER)\b.*(\$\{?[a-zA-Z_]|\{[a-zA-Z_])' 30 "sql_interp"
450
+ _risk_body_match '\b(rm -rf|unlink|removeSync|fs\.rm|os\.remove)' 20 "fs_delete"
451
+ _risk_body_match '\b(setuid|setgid|chmod 7|chown)' 25 "privilege"
452
+ _risk_body_match '\b(localhost|0\.0\.0\.0|127\.0\.0\.1):[0-9]' 5 "hardcoded_host"
453
+ _risk_body_match '\b(TODO|FIXME|XXX|HACK)\b' 5 "todo_marker"
454
+
455
+ # Size signal
456
+ if [ "$total_changed" -gt 600 ]; then
457
+ score=$((score + 25)); reasons="${reasons}${reasons:+ }size>600+25"
458
+ elif [ "$total_changed" -gt 300 ]; then
459
+ score=$((score + 15)); reasons="${reasons}${reasons:+ }size>300+15"
460
+ elif [ "$total_changed" -gt 100 ]; then
461
+ score=$((score + 5)); reasons="${reasons}${reasons:+ }size>100+5"
462
+ fi
463
+
464
+ # Multi-file blast radius
465
+ if [ "$files_changed" -gt 8 ]; then
466
+ score=$((score + 10)); reasons="${reasons}${reasons:+ }files>8+10"
467
+ fi
468
+
469
+ # Floor
470
+ [ "$score" -lt 0 ] && score=0
471
+
472
+ local mode
473
+ if [ "$score" -lt 20 ]; then mode="cost"
474
+ elif [ "$score" -lt 60 ]; then mode="balanced"
475
+ else mode="quality"
476
+ fi
477
+
478
+ printf 'CCG_RISK_SCORE=%d\n' "$score"
479
+ printf 'CCG_RISK_MODE=%s\n' "$mode"
480
+ printf 'CCG_RISK_FILES=%d\n' "$files_changed"
481
+ printf 'CCG_RISK_LINES=+%d-%d\n' "$lines_added" "$lines_removed"
482
+ printf 'CCG_RISK_REASONS=%s\n' "${reasons:-none}"
483
+ }
484
+
485
+ # ============================================================
486
+ # Public: ccg_ledger_record <workdir>
487
+ # Append a JSONL record of this review to the ledger.
488
+ # Reads CCG_DIR/diff.txt + CCG_DIR/synthesis.txt + risk score.
489
+ # ============================================================
490
+ ccg_ledger_record() {
491
+ local workdir="$1"
492
+ local ledger="${CCG_LEDGER_LOG:-$(_ccg_xdg_data_dir)/ledger.jsonl}"
493
+ mkdir -p "$(dirname "$ledger")" 2>/dev/null
494
+
495
+ local diff_file="$workdir/diff.txt"
496
+ local synth_file="$workdir/synthesis.txt"
497
+ local risk_file="$workdir/risk.txt"
498
+
499
+ local ts repo branch sha files lines mode score
500
+ ts=$(date -u +'%Y-%m-%dT%H:%M:%SZ')
501
+
502
+ if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
503
+ repo=$(git rev-parse --show-toplevel 2>/dev/null)
504
+ branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null)
505
+ sha=$(git rev-parse --short HEAD 2>/dev/null)
506
+ else
507
+ repo=""; branch=""; sha=""
508
+ fi
509
+
510
+ if [ -s "$risk_file" ]; then
511
+ mode=$(grep '^CCG_RISK_MODE=' "$risk_file" | head -1 | cut -d= -f2)
512
+ score=$(grep '^CCG_RISK_SCORE=' "$risk_file" | head -1 | cut -d= -f2)
513
+ else
514
+ mode=""; score=""
515
+ fi
516
+
517
+ if [ -s "$diff_file" ]; then
518
+ files=$(grep -cE '^diff --git ' "$diff_file" 2>/dev/null | head -1)
519
+ local added removed
520
+ added=$(grep -cE '^\+[^+]' "$diff_file" 2>/dev/null | head -1)
521
+ removed=$(grep -cE '^-[^-]' "$diff_file" 2>/dev/null | head -1)
522
+ : "${files:=0}"; : "${added:=0}"; : "${removed:=0}"
523
+ lines="+${added}-${removed}"
524
+ else
525
+ files=0; lines="+0-0"
526
+ fi
527
+
528
+ # Files touched (path list, deduped)
529
+ local paths_json="[]"
530
+ if [ -s "$diff_file" ]; then
531
+ paths_json=$(awk '
532
+ /^diff --git a\// {
533
+ sub(/^diff --git a\//, "")
534
+ sub(/ b\/.*$/, "")
535
+ if (!seen[$0]++) paths[++n] = $0
536
+ }
537
+ END {
538
+ printf "["
539
+ for (i = 1; i <= n; i++) {
540
+ if (i > 1) printf ","
541
+ gsub(/\\/, "\\\\", paths[i])
542
+ gsub(/"/, "\\\"", paths[i])
543
+ printf "\"%s\"", paths[i]
544
+ }
545
+ printf "]"
546
+ }
547
+ ' "$diff_file")
548
+ fi
549
+
550
+ # Synthesis summary (first 400 chars, JSON-escaped)
551
+ local synth_excerpt=""
552
+ if [ -s "$synth_file" ]; then
553
+ synth_excerpt=$(LC_ALL=C head -c 400 "$synth_file" \
554
+ | _ccg_redact \
555
+ | awk 'BEGIN { ORS = "" }
556
+ { gsub(/\\/, "\\\\"); gsub(/"/, "\\\""); gsub(/\t/, "\\t"); gsub(/\r/, ""); gsub(/\n/, "\\n"); print }')
557
+ fi
558
+
559
+ printf '{"ts":"%s","repo":"%s","branch":"%s","sha":"%s","mode":"%s","risk":%s,"files":%d,"lines":"%s","paths":%s,"synthesis":"%s"}\n' \
560
+ "$ts" "$repo" "$branch" "$sha" "$mode" "${score:-null}" \
561
+ "$files" "$lines" "$paths_json" "$synth_excerpt" \
562
+ >> "$ledger"
563
+
564
+ echo "CCG_LEDGER_OK=appended"
565
+ echo "CCG_LEDGER_PATH=$ledger"
566
+ }
567
+
568
+ # ============================================================
569
+ # Public: ccg_ledger_query [path-substring]
570
+ # Find prior reviews touching a given path (or all if omitted).
571
+ # ============================================================
572
+ ccg_ledger_query() {
573
+ local needle="${1:-}"
574
+ local ledger="${CCG_LEDGER_LOG:-$(_ccg_xdg_data_dir)/ledger.jsonl}"
575
+ if [ ! -s "$ledger" ]; then
576
+ echo "CCG_LEDGER=no-data (log: $ledger)"
577
+ return 0
578
+ fi
579
+ if [ -z "$needle" ]; then
580
+ local n
581
+ n=$(wc -l <"$ledger" | tr -d ' ')
582
+ echo "CCG_LEDGER_TOTAL=$n"
583
+ tail -5 "$ledger"
584
+ return 0
585
+ fi
586
+ local matches
587
+ matches=$(grep -F -- "$needle" "$ledger" 2>/dev/null | tail -10)
588
+ if [ -z "$matches" ]; then
589
+ echo "CCG_LEDGER_MATCH=0 needle=$needle"
590
+ return 0
591
+ fi
592
+ local count
593
+ count=$(printf '%s\n' "$matches" | wc -l | tr -d ' ')
594
+ echo "CCG_LEDGER_MATCH=$count needle=$needle"
595
+ printf '%s\n' "$matches"
596
+ }
597
+
598
+ # ============================================================
599
+ # Public: init workdir (24h orphan sweep, mode 700)
600
+ # ============================================================
601
+ ccg_init() {
602
+ local tmpbase="${TMPDIR:-/tmp}" uid
603
+ uid=$(id -u 2>/dev/null)
604
+ local sweep_age_min=1440
605
+ for root in "$tmpbase" /tmp; do
606
+ [ "$root" = "$tmpbase" ] || [ "$root" = "/tmp" ] || continue
607
+ [ -d "$root" ] || continue
608
+ if [ -n "$uid" ]; then
609
+ find -L "$root" -maxdepth 1 -name 'ccg.*' -type d -user "$uid" -mmin "+$sweep_age_min" \
610
+ -exec rm -rf {} + 2>/dev/null || true
611
+ else
612
+ find -L "$root" -maxdepth 1 -name 'ccg.*' -type d -mmin "+$sweep_age_min" \
613
+ -exec rm -rf {} + 2>/dev/null || true
614
+ fi
615
+ [ "$tmpbase" = "/tmp" ] && break
616
+ done
617
+
618
+ local workdir
619
+ workdir=$(mktemp -d -t "ccg.XXXXXXXX" 2>/dev/null) || workdir=$(mktemp -d 2>/dev/null)
620
+ if [ -z "$workdir" ] || [ ! -d "$workdir" ]; then
621
+ echo "CCG_INIT_FAIL=mktemp-failed" >&2
622
+ return 1
623
+ fi
624
+ if [[ "$workdir" != *"/ccg."* ]]; then
625
+ local renamed="${workdir%/*}/ccg.fallback.$$.${RANDOM:-x}"
626
+ if mv "$workdir" "$renamed" 2>/dev/null; then workdir="$renamed"; fi
627
+ fi
628
+ chmod 700 "$workdir"
629
+ printf 'CCG_DIR=%s\n' "$workdir"
630
+ printf 'CCG_CODEX_PROMPT=%s\n' "$workdir/codex.prompt"
631
+ printf 'CCG_GEMINI_PROMPT=%s\n' "$workdir/gemini.prompt"
632
+ printf 'CCG_CODEX_RESULT=%s\n' "$workdir/codex.result"
633
+ printf 'CCG_GEMINI_RESULT=%s\n' "$workdir/gemini.result"
634
+ printf 'CCG_CODEX_ERR=%s\n' "$workdir/codex.err"
635
+ printf 'CCG_GEMINI_ERR=%s\n' "$workdir/gemini.err"
636
+ printf 'CCG_DIFF_FILE=%s\n' "$workdir/diff.txt"
637
+ printf 'CCG_SYNTHESIS_FILE=%s\n' "$workdir/synthesis.txt"
638
+ printf 'CCG_RISK_FILE=%s\n' "$workdir/risk.txt"
639
+ }
640
+
641
+ # ============================================================
642
+ # Public: preflight
643
+ # ============================================================
644
+ ccg_preflight() {
645
+ if ! command -v codex >/dev/null 2>&1; then
646
+ echo "CCG_PREFLIGHT_CODEX=missing"
647
+ else
648
+ echo "CCG_PREFLIGHT_CODEX=ok"
649
+ fi
650
+
651
+ if ! command -v gemini >/dev/null 2>&1; then
652
+ echo "CCG_PREFLIGHT_GEMINI=missing"
653
+ return 0
654
+ fi
655
+
656
+ local launcher
657
+ if launcher=$(_ccg_pick_launcher GEMINI_API_KEY) && [ -n "$launcher" ] || \
658
+ [ -n "${GEMINI_API_KEY:-}" ]; then
659
+ echo "CCG_PREFLIGHT_GEMINI=ok"
660
+ echo "CCG_GEMINI_LAUNCHER=${launcher:-direct}"
661
+ else
662
+ echo "CCG_PREFLIGHT_GEMINI=no-api-key"
663
+ fi
664
+ }
665
+
666
+ # ============================================================
667
+ # Internal: prompt size guard
668
+ # ============================================================
669
+ _ccg_check_prompt_size() {
670
+ local prompt_file="$1"
671
+ local max_kb="${CCG_MAX_PROMPT_KB:-100}"
672
+ local sz_b
673
+ sz_b=$(wc -c <"$prompt_file" | tr -d ' ')
674
+ local max_b=$((max_kb * 1024))
675
+ if [ "$sz_b" -gt "$max_b" ]; then
676
+ echo "prompt-too-large-${sz_b}b-max-${max_b}b"
677
+ return 1
678
+ fi
679
+ return 0
680
+ }
681
+
682
+ # ============================================================
683
+ # Public: call Codex (cache-aware, usage-logged)
684
+ # ============================================================
685
+ ccg_codex() {
686
+ local prompt_file="$1"
687
+ local out_file="$2"
688
+ local err_file="${out_file%.result}.err"
689
+ local timeout_sec="${CCG_CODEX_TIMEOUT:-240}"
690
+
691
+ if ! command -v codex >/dev/null 2>&1; then
692
+ : > "$out_file"; echo "CCG_CODEX_FAIL=cli-missing"; return 127
693
+ fi
694
+ if [ ! -s "$prompt_file" ]; then
695
+ : > "$out_file"; echo "CCG_CODEX_FAIL=empty-prompt"; return 2
696
+ fi
697
+ local oversize
698
+ if ! oversize=$(_ccg_check_prompt_size "$prompt_file"); then
699
+ : > "$out_file"; echo "CCG_CODEX_FAIL=$oversize"; return 2
700
+ fi
701
+
702
+ : > "$out_file"; : > "$err_file"
703
+
704
+ local model
705
+ model=$(_ccg_resolve_codex_model)
706
+
707
+ # Cache lookup
708
+ local cache_key cache_hit=""
709
+ if cache_key=$(_ccg_cache_key "$prompt_file" "$model" 2>/dev/null) && [ -n "$cache_key" ]; then
710
+ if cache_hit=$(_ccg_cache_lookup "$cache_key"); then
711
+ cp "$cache_hit" "$out_file"
712
+ local sz
713
+ sz=$(wc -c <"$out_file" | tr -d ' ')
714
+ local in_tok out_tok
715
+ in_tok=$(_ccg_tokens_from_chars "$(wc -c <"$prompt_file" | tr -d ' ')")
716
+ out_tok=$(_ccg_tokens_from_chars "$sz")
717
+ _ccg_log_usage codex "$model" "$in_tok" "$out_tok" "0.000000" "1"
718
+ echo "CCG_CODEX_OK=${sz}b cache=hit"
719
+ return 0
720
+ fi
721
+ fi
722
+
723
+ local ec=0
724
+ _ccg_run_with_timeout "$timeout_sec" \
725
+ codex exec --skip-git-repo-check -m "$model" --output-last-message "$out_file" - \
726
+ < "$prompt_file" \
727
+ > /dev/null \
728
+ 2> "$err_file" || ec=$?
729
+
730
+ if [ "$ec" -eq 124 ]; then
731
+ echo "CCG_CODEX_FAIL=timeout-${timeout_sec}s"; return 124
732
+ fi
733
+ if [ ! -s "$out_file" ]; then
734
+ echo "CCG_CODEX_FAIL=empty-output"
735
+ echo "--- err.log (redacted) ---"
736
+ tail -5 "$err_file" 2>/dev/null | _ccg_redact
737
+ return "$ec"
738
+ fi
739
+ local non_ws
740
+ non_ws=$(tr -d '[:space:]' < "$out_file" | wc -c | tr -d ' ')
741
+ if [ "$non_ws" -lt 1 ]; then
742
+ echo "CCG_CODEX_FAIL=whitespace-only-output"; return 1
743
+ fi
744
+
745
+ local sz
746
+ sz=$(wc -c <"$out_file" | tr -d ' ')
747
+
748
+ # Log usage
749
+ local in_tok out_tok in_price out_price usd
750
+ in_tok=$(_ccg_tokens_from_chars "$(wc -c <"$prompt_file" | tr -d ' ')")
751
+ out_tok=$(_ccg_tokens_from_chars "$sz")
752
+ in_price=$(_ccg_price "$model" input)
753
+ out_price=$(_ccg_price "$model" output)
754
+ usd=$(awk -v ip="$in_price" -v op="$out_price" -v it="$in_tok" -v ot="$out_tok" \
755
+ 'BEGIN { printf "%.6f", (ip * it + op * ot) / 1000000 }')
756
+ _ccg_log_usage codex "$model" "$in_tok" "$out_tok" "$usd" "0"
757
+
758
+ # Store in cache
759
+ [ -n "$cache_key" ] && _ccg_cache_store "$cache_key" "$out_file"
760
+
761
+ echo "CCG_CODEX_OK=${sz}b"
762
+ return 0
763
+ }
764
+
765
+ # ============================================================
766
+ # Public: call Gemini (cache-aware, usage-logged)
767
+ # ============================================================
768
+ ccg_gemini() {
769
+ local prompt_file="$1"
770
+ local out_file="$2"
771
+ local err_file="${out_file%.result}.err"
772
+ local timeout_sec="${CCG_GEMINI_TIMEOUT:-120}"
773
+
774
+ local model
775
+ model=$(_ccg_resolve_gemini_model)
776
+
777
+ if ! command -v gemini >/dev/null 2>&1; then
778
+ : > "$out_file"; echo "CCG_GEMINI_FAIL=cli-missing"; return 127
779
+ fi
780
+ if [ ! -s "$prompt_file" ]; then
781
+ : > "$out_file"; echo "CCG_GEMINI_FAIL=empty-prompt"; return 2
782
+ fi
783
+ local oversize
784
+ if ! oversize=$(_ccg_check_prompt_size "$prompt_file"); then
785
+ : > "$out_file"; echo "CCG_GEMINI_FAIL=$oversize"; return 2
786
+ fi
787
+
788
+ : > "$out_file"; : > "$err_file"
789
+
790
+ # Cache lookup
791
+ local cache_key cache_hit=""
792
+ if cache_key=$(_ccg_cache_key "$prompt_file" "$model" 2>/dev/null) && [ -n "$cache_key" ]; then
793
+ if cache_hit=$(_ccg_cache_lookup "$cache_key"); then
794
+ cp "$cache_hit" "$out_file"
795
+ local sz
796
+ sz=$(wc -c <"$out_file" | tr -d ' ')
797
+ local in_tok out_tok
798
+ in_tok=$(_ccg_tokens_from_chars "$(wc -c <"$prompt_file" | tr -d ' ')")
799
+ out_tok=$(_ccg_tokens_from_chars "$sz")
800
+ _ccg_log_usage gemini "$model" "$in_tok" "$out_tok" "0.000000" "1"
801
+ echo "CCG_GEMINI_OK=${sz}b cache=hit"
802
+ return 0
803
+ fi
804
+ fi
805
+
806
+ local launcher_kind=""
807
+ if [ -n "${GEMINI_API_KEY:-}" ]; then
808
+ launcher_kind="direct"
809
+ elif command -v zsh >/dev/null 2>&1 && \
810
+ zsh -i -c "printenv GEMINI_API_KEY" 2>/dev/null | grep -q .; then
811
+ launcher_kind="zsh"
812
+ elif command -v bash >/dev/null 2>&1 && \
813
+ bash -i -c "printenv GEMINI_API_KEY" 2>/dev/null | grep -q .; then
814
+ launcher_kind="bash"
815
+ fi
816
+ case "${CCG_SHELL_LAUNCHER:-auto}" in
817
+ none) launcher_kind="direct" ;;
818
+ zsh-i) launcher_kind="zsh" ;;
819
+ bash-i) launcher_kind="bash" ;;
820
+ esac
821
+ if [ -z "$launcher_kind" ] || \
822
+ { [ "$launcher_kind" = "direct" ] && [ -z "${GEMINI_API_KEY:-}" ]; }; then
823
+ echo "CCG_GEMINI_FAIL=no-api-key"; return 3
824
+ fi
825
+
826
+ local ec=0
827
+ if [ "$launcher_kind" = "direct" ]; then
828
+ _ccg_run_with_timeout "$timeout_sec" \
829
+ gemini -m "$model" --output-format text \
830
+ < "$prompt_file" > "$out_file" 2> "$err_file" || ec=$?
831
+ elif [ "$launcher_kind" = "zsh" ]; then
832
+ # shellcheck disable=SC2016
833
+ _ccg_run_with_timeout "$timeout_sec" \
834
+ env "_CCG_M=$model" "_CCG_P=$prompt_file" \
835
+ zsh -i -c 'gemini -m "$_CCG_M" --output-format text < "$_CCG_P"' \
836
+ > "$out_file" 2> "$err_file" || ec=$?
837
+ else
838
+ # shellcheck disable=SC2016
839
+ _ccg_run_with_timeout "$timeout_sec" \
840
+ env "_CCG_M=$model" "_CCG_P=$prompt_file" \
841
+ bash -i -c 'gemini -m "$_CCG_M" --output-format text < "$_CCG_P"' \
842
+ > "$out_file" 2> "$err_file" || ec=$?
843
+ fi
844
+
845
+ if [ "$ec" -eq 124 ]; then
846
+ echo "CCG_GEMINI_FAIL=timeout-${timeout_sec}s"; return 124
847
+ fi
848
+ if [ ! -s "$out_file" ]; then
849
+ echo "CCG_GEMINI_FAIL=empty-output"
850
+ echo "--- err.log (redacted) ---"
851
+ tail -5 "$err_file" 2>/dev/null | _ccg_redact
852
+ return "$ec"
853
+ fi
854
+ local non_ws sz
855
+ sz=$(wc -c <"$out_file" | tr -d ' ')
856
+ non_ws=$(tr -d '[:space:]' < "$out_file" | wc -c | tr -d ' ')
857
+ if [ "$non_ws" -lt 1 ]; then
858
+ echo "CCG_GEMINI_FAIL=whitespace-only-output"; return 1
859
+ fi
860
+ if [ "$sz" -lt 800 ]; then
861
+ if LC_ALL=C head -c 400 "$out_file" \
862
+ | grep -qiE '^[[:space:]]*(\{"error"|\[?api error|error[: ]|❌|\bsettlement (blocked|unknown)|\bsafety_violation\b|\bcontent_filter\b|\bcontext (length|deadline) exceeded\b|\bENOTFOUND\b|\bECONNREFUSED\b|connection refused|API key not valid|key.*invalid|unauthorized|forbidden|quota.*exceeded|critical error)' \
863
+ || LC_ALL=C head -c 400 "$out_file" \
864
+ | grep -qE '"code":[[:space:]]*"[A-Z_]+_(BLOCKED|EXCEEDED|NOT_FOUND|INVALID|FAILED)"'; then
865
+ echo "CCG_GEMINI_FAIL=error-leaked-to-stdout"
866
+ echo "--- output sample (redacted) ---"
867
+ LC_ALL=C head -c 200 "$out_file" | _ccg_redact
868
+ return 1
869
+ fi
870
+ fi
871
+
872
+ # Log usage
873
+ local in_tok out_tok in_price out_price usd
874
+ in_tok=$(_ccg_tokens_from_chars "$(wc -c <"$prompt_file" | tr -d ' ')")
875
+ out_tok=$(_ccg_tokens_from_chars "$sz")
876
+ in_price=$(_ccg_price "$model" input)
877
+ out_price=$(_ccg_price "$model" output)
878
+ usd=$(awk -v ip="$in_price" -v op="$out_price" -v it="$in_tok" -v ot="$out_tok" \
879
+ 'BEGIN { printf "%.6f", (ip * it + op * ot) / 1000000 }')
880
+ _ccg_log_usage gemini "$model" "$in_tok" "$out_tok" "$usd" "0"
881
+
882
+ [ -n "$cache_key" ] && _ccg_cache_store "$cache_key" "$out_file"
883
+
884
+ echo "CCG_GEMINI_OK=${sz}b"
885
+ return 0
886
+ }
887
+
888
+ # ============================================================
889
+ # Public: cleanup (path-traversal safe)
890
+ # ============================================================
891
+ ccg_cleanup() {
892
+ local workdir="$1"
893
+ if [ "${CCG_KEEP_ARTIFACTS:-0}" = "1" ]; then
894
+ echo "CCG_CLEANUP=skipped (artifacts kept at $workdir)"
895
+ return 0
896
+ fi
897
+ local normalized="${workdir%/}"
898
+ local base="${normalized##*/}"
899
+ if [ -n "$normalized" ] \
900
+ && [ -d "$normalized" ] \
901
+ && [ ! -L "$normalized" ] \
902
+ && [[ "$normalized" == /* ]] \
903
+ && [[ "$normalized" != *".."* ]] \
904
+ && [[ "$base" == ccg.* ]]; then
905
+ rm -rf "$normalized"
906
+ echo "CCG_CLEANUP=done"
907
+ else
908
+ echo "CCG_CLEANUP=skipped (not a ccg workdir: $workdir)"
909
+ fi
910
+ }
911
+
912
+ # ============================================================
913
+ # Dispatch guard: only when executed (not sourced).
914
+ # ============================================================
915
+ if [ -n "${BASH_SOURCE[0]:-}" ] && [ "${BASH_SOURCE[0]}" = "$0" ]; then
916
+ if [ "$#" -ge 1 ]; then
917
+ cmd="$1"; shift
918
+ case "$cmd" in
919
+ init|preflight|codex|gemini|cleanup|actual|usage|diff_capture|risk_score|ledger_record|ledger_query)
920
+ "ccg_$cmd" "$@"
921
+ ;;
922
+ *)
923
+ echo "Unknown subcommand: $cmd" >&2
924
+ exit 2
925
+ ;;
926
+ esac
927
+ fi
928
+ fi