@ictechgy/context-guard 0.4.1 → 0.4.3

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.
Files changed (45) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/README.ko.md +61 -32
  3. package/README.md +90 -22
  4. package/context-guard-kit/README.md +39 -26
  5. package/context-guard-kit/benchmark_runner.py +273 -8
  6. package/context-guard-kit/claude_transcript_cost_audit.py +325 -12
  7. package/context-guard-kit/context_compress.py +153 -1
  8. package/context-guard-kit/context_filter.py +446 -0
  9. package/context-guard-kit/context_guard_cli.py +3 -0
  10. package/context-guard-kit/context_guard_diet.py +677 -2
  11. package/context-guard-kit/context_pack.py +1694 -2
  12. package/context-guard-kit/cost_guard.py +1870 -0
  13. package/context-guard-kit/setup_wizard.py +820 -29
  14. package/context-guard-kit/trim_command_output.py +396 -45
  15. package/docs/benchmark-fixtures/learned-compression.tasks.example.json +24 -0
  16. package/docs/benchmark-fixtures/learned-compression.variants.example.json +10 -0
  17. package/docs/benchmark-fixtures/visual-ocr.tasks.example.json +24 -0
  18. package/docs/benchmark-fixtures/visual-ocr.variants.example.json +10 -0
  19. package/docs/benchmark-workflow-examples.md +40 -0
  20. package/docs/benchmark-workflows/context-pack-byte-proxy.example.json +169 -0
  21. package/docs/benchmark-workflows/measured-token-workflow.example.json +170 -0
  22. package/docs/benchmark-workflows/provider-cache-telemetry.example.json +170 -0
  23. package/docs/cache-diagnostics-schema.md +75 -0
  24. package/docs/cache-diagnostics.example.json +116 -0
  25. package/docs/cache-diagnostics.schema.json +460 -0
  26. package/docs/distribution.md +4 -2
  27. package/docs/experimental-benchmark-fixtures.md +36 -0
  28. package/package.json +11 -2
  29. package/packaging/homebrew/context-guard.rb.template +3 -2
  30. package/plugins/context-guard/.claude-plugin/plugin.json +1 -1
  31. package/plugins/context-guard/README.ko.md +21 -13
  32. package/plugins/context-guard/README.md +24 -10
  33. package/plugins/context-guard/bin/context-guard +3 -0
  34. package/plugins/context-guard/bin/context-guard-audit +325 -12
  35. package/plugins/context-guard/bin/context-guard-bench +273 -8
  36. package/plugins/context-guard/bin/context-guard-compress +153 -1
  37. package/plugins/context-guard/bin/context-guard-cost +1870 -0
  38. package/plugins/context-guard/bin/context-guard-diet +677 -2
  39. package/plugins/context-guard/bin/context-guard-filter +446 -0
  40. package/plugins/context-guard/bin/context-guard-pack +1694 -2
  41. package/plugins/context-guard/bin/context-guard-setup +820 -29
  42. package/plugins/context-guard/bin/context-guard-trim-output +396 -45
  43. package/plugins/context-guard/brief/README.md +10 -3
  44. package/plugins/context-guard/skills/optimize/SKILL.md +5 -2
  45. package/plugins/context-guard/skills/setup/SKILL.md +3 -1
@@ -1,24 +1,25 @@
1
1
  # ContextGuard Kit
2
2
 
3
- Claude Code CLI 토큰 절감을 위한 실험용 도구 모음입니다. 모두 Python/Bash 표준 기능만 사용합니다.
3
+ Claude Code CLI 컨텍스트 낭비를 줄이기 위한 실험용 도구 모음입니다. 모두 Python/Bash 표준 기능만 사용합니다.
4
4
 
5
5
  ## 구성
6
6
 
7
- - `statusline.sh` — context/cost/model을 statusline에 표시
8
- - `trim_command_output.py` — 긴 명령 output을 head/tail/error와 pytest/Jest/Vitest/Go/Rust 실패 요약 중심으로 축약하고 원래 exit code 보존
9
- - `rewrite_bash_for_token_budget.py` — Claude Code `PreToolUse` hook에서 test/build/lint 명령을 wrapper로 감쌈
10
- - `claude_transcript_cost_audit.py` — `~/.claude/projects` JSONL transcript에서 usage/cost/cache field와 cache-friendly prompt layout 신호를 집계하고 `--recommend`로 절감 액션 제안
11
- - `context_guard_diet.py` — project `.claude/settings.json` deny/hook/statusline, 여러 AI 에이전트 rule file의 context bloat, local context-exclusion 추천을 스캔
12
- - `guard_large_read.py` — Claude Code `PreToolUse` Read hook에서 큰 파일 전체 읽기를 막고 symbol/line-range 읽기로 유도
13
- - `read_symbol.py` — Python/JS/TS/Go/Rust 파일에서 지정 symbol 주변만 출력
14
- - `sanitize_output.py` — `rg`/`grep`/`git diff` 같은 검색·diff output에서 credential을 redact하고 head/anchor/tail로 축약
15
- - `context_escrow.py` — 큰 command output을 sanitize 로컬 artifact로 저장하고 line/pattern query로 다시 조회
16
- - `context_pack.py` — 우선순위 local file evidence를 byte budget 안의 Markdown context pack으로 조립하고 omission/retrieval receipt기록
17
- - `tool_schema_pruner.py` — 로컬 tool/MCP catalog를 top-k schema 자문 리포트로 줄이고 전체 정제 schema는 receipt/payload로 재조회
18
- - `benchmark_runner.py` — 고정 task/variant fixture로 A/B token/cost 절감 benchmark, cost-shift ledger, report 생성
19
- - `setup_wizard.py` — 설치 project-local `.claude/settings.json`을 대화형으로 선택하고 병합
20
- - `failed_attempt_nudge.py` — 반복 Bash 실패 `/clear`/`/compact`와 strategy switch를 짧게 권유
21
- - `settings.example.json` — project `.claude/settings.json` 예시
7
+ - `statusline.sh` — context/cost/model을 상태표시줄에 표시합니다.
8
+ - `trim_command_output.py` — 긴 명령 출력을 head/tail/error와 pytest/Jest/Vitest/Go/Rust 실패 요약 중심으로 축약하고 원래 종료 코드를 보존합니다.
9
+ - `rewrite_bash_for_token_budget.py` — Claude Code `PreToolUse` hook에서 test/build/lint 명령을 wrapper로 감쌉니다.
10
+ - `claude_transcript_cost_audit.py` — `~/.claude/projects` JSONL transcript에서 usage/cost/cache 필드와 캐시 친화적 프롬프트 배치 신호를 집계하고 `--recommend`로 절감 액션을 제안합니다.
11
+ - `context_guard_diet.py` — 프로젝트 `.claude/settings.json`의 deny/hook/statusline, 여러 AI 에이전트 규칙 파일의 컨텍스트 비대화, 로컬 context-exclusion 추천, structural-waste 진단을 스캔합니다.
12
+ - `guard_large_read.py` — Claude Code `PreToolUse` Read hook에서 큰 파일 전체 읽기를 막고 symbol/line-range 읽기로 유도합니다.
13
+ - `read_symbol.py` — Python/JS/TS/Go/Rust 파일에서 지정 symbol 주변만 출력합니다.
14
+ - `sanitize_output.py` — `rg`/`grep`/`git diff` 같은 검색·diff 출력에서 자격 증명처럼 보이는 값을 가리고 head/anchor/tail로 축약합니다.
15
+ - `context_escrow.py` — 큰 명령 출력을 정제한 로컬 artifact로 저장하고 line/pattern query로 다시 조회합니다.
16
+ - `context_pack.py` — 우선순위가 있는 로컬 파일 근거를 바이트 예산 안의 Markdown context pack으로 조립하고, 로컬 query/diff/output 신호에서 build manifest추천합니다.
17
+ - `context_filter.py` — 사용자 소유 JSON DSL로 성공 출력 라인 필터를 적용하되, 보호해야 실패 출력은 원문 그대로 통과시킵니다.
18
+ - `tool_schema_pruner.py` — 로컬 tool/MCP catalog를 top-k schema 자문 리포트로 줄이고, 전체 정제된 schema는 receipt/payload로 재조회할 수 있게 합니다.
19
+ - `benchmark_runner.py` — 고정 task/variant fixture로 A/B token/cost 절감 benchmark, cost-shift ledger, report를 생성합니다.
20
+ - `setup_wizard.py` — 설치 project-local `.claude/settings.json`을 대화형으로 선택하고 병합합니다.
21
+ - `failed_attempt_nudge.py` — 반복 Bash 실패 시 `/clear`/`/compact`와 전략 전환을 짧게 권유합니다.
22
+ - `settings.example.json` — project `.claude/settings.json` 예시입니다.
22
23
 
23
24
  ## 빠른 실험
24
25
 
@@ -28,10 +29,14 @@ python3 context-guard-kit/trim_command_output.py --max-lines 80 -- pytest tests
28
29
  python3 context-guard-kit/claude_transcript_cost_audit.py ~/.claude/projects --top 10 --recommend
29
30
  python3 context-guard-kit/setup_wizard.py
30
31
  python3 context-guard-kit/context_guard_diet.py scan . --json
32
+ python3 context-guard-kit/context_guard_diet.py structural-waste . --tool-catalog tools.json --log-path .claude --json
33
+ python3 context-guard-kit/context_filter.py validate --config .context-guard/filter-dsl.json --json
34
+ python3 context-guard-kit/context_filter.py run --config .context-guard/filter-dsl.json -- git status --short
31
35
  python3 context-guard-kit/read_symbol.py path/to/file.py TargetSymbol
32
36
  long-command 2>&1 | python3 context-guard-kit/context_escrow.py store --command "long-command" --json
33
37
  python3 context-guard-kit/context_escrow.py get <artifact_id> --lines 1:80
34
- python3 context-guard-kit/context_pack.py build --root . --source 'path=README.md,priority=100,lines=1:80' --budget-bytes 12000 --json
38
+ python3 context-guard-kit/context_pack.py suggest --root . --query "failing tests review" --diff HEAD --manifest-out suggested-pack.json --budget-bytes 12000 --json
39
+ python3 context-guard-kit/context_pack.py build --root . --manifest suggested-pack.json --budget-bytes 12000 --json
35
40
  python3 context-guard-kit/context_pack.py slice --root . --path README.md --lines 1:40 --json
36
41
  python3 context-guard-kit/tool_schema_pruner.py select --catalog tools.json --query "review failing tests" --top 5 --budget-bytes 12000 --json
37
42
  python3 context-guard-kit/tool_schema_pruner.py get <receipt_id> --tool read_file --json
@@ -40,35 +45,43 @@ python3 context-guard-kit/sanitize_output.py -- rg -n "TOKEN|SECRET" .
40
45
  python3 context-guard-kit/sanitize_output.py -- git diff
41
46
  ```
42
47
 
43
- `trim_command_output.py`는 output이 budget을 넘을 때 runner별 failure summary를 먼저 보여줍니다. 예를 들어 pytest node id, Jest/Vitest 실패 파일/테스트, `go test`의 실패 test와 `_test.go:line`, `cargo test` panic 위치를 짧게 보존해 Claude가 전체 로그를 다시 읽지 않고도 다음에 수정할 파일을 고를 수 있게 합니다. head/tail 로그 대신 더 작은 의미 요약만 필요하면 `--digest markdown` 또는 `--digest json`을 추가하세요. digest mode는 status, exit code, truncation count, runner failure facts, 정제된 failure signature, 중복 라인 그룹, 대표 라인, redaction count, 다음 query 제안을 남깁니다. 감싼 명령은 기본 600초 후 timeout 처리되며(`--timeout-seconds`로 조정), 가능한 환경에서는 process group까지 종료한 뒤 124를 반환합니다. ANSI color code는 제거하며, 절대경로는 기본적으로 `basename#path:<hash>`로 익명화합니다. 로컬 디버깅에서 원문 절대경로가 꼭 필요하면 `--show-paths`를 추가하세요.
48
+ `trim_command_output.py`는 output이 budget을 넘을 때 runner별 failure summary를 먼저 보여줍니다. 예를 들어 pytest node id, Jest/Vitest 실패 파일/테스트, `go test`의 실패 test와 `_test.go:line`, `cargo test` panic 위치를 짧게 보존해 Claude가 전체 로그를 다시 읽지 않고도 다음에 수정할 파일을 고를 수 있게 합니다. head/tail 로그 대신 더 작은 의미 요약만 필요하면 `--digest markdown` 또는 `--digest json`을 추가하세요. digest mode는 status, exit code, truncation count, runner failure facts, 정제된 failure signature, 중복 라인 그룹, 대표 라인, redaction count, 다음 query 제안을 남깁니다. digest mode에 `--artifact-receipt`를 더하면 sanitized 전체 output을 로컬 `context-guard-artifact` receipt로 보관하고, 출력된 `context-guard-artifact get ...` 명령으로 누락된 부분을 정확히 다시 조회할 수 있습니다. 감싼 명령은 기본 600초 후 timeout 처리되며(`--timeout-seconds`로 조정), 가능한 환경에서는 process group까지 종료한 뒤 124를 반환합니다. ANSI color code는 제거하며, 절대경로는 기본적으로 `basename#path:<hash>`로 익명화합니다. 로컬 디버깅에서 원문 절대경로가 꼭 필요하면 `--show-paths`를 추가하세요.
44
49
 
45
50
  `context_escrow.py`는 대용량 output을 Claude context에 그대로 넣지 않고 `.context-guard/artifacts` 아래 `0o600` 파일로 저장합니다. 저장 전에 sanitizer를 적용해 secret/path 노출을 줄이고, receipt에는 `artifact_id`, line/byte count, 줄 번호가 포함된 top-error receipt, 중복 라인 그룹, 대표 head/tail, 정제된 bounded `suggested_queries`와 `get --lines`/`get --pattern` query 예시만 출력합니다. suggested `--lines START:END` query에 `--max-lines`가 함께 있으면 이는 해당 line range의 반환 cap일 뿐 selector를 넓히는 옵션이 아닙니다. `get`과 `list`는 legacy 기본 위치인 `.claude-token-optimizer/artifacts`도 함께 읽어 리브랜딩 전 receipt를 계속 조회할 수 있습니다. 저장된 artifact는 sanitize된 사본이며, 필요할 때만 `get <artifact_id> --lines 10:40`처럼 정확한 범위를 조회하세요. 파이프라인 저장은 capture/query 용도이므로 producer 명령의 exit code가 필요한 release check에서는 shell `pipefail`/별도 `$?` 저장을 쓰거나 `trim_command_output.py -- ...`로 감싸세요.
46
51
 
47
52
 
48
- `context_pack.py`는 여러 로컬 파일 source를 우선순위와 줄 범위에 따라 정렬하고, 렌더링된 UTF-8 byte budget 안에서 Markdown context pack을 만듭니다. 포함·부분 포함·누락 source, 누락 사유, `.context-guard/packs` bounded receipt, 그리고 `slice --lines` 정확 재조회 명령을 JSON으로 남깁니다. pack 본문/영수증을 만들기 전에 sanitizer를 적용하며, token 값은 관측값이 아닌 추정 proxy로만 표시합니다.
53
+ `context_pack.py auto`는 `suggest`와 `build`를 한 번에 합성해 build-compatible manifest와 예산 기반 Markdown pack을 함께 만듭니다. `auto --explain`은 manifest, pack 본문, receipt, byte budget을 바꾸지 않고 결정적 로컬 선택/build 이유를 JSON 또는 텍스트로 짧게 보여줍니다. JSON explain에는 bounded `repo_map`도 포함되어 sampled byte/token-proxy tree, category-only secret risk summary, signature-first hints, explain-only graph rank, 기존 `slice`/symbol 재조회 힌트를 제공합니다. 이 repo-map은 네트워크·모델 호출·임베딩 없이 로컬 표준 라이브러리 휴리스틱만 쓰며, pack 선택/본문/receipt를 바꾸지 않고 provider token 또는 savings claim으로 해석하면 안 됩니다. `context_pack.py suggest`는 `--query`, `--diff`, 반복 `--files`, 가림 처리한 `--output`, `--test-output`에서 build-compatible manifest 후보를 만듭니다. 모두 `--root` 아래 로컬 파일과 `git diff`만 읽고, 네트워크·모델 호출·임베딩·provider 비용 추정은 하지 않습니다. `context_pack.py build`는 여러 로컬 파일 source를 우선순위와 줄 범위에 따라 정렬하고, 렌더링된 UTF-8 byte budget 안에서 Markdown context pack을 만듭니다. 포함·부분 포함·누락 source, 누락 사유, `.context-guard/packs` bounded receipt, 그리고 `slice --lines` 정확 재조회 명령을 JSON으로 남깁니다. pack 본문과 receipt를 만들기 전에 sanitizer를 적용하며, token 값은 관측값이 아닌 추정 proxy로만 표시합니다.
54
+
55
+ `context_filter.py`는 opt-in declarative output filter helper입니다. filter JSON은 사용자가 package code 밖(예: `.context-guard/filter-dsl.json`)에 두고 `validate`로 검증한 뒤 `run --config ... -- <command>`로 적용합니다. invalid config, no-match, filter error, empty output, protected `git`/test/lint/`gh` failure는 원래 command stdout/stderr와 exit code를 passthrough합니다. filtered mode는 stdout+stderr를 합친 line에 filter를 적용해 stdout으로 쓰고, passthrough mode는 stdout/stderr stream을 그대로 보존합니다. `--json-report`는 stdout을 command/filter output 전용으로 두기 위해 stderr에만 diagnostic JSON을 쓰지만, protected nonzero passthrough에서는 stderr 원문 보존을 위해 report를 생략합니다. token/cost 절감 수치는 측정 claim이 아니라 local presentation 변화로만 다루세요.
49
56
 
50
57
  `tool_schema_pruner.py`는 provider-neutral tool/MCP catalog helper입니다. `select`는 task query와 lexical overlap으로 top-k tool을 고르고, inline schema는 `--budget-bytes` 안에만 넣으며, compact receipt와 별도 sanitized payload를 `.context-guard/tool-prune`에 기록합니다. `get`은 payload size/SHA-256을 검증한 뒤 전체 정제 schema를 반환합니다. 이 helper는 MCP 설정을 바꾸지 않으며, token 절감은 측정값이 아니라 추정 proxy로만 표현합니다.
51
58
 
52
- `benchmark_runner.py`는 `research/benchmark-plan.md`의 고정 task/variant 실험을 실행합니다. `--ledger-jsonl`은 subagent·artifact 외부 실행 표면으로 옮겨간 token/cost를 run별로 남기고, `--report-json`은 baseline 대비 실제 token/cost 절감과 proxy byte 감소를 분리한 A/B report생성합니다.
59
+ `context_compress.py --protected-policy`는 기본 압축 동작을 바꾸지 않고 code fence, diff, identifier, numeric constant, hash, path, stack frame, quoted string, JSON key 같은 보호-zone class/count 정책 메타데이터를 추가합니다. 보호-zone 정책은 semantic/paraphrase rewrite금지하고 structural dedupe/window/truncate 및 artifact retrieval만 허용합니다. raw span은 receipt에 저장하지 않으며, lossy structural transform에는 정확 재조회가 필요하다는 hint를 남깁니다.
60
+
61
+ `cost_guard.py compile`은 section manifest의 `protected`, `semantic_sensitive`, `protected_zone_classes`, `content_type`, `volatile`, `ttl`, `bytes` 필드를 읽어 `protected_zone_policy`와 `transform_policy`를 출력합니다. `protected=true`와 `volatile=true`가 같이 있으면 volatile이 cache ordering을 tail 쪽으로 보내고, protection은 transform/retrieval 정책만 제어합니다. 대용량 protected section은 local artifact retrieval을 권고하지만 provider prompt cache를 대체한다고 주장하지 않습니다.
53
62
 
54
- `../research/experimental-token-reduction-radar.md`는 learned compression, multimodal crop/OCR/visual-token pruning, self-hosted KV/latent inference optimization 같은 선택적 미래 실험을 문서화한 gate입니다. radar는 runtime helper가 아니며, hosted API token/cost 절감을 보장하지 않습니다. hosted API token/cost 절감 주장은 provider가 측정한 matched-task 근거가 있을 때만 허용합니다.
63
+ `benchmark_runner.py`는 `research/benchmark-plan.md`의 고정 task/variant 실험을 실행합니다. `--ledger-jsonl`은 subagent·artifact 외부 실행 표면으로 옮겨간 token/cost와 run별 측정 가능 여부를 남기고, `--report-json`은 baseline 대비 실제 token/cost 절감과 proxy byte 감소를 분리한 A/B report를 생성합니다. Report의 `matched_pair_evidence`는 성공한 baseline/variant task bucket을 transform, quality gate, 측정 가능 여부, claim boundary와 연결하므로 절감 주장을 쓰기 전에 이 항목을 확인하세요.
64
+
65
+ `../research/experimental-token-reduction-radar.md`는 learned compression, multimodal crop/OCR/visual-token pruning, self-hosted KV/latent inference optimization 같은 선택적 미래 실험을 문서화한 gate입니다. `../docs/experimental-benchmark-fixtures.md`에는 fixture-only task/variant 시작 예시가 있습니다. 이 radar와 fixture는 현재 제공되는 runtime helper가 아니며, hosted API token/cost 절감을 보장하지 않습니다. hosted API token/cost 절감 주장은 provider가 측정한 matched-task 근거가 있을 때만 허용합니다. Radar의 later-roadmap gate는 neural/semantic compression, trust-tiered injection-aware compression, context-diff compaction, local proxy constraint를 별도 미래 PR이 gate를 통과하기 전까지 experimental/non-shipped로 유지합니다.
55
66
 
56
67
  `claude_transcript_cost_audit.py --recommend`의 기본 출력은 공유 시 안전하도록 transcript 경로를 `basename#hash`, 명령을 `command#hash` 형태로 익명화합니다. 로컬 원문 식별자가 꼭 필요할 때만 `--show-paths` 또는 `--show-commands`를 추가하세요.
57
- 대용량/손상 transcript 방어를 위해 파일 단위 `--max-file-bytes`, JSONL record 단위 `--max-line-bytes` 제한도 기본 적용되며, 건너뛴 항목은 skip count와 warning으로 노출됩니다. JSON summary/feasibility 출력의 `cache_friendliness`는 제한된 정제 segment hash로 안정적인 prefix와 volatile prefix/tail 신호를 비교하는 휴리스틱입니다. 원문 prompt text는 출력하지 않고, provider cache token field는 ContextGuard가 만든 토큰 절감 증거가 아니라 별도 진단 텔레메트리로 해석하세요.
68
+ 대용량/손상 transcript 방어를 위해 파일 단위 `--max-file-bytes`, JSONL record 단위 `--max-line-bytes` 제한도 기본 적용되며, 건너뛴 항목은 skip count와 warning으로 표시됩니다. JSON summary/feasibility 출력의 `cache_friendliness`는 제한된 정제 segment hash로 안정적인 prefix와 volatile prefix/tail 신호를 비교하는 휴리스틱입니다. 원문 prompt text는 출력하지 않고, provider cache token field는 ContextGuard가 만든 토큰 절감 증거가 아니라 별도 진단 텔레메트리로 해석하세요.
58
69
 
59
70
  `context_guard_diet.py scan`은 항상 로컬에서만 읽는 read-only 스캐너입니다. 기본 출력은 project root를 익명화하고 상대경로 중심으로 보고합니다. `--top`은 보고서의 context-like file 목록과 context-exclusion recommendation 목록에 공통으로 적용됩니다. `--show-paths`는 로컬/비공개 디버깅에서만 쓰세요.
60
71
 
61
- `context_pack.py build`의 retrieval command는 path/root를 안전하게 표시할 있을 때만 출력됩니다. 안전하지 않으면 pack 본문과 JSON source metadata에 `retrieval_omitted_reason`을 기록합니다. `token_proxy`는 렌더링된 pack 문자 수를 `chars_div_4`로 나눈 추정치이며, provider가 실제로 청구/소모한 token 측정값이 아닙니다.
72
+ `context_guard_diet.py structural-waste`는 opt-in read-only 구조 진단입니다. context/rule file의 중복 rule unit, stale Python import 후보, unused skill 후보, MCP/tool schema 과다, local JSON/JSONL log의 반복 file read·중복 tool call을 bounded scan으로 보고합니다. 네트워크 호출이나 삭제/수정은 하지 않고, 기본 출력은 raw prompt/tool input/command를 출력하지 않으며 secret-shaped path component를 redaction합니다. import/skill 결과는 동적 사용을 놓칠 수 있는 advisory 후보로만 다루세요.
73
+
74
+ `context_pack.py suggest`가 쓰는 manifest는 그대로 `context_pack.py build --manifest suggested-pack.json`에 넣을 수 있습니다. `context_pack.py build`의 retrieval command는 path/root를 안전하게 표시할 수 있을 때만 출력됩니다. 안전하지 않으면 pack 본문과 JSON source metadata에 `retrieval_omitted_reason`을 기록합니다. `token_proxy`는 렌더링된 pack 문자 수를 `chars_div_4`로 나눈 추정치이며, provider가 실제로 청구/소모한 token 측정값이 아닙니다.
62
75
 
63
- `setup_wizard.py`는 설치 후 한 번 실행하는 설정 마법사입니다. 터미널에서 실행하면 deny rules, statusline, Bash trim/sanitize hook, large Read guard, 반복 실패 nudge, model/effort defaults를 project-local `.claude/settings.json`에 병합합니다. 비대화형 환경에서는 `--plan`으로 미리 보고 `--yes`로 추천값을 적용하세요. 설정을 적용하면 read-only `context_guard_diet.py scan` 요약을 자동으로 출력해 남은 gap을 확인할 수 있습니다. 반복 실패 nudge가 방해되는 프로젝트는 `--no-failed-attempt-nudge`로, post-setup scan이 불필요한 자동화는 `--no-diet-scan`으로 제외할 수 있습니다.
76
+ `setup_wizard.py`는 설치 후 한 번 실행하는 설정 마법사입니다. 터미널에서 실행하면 deny rules, statusline, Bash trim/sanitize hook, large Read guard, 반복 실패 nudge, model/effort defaults를 project-local `.claude/settings.json`에 병합합니다. 비대화형 환경에서는 `--verify`로 읽기 전용 상태 점검을 하고, `--plan`으로 미리 뒤, `--yes`로 추천값을 적용하세요. Codex/Gemini/Cursor 같은 rule-file 에이전트에는 `--brief-mode lite|standard|ultra`로 권고 brief 스니펫을 설치·교체하고, `--brief-mode off`로 제거할 수 있습니다. 설정을 적용하면 read-only `context_guard_diet.py scan` 요약을 자동으로 출력해 남은 gap을 확인할 수 있습니다. 반복 실패 nudge가 방해되는 프로젝트는 `--no-failed-attempt-nudge`로, post-setup scan이 불필요한 자동화는 `--no-diet-scan`으로 제외할 수 있습니다.
64
77
 
65
78
  `guard_large_read.py`는 opt-in Read hook입니다. 큰 파일 전체를 Claude context에 넣기 전에 progressive read ladder를 반환해 `rg -n` 검색, `read_symbol.py` symbol slice, 작은 `offset`/`limit` Read 순서로 좁히게 합니다. Python/JS/TS/Go/Rust/Markdown 파일은 bounded prefix에서 top-level outline과 line estimate도 함께 보여줍니다. 같은 oversized file fingerprint를 반복해서 읽으려 하면 repeated-read dedup 힌트를 추가해 이전 ladder를 재사용하게 합니다. `CONTEXT_GUARD_READ_GUARD=0`으로 로컬에서 일시 비활성화할 수 있습니다.
66
79
 
67
80
  `failed_attempt_nudge.py`는 같은 Bash 실패 방향이 두 번 반복되면 `/clear`/`/compact` 힌트를 주고, 세 번 이상 반복되면 strategy-switch signal을 추가해 동일 명령 재시도 대신 다른 가설·더 작은 재현·수정 후 재검증으로 전환하게 합니다. recommended setup에서는 기본으로 켜지며, 실행을 막지 않고 짧은 추가 컨텍스트만 주입합니다.
68
81
 
69
- `sanitize_output.py`는 grep/diff output을 Claude에게 보여주기 전에 secret-like line, Authorization header, private key block, API token, credential URL을 `[REDACTED]`로 바꾸고, 긴 결과는 head / grep·diff·security anchor / tail만 남깁니다. 명령을 감싸는 wrapper mode는 원래 exit code를 보존합니다. stdin pipe도 지원하지만 producer exit code는 shell `pipefail` 없이는 알 수 없으므로 자동화에는 `python3 .../sanitize_output.py -- rg ...`처럼 wrapper mode를 선호하세요. 절대경로는 기본 익명화되고 로컬 디버깅에서만 `--show-paths`를 쓰세요. `rewrite_bash_for_token_budget.py` hook은 단일 argv 형태의 `rg`, `grep`, `git grep`, `git diff`, `git show`, `git log -p`를 자동으로 이 sanitizer에 감쌉니다.
82
+ `sanitize_output.py`는 grep/diff output을 Claude에게 보여주기 전에 secret-like line, Authorization header, private key block, API token, credential URL을 `[REDACTED]`로 바꾸고, 긴 결과는 head / grep·diff·security anchor / tail만 남깁니다. 명령을 감싸는 wrapper mode는 원래 종료 코드를 보존합니다. stdin pipe도 지원하지만 producer exit code는 shell `pipefail` 없이는 알 수 없으므로 자동화에는 `python3 .../sanitize_output.py -- rg ...`처럼 wrapper mode를 선호하세요. 절대경로는 기본 익명화되고 로컬 디버깅에서만 `--show-paths`를 쓰세요. `rewrite_bash_for_token_budget.py` hook은 단일 argv 형태의 `rg`, `grep`, `git grep`, `git diff`, `git show`, `git log -p`를 자동으로 이 sanitizer에 감쌉니다.
70
83
 
71
- Claude Code에 적용하려면 `settings.example.json`을 `.claude/settings.json`으로 복사하되, 먼저 작은 repo에서 quoting/exit code를 확인하세요.
84
+ Claude Code에 적용하려면 `settings.example.json`을 `.claude/settings.json`으로 복사하되, 먼저 작은 repo에서 quoting/종료 코드를 확인하세요.
72
85
 
73
86
 
74
87
  ## License
@@ -108,6 +108,7 @@ CSV_COLUMNS = [
108
108
  MAX_CSV_NOTE_CHARS = 500
109
109
  MAX_CSV_ROWS = 100_000
110
110
  CSV_FORMULA_PREFIXES = ("=", "+", "-", "@")
111
+ PLACEHOLDER_SUCCESS_COMMAND_MARKER = "fixture-only placeholder: replace success_command before real benchmark runs"
111
112
  PROTECTED_VARIANT_FLAGS = frozenset({
112
113
  "--",
113
114
  "-p",
@@ -180,6 +181,8 @@ MAX_USAGE_COST_USD = 10**9
180
181
  # 추정치이며, report에서 evidence="inferred"로 분명히 라벨링한다. 영어 텍스트 기준
181
182
  # ~4 bytes/token의 통용 근사값을 사용한다.
182
183
  TOKEN_PROXY_BYTES_PER_TOKEN = 4
184
+ BENCH_RUN_EVIDENCE_SCHEMA_VERSION = "contextguard.bench.run-evidence.v1"
185
+ MATCHED_PAIR_EVIDENCE_SCHEMA_VERSION = "contextguard.bench.matched-pair.v1"
183
186
  CLAUDE_OUTPUT_MAX_BYTES = 1_000_000
184
187
  SUCCESS_COMMAND_OUTPUT_MAX_BYTES = 64_000
185
188
  VERSION_OUTPUT_MAX_BYTES = 16_000
@@ -395,6 +398,10 @@ class BoundedProcessResult:
395
398
  output_truncated: bool = False
396
399
 
397
400
 
401
+ def is_placeholder_success_command(command: str | None) -> bool:
402
+ return bool(command and PLACEHOLDER_SUCCESS_COMMAND_MARKER in command)
403
+
404
+
398
405
  def parse_positive_int(value: Any, *, field: str, owner: str) -> int:
399
406
  """Parse a JSON fixture field that must be a positive integer."""
400
407
  if isinstance(value, bool):
@@ -940,6 +947,14 @@ def run_fixture(task: TaskFixture, variant: Variant, claude_bin: str,
940
947
  success=True, notes=f"dry-run: {shlex.join(argv)}",
941
948
  wall_time_seconds=0.0,
942
949
  )
950
+ if is_placeholder_success_command(task.success_command):
951
+ return RunResult(
952
+ task_id=task.id, variant=variant.name, model=task.model, effort=task.effort,
953
+ tokens={k: 0 for k, _ in USAGE_KEY_GROUPS}, cost_usd=0.0,
954
+ success=False,
955
+ notes=f"{PLACEHOLDER_SUCCESS_COMMAND_MARKER}; refusing to invoke provider",
956
+ wall_time_seconds=elapsed_seconds_since(started_at),
957
+ )
943
958
  argv[0] = executable_argv0(argv[0])
944
959
  try:
945
960
  proc = run_bounded_command(
@@ -1116,11 +1131,14 @@ def write_text_no_follow(path: Path, text: str) -> None:
1116
1131
 
1117
1132
  def append_cost_shift_ledger(path: Path, claude_ver: str, result: RunResult) -> None:
1118
1133
  shifted_cost_known = cost_shift_measured(result)
1134
+ byte_metrics_observed = bool(result.bytes_before or result.bytes_after)
1119
1135
  payload = {
1136
+ "schema_version": BENCH_RUN_EVIDENCE_SCHEMA_VERSION,
1120
1137
  "date": _dt.datetime.now().strftime("%Y-%m-%dT%H:%M:%S"),
1121
1138
  "claude_version": claude_ver,
1122
1139
  "task_id": result.task_id,
1123
1140
  "variant": result.variant,
1141
+ "transform_id": result.variant,
1124
1142
  "success": result.success,
1125
1143
  "primary_cost_measured": result.cost_measured,
1126
1144
  "primary_cost_usd": round(result.cost_usd, 6),
@@ -1142,6 +1160,22 @@ def append_cost_shift_ledger(path: Path, claude_ver: str, result: RunResult) ->
1142
1160
  "hook_triggers": result.hook_triggers,
1143
1161
  "turns": result.turns,
1144
1162
  "notes": sanitize_csv_note(result.notes),
1163
+ "measurement_availability": {
1164
+ "primary_tokens": result.primary_tokens_measured,
1165
+ "primary_cost": result.cost_measured,
1166
+ "external_tokens": result.external_tokens_measured,
1167
+ "external_cost": result.external_cost_measured,
1168
+ "shifted_cost": shifted_cost_known,
1169
+ "provider_cache": result.provider_cached_tokens_measured,
1170
+ "byte_metrics": byte_metrics_observed,
1171
+ "wall_time": result.wall_time_seconds >= 0,
1172
+ },
1173
+ "proxy_metrics": {
1174
+ "byte_metrics_observed": byte_metrics_observed,
1175
+ "token_proxy": "chars_div_4",
1176
+ "bytes_per_token": TOKEN_PROXY_BYTES_PER_TOKEN,
1177
+ "claim_boundary": "proxy_only_not_hosted_token_savings",
1178
+ },
1145
1179
  }
1146
1180
  with csv_file_lock(path, create_parent=True):
1147
1181
  fd = _open_regular_no_symlink(path, os.O_CREAT | os.O_APPEND | os.O_WRONLY, 0o600, create_parent=True)
@@ -1283,7 +1317,9 @@ def summarize_benchmark_rows(rows: list[dict[str, str]], baseline_variant: str)
1283
1317
  seen_tasks_by_variant: dict[str, set[str]] = {}
1284
1318
  successful_tasks_by_variant: dict[str, set[str]] = {}
1285
1319
 
1286
- for row in rows:
1320
+ for row_index, raw_row in enumerate(rows, start=1):
1321
+ row = dict(raw_row)
1322
+ row["_row_index"] = str(row_index)
1287
1323
  variant = row.get("variant") or "unknown"
1288
1324
  task_id = row.get("task_id") or "unknown"
1289
1325
  seen_tasks_by_variant.setdefault(variant, set()).add(task_id)
@@ -1566,7 +1602,215 @@ def summarize_benchmark_rows(rows: list[dict[str, str]], baseline_variant: str)
1566
1602
  len(baseline_values),
1567
1603
  )
1568
1604
 
1605
+ def row_indices_for(rows_for_task: list[dict[str, str]]) -> list[int]:
1606
+ out: list[int] = []
1607
+ for row in rows_for_task:
1608
+ index = row_optional_nonnegative_int(row, "_row_index")
1609
+ if index is not None:
1610
+ out.append(index)
1611
+ return out
1612
+
1613
+ def all_rows_bool(rows_for_task: list[dict[str, str]], key: str) -> bool:
1614
+ return bool(rows_for_task) and all(row_bool(row, key) for row in rows_for_task)
1615
+
1616
+ def all_rows_optional_int(rows_for_task: list[dict[str, str]], key: str) -> list[int] | None:
1617
+ values = [row_optional_nonnegative_int(row, key) for row in rows_for_task]
1618
+ if not values or any(value is None for value in values):
1619
+ return None
1620
+ return [value for value in values if value is not None]
1621
+
1622
+ def all_rows_optional_float(rows_for_task: list[dict[str, str]], key: str) -> list[float] | None:
1623
+ values = [row_optional_float(row, key) for row in rows_for_task]
1624
+ if not values or any(value is None for value in values):
1625
+ return None
1626
+ return [value for value in values if value is not None]
1627
+
1628
+ def average_optional_int(rows_for_task: list[dict[str, str]], key: str) -> float | None:
1629
+ values = all_rows_optional_int(rows_for_task, key)
1630
+ return (sum(values) / len(values)) if values else None
1631
+
1632
+ def average_optional_float(rows_for_task: list[dict[str, str]], key: str) -> float | None:
1633
+ values = all_rows_optional_float(rows_for_task, key)
1634
+ return (sum(values) / len(values)) if values else None
1635
+
1636
+ def total_optional_int(rows_for_task: list[dict[str, str]], key: str) -> int | None:
1637
+ values = all_rows_optional_int(rows_for_task, key)
1638
+ return sum(values) if values is not None else None
1639
+
1640
+ def all_rows_shifted_cost_measured(rows_for_task: list[dict[str, str]]) -> bool:
1641
+ return bool(rows_for_task) and all(
1642
+ row_cost_shift_measured(row) and row_optional_float(row, "total_cost_with_shift_usd") is not None
1643
+ for row in rows_for_task
1644
+ )
1645
+
1646
+ def matched_side_evidence(variant: str, task_id: str, rows_for_task: list[dict[str, str]]) -> dict[str, Any]:
1647
+ primary_tokens_measured = all_rows_bool(rows_for_task, "primary_tokens_measured")
1648
+ primary_cost_measured = all_rows_bool(rows_for_task, "cost_measured")
1649
+ shifted_cost_measured = all_rows_shifted_cost_measured(rows_for_task)
1650
+ provider_cache_measured = all_rows_bool(rows_for_task, "provider_cached_tokens_measured")
1651
+ external_tokens_measured = all_rows_bool(rows_for_task, "external_tokens_measured")
1652
+ external_cost_measured = all_rows_bool(rows_for_task, "external_cost_measured")
1653
+ corrections_values = all_rows_optional_int(rows_for_task, "corrections")
1654
+ bytes_before_values = [row_optional_nonnegative_int(row, "bytes_before") for row in rows_for_task]
1655
+ bytes_after_values = [row_optional_nonnegative_int(row, "bytes_after") for row in rows_for_task]
1656
+ byte_metrics_observed = bool(rows_for_task) and not any(
1657
+ value is None for value in [*bytes_before_values, *bytes_after_values]
1658
+ )
1659
+ bytes_before_total = sum(value for value in bytes_before_values if value is not None)
1660
+ bytes_after_total = sum(value for value in bytes_after_values if value is not None)
1661
+ byte_delta = bytes_after_total - bytes_before_total if byte_metrics_observed else None
1662
+ token_proxy_delta = (
1663
+ int(byte_delta / TOKEN_PROXY_BYTES_PER_TOKEN) if byte_delta is not None else None
1664
+ )
1665
+ return {
1666
+ "variant": variant,
1667
+ "task_id": task_id,
1668
+ "run_count": len(rows_for_task),
1669
+ "row_indices": row_indices_for(rows_for_task),
1670
+ "primary_tokens": {
1671
+ "measured": primary_tokens_measured,
1672
+ "average": average_optional_int(rows_for_task, "total_tokens") if primary_tokens_measured else None,
1673
+ "total": total_optional_int(rows_for_task, "total_tokens") if primary_tokens_measured else None,
1674
+ },
1675
+ "primary_cost_usd": {
1676
+ "measured": primary_cost_measured,
1677
+ "average": average_optional_float(rows_for_task, "cost_usd") if primary_cost_measured else None,
1678
+ },
1679
+ "total_cost_with_shift_usd": {
1680
+ "measured": shifted_cost_measured,
1681
+ "average": (
1682
+ average_optional_float(rows_for_task, "total_cost_with_shift_usd")
1683
+ if shifted_cost_measured else None
1684
+ ),
1685
+ },
1686
+ "external_tokens": {
1687
+ "measured": external_tokens_measured,
1688
+ "total": total_optional_int(rows_for_task, "external_tokens") if external_tokens_measured else None,
1689
+ },
1690
+ "external_cost_usd": {
1691
+ "measured": external_cost_measured,
1692
+ "total": (
1693
+ sum(row_float(row, "external_cost_usd") for row in rows_for_task)
1694
+ if external_cost_measured else None
1695
+ ),
1696
+ },
1697
+ "bytes": {
1698
+ "measurement": "observed" if byte_metrics_observed else "unavailable",
1699
+ "before_total": bytes_before_total if byte_metrics_observed else None,
1700
+ "after_total": bytes_after_total if byte_metrics_observed else None,
1701
+ "delta_total": byte_delta,
1702
+ "token_proxy_delta": token_proxy_delta,
1703
+ "token_proxy": "chars_div_4_proxy_only" if byte_metrics_observed else "unavailable",
1704
+ },
1705
+ "wall_time_seconds": {
1706
+ "measured": all_rows_optional_float(rows_for_task, "wall_time_seconds") is not None,
1707
+ "average": average_optional_float(rows_for_task, "wall_time_seconds"),
1708
+ },
1709
+ "provider_cached_tokens": {
1710
+ "measured": provider_cache_measured,
1711
+ "average": (
1712
+ average_optional_int(rows_for_task, "provider_cached_tokens")
1713
+ if provider_cache_measured else None
1714
+ ),
1715
+ },
1716
+ "corrections": {
1717
+ "measured": corrections_values is not None,
1718
+ "average": (sum(corrections_values) / len(corrections_values)) if corrections_values else None,
1719
+ },
1720
+ }
1721
+
1722
+ def matched_pair_evidence_entry(
1723
+ variant: str,
1724
+ task_id: str,
1725
+ quality_gate: str,
1726
+ ) -> dict[str, Any]:
1727
+ baseline_rows = successful_rows_by_variant_task[baseline_variant][task_id]
1728
+ variant_rows = successful_rows_by_variant_task[variant][task_id]
1729
+ baseline_evidence = matched_side_evidence(baseline_variant, task_id, baseline_rows)
1730
+ variant_evidence = matched_side_evidence(variant, task_id, variant_rows)
1731
+ baseline_token_avg = baseline_evidence["primary_tokens"]["average"]
1732
+ variant_token_avg = variant_evidence["primary_tokens"]["average"]
1733
+ token_claim_allowed = (
1734
+ quality_gate == "pass"
1735
+ and bool(baseline_evidence["primary_tokens"]["measured"])
1736
+ and bool(variant_evidence["primary_tokens"]["measured"])
1737
+ and isinstance(baseline_token_avg, (int, float))
1738
+ and baseline_token_avg > 0
1739
+ and isinstance(variant_token_avg, (int, float))
1740
+ )
1741
+ baseline_cost_avg = baseline_evidence["total_cost_with_shift_usd"]["average"]
1742
+ variant_cost_avg = variant_evidence["total_cost_with_shift_usd"]["average"]
1743
+ shifted_cost_claim_allowed = (
1744
+ quality_gate == "pass"
1745
+ and bool(baseline_evidence["total_cost_with_shift_usd"]["measured"])
1746
+ and bool(variant_evidence["total_cost_with_shift_usd"]["measured"])
1747
+ and isinstance(baseline_cost_avg, (int, float))
1748
+ and baseline_cost_avg > 0
1749
+ and isinstance(variant_cost_avg, (int, float))
1750
+ )
1751
+ token_delta = (
1752
+ variant_token_avg - baseline_token_avg
1753
+ if token_claim_allowed
1754
+ else None
1755
+ )
1756
+ token_savings_pct = (
1757
+ (baseline_token_avg - variant_token_avg) / baseline_token_avg * 100.0
1758
+ if token_delta is not None
1759
+ else None
1760
+ )
1761
+ cost_delta = (
1762
+ variant_cost_avg - baseline_cost_avg
1763
+ if shifted_cost_claim_allowed
1764
+ else None
1765
+ )
1766
+ cost_savings_pct = (
1767
+ (baseline_cost_avg - variant_cost_avg) / baseline_cost_avg * 100.0
1768
+ if cost_delta is not None
1769
+ else None
1770
+ )
1771
+ base_after = baseline_evidence["bytes"]["after_total"]
1772
+ variant_after = variant_evidence["bytes"]["after_total"]
1773
+ byte_after_delta = (
1774
+ variant_after - base_after
1775
+ if isinstance(base_after, int) and isinstance(variant_after, int)
1776
+ else None
1777
+ )
1778
+ return {
1779
+ "schema_version": MATCHED_PAIR_EVIDENCE_SCHEMA_VERSION,
1780
+ "task_id": task_id,
1781
+ "baseline_variant": baseline_variant,
1782
+ "variant": variant,
1783
+ "transform_id": variant,
1784
+ "quality_gate": quality_gate,
1785
+ "evidence_kind": "matched_successful_task_bucket",
1786
+ "measurements": {
1787
+ "baseline": baseline_evidence,
1788
+ "variant": variant_evidence,
1789
+ },
1790
+ "delta": {
1791
+ "primary_tokens_average": token_delta,
1792
+ "token_savings_pct": token_savings_pct,
1793
+ "total_cost_with_shift_usd_average": cost_delta,
1794
+ "cost_savings_pct_with_shift": cost_savings_pct,
1795
+ "bytes_after_total": byte_after_delta,
1796
+ "token_proxy_after_total": (
1797
+ int(byte_after_delta / TOKEN_PROXY_BYTES_PER_TOKEN)
1798
+ if byte_after_delta is not None else None
1799
+ ),
1800
+ "proxy_measurement": "chars_div_4_proxy_only",
1801
+ },
1802
+ "claim_boundary": {
1803
+ "quality_gate": quality_gate,
1804
+ "token_savings_claim_allowed": token_claim_allowed,
1805
+ "shifted_cost_claim_allowed": shifted_cost_claim_allowed,
1806
+ "byte_proxy_only": True,
1807
+ "requires_matched_successful_tasks": True,
1808
+ "raw_estimate_only_claim_allowed": False,
1809
+ },
1810
+ }
1811
+
1569
1812
  comparisons: list[dict[str, Any]] = []
1813
+ matched_pair_evidence: list[dict[str, Any]] = []
1570
1814
  baseline = by_variant.get(baseline_variant)
1571
1815
  baseline_successful_tasks = successful_tasks_by_variant.get(baseline_variant, set())
1572
1816
  baseline_failure_rate = baseline.get("failure_rate") if baseline else None
@@ -1680,6 +1924,8 @@ def summarize_benchmark_rows(rows: list[dict[str, str]], baseline_variant: str)
1680
1924
  else:
1681
1925
  comparison["cost_savings_pct_with_shift"] = None
1682
1926
  comparison["paired_cost_task_count"] = cost_task_count
1927
+ for task_id in sorted(matched_tasks):
1928
+ matched_pair_evidence.append(matched_pair_evidence_entry(variant, task_id, quality_gate))
1683
1929
  comparisons.append(comparison)
1684
1930
 
1685
1931
  claim_status = "insufficient_baseline"
@@ -1712,6 +1958,7 @@ def summarize_benchmark_rows(rows: list[dict[str, str]], baseline_variant: str)
1712
1958
  "row_count": len(rows),
1713
1959
  "summary_by_variant": by_variant,
1714
1960
  "comparisons": comparisons,
1961
+ "matched_pair_evidence": matched_pair_evidence,
1715
1962
  "claim_status": claim_status,
1716
1963
  "caveat": (
1717
1964
  "Proxy byte reductions are reported separately from matched-task token/cost metrics; "
@@ -1843,12 +2090,6 @@ def main() -> int:
1843
2090
  require_no_follow_file_ops_supported()
1844
2091
  validate_distinct_output_paths(args.csv, args.ledger_jsonl, args.report_json)
1845
2092
 
1846
- if not args.dry_run and shutil.which(args.claude_bin) is None:
1847
- # claude_bin 이 절대경로면 shutil.which 가 None 일 수 있으므로 추가 검사.
1848
- if not Path(args.claude_bin).exists():
1849
- print(f"claude binary not found: {args.claude_bin}", file=sys.stderr)
1850
- return 2
1851
-
1852
2093
  tasks = parse_tasks(args.tasks)
1853
2094
  variants = parse_variants(args.variants)
1854
2095
  targets = filter_targets(tasks, variants, args.task_id, args.variant)
@@ -1857,8 +2098,32 @@ def main() -> int:
1857
2098
  return 1
1858
2099
 
1859
2100
  skip_keys = existing_keys(args.csv) if args.resume else set()
2101
+ runnable_targets = [
2102
+ (task, variant)
2103
+ for task, variant in targets
2104
+ if (task.id, variant.name) not in skip_keys
2105
+ ]
2106
+ placeholder_targets = [
2107
+ f"{task.id}/{variant.name}"
2108
+ for task, variant in runnable_targets
2109
+ if is_placeholder_success_command(task.success_command)
2110
+ ]
2111
+ if placeholder_targets and not args.dry_run:
2112
+ print(
2113
+ f"{PLACEHOLDER_SUCCESS_COMMAND_MARKER}; refusing non-dry-run provider invocation for: "
2114
+ f"{', '.join(placeholder_targets)}",
2115
+ file=sys.stderr,
2116
+ )
2117
+ return 2
2118
+
2119
+ if runnable_targets and not args.dry_run and shutil.which(args.claude_bin) is None:
2120
+ # claude_bin 이 절대경로면 shutil.which 가 None 일 수 있으므로 추가 검사.
2121
+ if not Path(args.claude_bin).exists():
2122
+ print(f"claude binary not found: {args.claude_bin}", file=sys.stderr)
2123
+ return 2
2124
+
1860
2125
  project_root = args.project_root.resolve()
1861
- claude_ver = "dry-run" if args.dry_run else claude_version(args.claude_bin)
2126
+ claude_ver = "dry-run" if args.dry_run else (claude_version(args.claude_bin) if runnable_targets else "skipped")
1862
2127
 
1863
2128
  completed = 0
1864
2129
  for task, variant in targets: