@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/CHANGELOG.md +119 -0
- package/LICENSE +21 -0
- package/README.ja.md +220 -0
- package/README.ko.md +219 -0
- package/README.md +220 -0
- package/README.zh-CN.md +219 -0
- package/bin/ccg.js +391 -0
- package/ccg.md +303 -0
- package/ccg.sh +928 -0
- package/package.json +54 -0
- package/scripts/curl-install.sh +97 -0
- package/scripts/install.sh +55 -0
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
|