@ictechgy/context-guard 0.4.4 → 0.4.5
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 +7 -0
- package/README.ko.md +15 -2
- package/README.md +12 -2
- package/context-guard-kit/README.md +2 -2
- package/context-guard-kit/benchmark_runner.py +244 -6
- package/context-guard-kit/claude_transcript_cost_audit.py +171 -1
- package/docs/benchmark-fixtures/learned-compression-baseline-context-pack.prompt.example.md +19 -0
- package/docs/benchmark-fixtures/learned-compression-candidate-digest.prompt.example.md +21 -0
- package/docs/benchmark-fixtures/learned-compression.tasks.example.json +5 -1
- package/docs/benchmark-fixtures/output-transform-baseline-raw-output.prompt.example.md +20 -0
- package/docs/benchmark-fixtures/output-transform-digest-receipt.prompt.example.md +23 -0
- package/docs/benchmark-fixtures/output-transform.tasks.example.json +28 -0
- package/docs/benchmark-fixtures/output-transform.variants.example.json +10 -0
- package/docs/benchmark-fixtures/visual-ocr-cropped-ocr.prompt.example.md +22 -0
- package/docs/benchmark-fixtures/visual-ocr-full-visual.prompt.example.md +19 -0
- package/docs/benchmark-fixtures/visual-ocr.tasks.example.json +5 -1
- package/docs/benchmark-workflow-examples.md +6 -2
- package/docs/benchmark-workflows/self-hosted-metrics-ledger.example.jsonl +1 -0
- package/docs/experimental-benchmark-fixtures.md +17 -6
- package/docs/mac-visibility-feasibility-schema.md +62 -0
- package/docs/mac-visibility-feasibility.example.json +130 -0
- package/package.json +5 -1
- package/plugins/context-guard/.claude-plugin/plugin.json +1 -1
- package/plugins/context-guard/README.ko.md +1 -1
- package/plugins/context-guard/README.md +1 -1
- package/plugins/context-guard/bin/context-guard-audit +171 -1
- package/plugins/context-guard/bin/context-guard-bench +244 -6
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes for the ContextGuard plugin are documented here.
|
|
4
4
|
|
|
5
|
+
## [Unreleased]
|
|
6
|
+
|
|
7
|
+
## [0.4.5] - 2026-06-09
|
|
8
|
+
|
|
9
|
+
- Added a package-visible `mac_visibility` feasibility contract for future local macOS-visible surfaces without building a GUI or inferring live headroom from historical transcript scans.
|
|
10
|
+
- Clarified README, plugin README, kit README, and GitHub Pages measurement boundaries for self-hosted metrics sidecars, benchmark evidence, mac visibility contracts, and experimental fixtures.
|
|
11
|
+
|
|
5
12
|
## [0.4.4] - 2026-06-08
|
|
6
13
|
|
|
7
14
|
- Added top-level `cache_layout_advice` to transcript audit JSON and feasibility output so cache-prefix instability can be prioritized without mixing advice into evidence-only diagnostics.
|
package/README.ko.md
CHANGED
|
@@ -125,13 +125,16 @@ brief 모드는 코딩 에이전트가 군더더기를 줄이도록 요청하되
|
|
|
125
125
|
| Claude Code 플러그인 스킬 | 설정 마법사, 최적화 점검, 대화 기록 사용량 감사를 Claude Code 안에서 실행합니다. |
|
|
126
126
|
| 프로젝트 단위 설정 마법사 | 전역 설정은 그대로 두고 권장 `.claude/settings.json` 옵션을 프로젝트에 적용합니다. |
|
|
127
127
|
| 컨텍스트 관리 스캐너 | 누락된 가드레일, 과도한 훅 출력, 넓은 읽기 범위, 큰 컨텍스트 파일, 민감해 보이는 파일, 과도한 MCP 서버, 비용이 큰 기본값을 찾습니다. |
|
|
128
|
+
| 구조적 낭비 진단 | 중복 규칙, stale import 후보, 쓰이지 않는 skill 후보, 과도한 tool schema, 반복 read/tool-call loop를 읽기 전용으로 진단합니다. |
|
|
128
129
|
| 대용량 읽기 가드와 심볼 리더 | 파일 전체 읽기 대신 `rg`, 심볼 단위 읽기, 작은 줄 범위 읽기를 사용하도록 안내합니다. |
|
|
129
130
|
| 출력 축약과 민감정보 가림 | 테스트·빌드·검색·diff 출력을 작게 만들고, 에이전트 컨텍스트에 들어가기 전에 민감해 보이는 값을 가립니다. |
|
|
131
|
+
| 선언형 출력 필터 | 사용자 정의 JSON DSL로 성공 출력만 명시적으로 줄이고, 보호해야 하는 실패 출력은 원문 stdout/stderr와 종료 코드를 보존합니다. |
|
|
130
132
|
| 로컬 로그 보관소 | 큰 로그를 대화 밖 로컬 저장소에 보관하고, 요약 정보나 요청한 줄 범위만 다시 가져옵니다. |
|
|
131
133
|
| Anthropic 비용 가드 | `context-guard cost preflight/observe/ledger/compile`이 cache 위험과 비용 범위를 추정하고, 원문 대신 keyed HMAC fingerprint만 저장하며, `--enforce`를 명시하지 않으면 경고만 합니다. |
|
|
132
134
|
| 예산 기반 컨텍스트 패커 | 우선순위가 있는 로컬 파일 근거를 바이트 예산 안의 Markdown 팩으로 조립하고, 로컬 신호에서 `build`용 manifest를 추천하며, `--explain`으로 짧은 로컬 선택 이유를 덧붙일 수 있습니다. |
|
|
133
135
|
| Tool/MCP schema pruner | 로컬 catalog에서 bounded top-k tool/schema 자문 리포트를 만들고, compact 요약 기록과 전체 가림 처리된 payload 재조회 경로를 남깁니다. |
|
|
134
136
|
| 보수적 stdin 압축기 | 선택한 JSON, diff, 로그, 검색 출력, 코드, 산문을 줄이고, 관측 바이트 근거와 추정 토큰 proxy를 함께 표시합니다. |
|
|
137
|
+
| 보호 영역 정책 기록 | `context-guard-compress --protected-policy`와 `context-guard cost compile`이 코드·diff·path·hash·JSON/literal zone을 structural-only 변환 대상으로 표시하고 정확한 재조회 경계를 남깁니다. |
|
|
135
138
|
| 반복 실패 알림 | Bash 실패가 반복되면 실패 로그가 컨텍스트를 채우기 전에 전략을 바꾸도록 안내합니다. |
|
|
136
139
|
| 상태표시줄, 감사, 벤치마크 | 컨텍스트·캐시·비용 신호를 보여주고, 사용량과 캐시 친화성 집중 지점을 찾고, 보수적인 전후 비교 증거를 남깁니다. |
|
|
137
140
|
|
|
@@ -300,7 +303,7 @@ head/tail 로그 대신 의미 요약이 필요하면 `--digest markdown` 또는
|
|
|
300
303
|
./plugins/context-guard/bin/context-guard-audit ~/.claude/projects --top 20 --recommend
|
|
301
304
|
```
|
|
302
305
|
|
|
303
|
-
감사 명령은 기본적으로 너무 큰 대화 기록 파일과 JSONL 기록을 건너뛰고(`--max-file-bytes`, `--max-line-bytes`), 건너뛴 개수를 함께 보고합니다. 손상된 추적 기록이 메모리를 독점하거나 스캔 공백을 숨기지 않도록 하기 위한 방어입니다. JSON 출력에는 `cache_friendliness`와 [`cache_diagnostics`](docs/cache-diagnostics-schema.md)도 포함됩니다. 이는 제한된 사용량 필드, timestamped cache telemetry records, 가림 처리된 segment hash로 만든 휴리스틱 프롬프트 배치/cache-read 진단입니다. sibling `cache_layout_advice`는 이 신호를 긴 세션 분리, prefix 안정화 같은 순위화된 **확인/실험**으로 바꾸되, 관측된 issue와 가설/입증된 cause를 분리합니다. 원문 프롬프트는 출력하지 않고 provider cache hit
|
|
306
|
+
감사 명령은 기본적으로 너무 큰 대화 기록 파일과 JSONL 기록을 건너뛰고(`--max-file-bytes`, `--max-line-bytes`), 건너뛴 개수를 함께 보고합니다. 손상된 추적 기록이 메모리를 독점하거나 스캔 공백을 숨기지 않도록 하기 위한 방어입니다. JSON 출력에는 `cache_friendliness`와 [`cache_diagnostics`](docs/cache-diagnostics-schema.md)도 포함됩니다. 이는 제한된 사용량 필드, timestamped cache telemetry records, 가림 처리된 segment hash로 만든 휴리스틱 프롬프트 배치/cache-read 진단입니다. sibling `cache_layout_advice`는 이 신호를 긴 세션 분리, prefix 안정화 같은 순위화된 **확인/실험**으로 바꾸되, 관측된 issue와 가설/입증된 cause를 분리합니다. `--feasibility-json` 출력에는 로컬 macOS 가시화 surface가 바인딩할 수 있는 [`mac_visibility`](docs/mac-visibility-feasibility-schema.md) 계약도 포함됩니다. 이 계약은 안정적인 top-level field만 가리키며, `summary`는 primary UI binding 대상이 아닙니다. 원문 프롬프트는 출력하지 않고 provider cache hit나 live headroom을 증명하지 않으며, 대화 기록 스키마가 충분한 증거를 드러내지 않으면 `missing`, `partial`, `hypothesis`, `unavailable`일 수 있습니다.
|
|
304
307
|
|
|
305
308
|
### 상태표시줄에서 컨텍스트와 캐시 상태 확인
|
|
306
309
|
|
|
@@ -318,7 +321,17 @@ head/tail 로그 대신 의미 요약이 필요하면 `--digest markdown` 또는
|
|
|
318
321
|
--ledger-jsonl bench/cost-shift.jsonl --report-json bench/report.json
|
|
319
322
|
```
|
|
320
323
|
|
|
321
|
-
|
|
324
|
+
보고서를 읽을 때는 먼저 claim boundary를 확인하세요.
|
|
325
|
+
|
|
326
|
+
- 성공한 기준/변형 실행은 실제 토큰과 `cost_usd + external_cost_usd` 기준으로 비교하고, 바이트 감소는 간접 증거로만 기록합니다.
|
|
327
|
+
- 토큰 절감 주장은 대응 태스크 양쪽 모두에 `primary_tokens_measured`가 있을 때만 계산합니다.
|
|
328
|
+
- `matched_pair_evidence`는 성공한 task bucket을 transform, 측정 가능 여부, quality gate, claim boundary와 연결하므로 절감 문구를 쓰기 전에 먼저 확인해야 합니다.
|
|
329
|
+
- `wall_time_seconds`, `provider_cached_tokens`, `provider_cached_tokens_measured`는 진단용 텔레메트리이며, ContextGuard가 직접 만든 토큰·비용 절감 증거로 보지 않습니다.
|
|
330
|
+
- 선택적 `self_hosted_metrics`는 run별 JSONL ledger sidecar로만 기록하고 CSV/report 요약에는 넣지 않으며, hosted API token/cost 절감 주장의 근거로 포함해서는 안 됩니다.
|
|
331
|
+
- 비용 필드가 0이거나 없으면 토큰 절감만 표시하고 실제 비용 절감은 주장하지 않습니다.
|
|
332
|
+
- CSV 스키마는 엄격하게 검사합니다. 벤치마크 헬퍼를 업그레이드한 뒤에는 새 `--csv` 파일을 시작하거나 mismatch 오류가 알려주는 헤더로 마이그레이션하세요.
|
|
333
|
+
|
|
334
|
+
최소 보고서 형태 예시는 [`docs/benchmark-report.example.json`](docs/benchmark-report.example.json)을, 작업 유형별 합성 예시와 안전한 해석 경계는 [`docs/benchmark-workflow-examples.md`](docs/benchmark-workflow-examples.md)을, fixture-only 실험 시작 예시는 [`docs/experimental-benchmark-fixtures.md`](docs/experimental-benchmark-fixtures.md)을 참고하세요.
|
|
322
335
|
|
|
323
336
|
## 아직 제공하지 않는 기능
|
|
324
337
|
|
package/README.md
CHANGED
|
@@ -339,7 +339,7 @@ JSON
|
|
|
339
339
|
./plugins/context-guard/bin/context-guard-audit ~/.claude/projects --top 20 --recommend
|
|
340
340
|
```
|
|
341
341
|
|
|
342
|
-
The audit command skips oversized transcript files and JSONL records by default (`--max-file-bytes`, `--max-line-bytes`) and reports skipped counts, so a corrupt trace cannot dominate memory or hide scan gaps. JSON output also includes `cache_friendliness` and [`cache_diagnostics`](docs/cache-diagnostics-schema.md): heuristic prompt-layout/cache-read diagnostics built from bounded usage fields, timestamped cache telemetry records, and redacted segment hashes. The sibling `cache_layout_advice` field turns those signals into ranked **checks/experiments** such as splitting long sessions or stabilizing early prompt prefixes, while keeping observed issues separate from hypothesized or corroborated causes. These fields can flag likely volatile content near the prompt prefix, stable-prefix candidates, cache-miss hypotheses, and TTL/headroom evidence gaps, but they do not print raw prompt text, do not prove provider cache hits, and may be `missing`, `partial`, `hypothesis`, or `unavailable` when transcript schemas do not expose enough evidence.
|
|
342
|
+
The audit command skips oversized transcript files and JSONL records by default (`--max-file-bytes`, `--max-line-bytes`) and reports skipped counts, so a corrupt trace cannot dominate memory or hide scan gaps. JSON output also includes `cache_friendliness` and [`cache_diagnostics`](docs/cache-diagnostics-schema.md): heuristic prompt-layout/cache-read diagnostics built from bounded usage fields, timestamped cache telemetry records, and redacted segment hashes. The sibling `cache_layout_advice` field turns those signals into ranked **checks/experiments** such as splitting long sessions or stabilizing early prompt prefixes, while keeping observed issues separate from hypothesized or corroborated causes. `--feasibility-json` also includes a [`mac_visibility`](docs/mac-visibility-feasibility-schema.md) contract that local macOS-visible consumers can bind against; only stable top-level fields are designated binding targets, and `summary` is not a primary UI binding source. These fields can flag likely volatile content near the prompt prefix, stable-prefix candidates, cache-miss hypotheses, and TTL/headroom evidence gaps, but they do not print raw prompt text, do not prove provider cache hits, and may be `missing`, `partial`, `hypothesis`, or `unavailable` when transcript schemas do not expose enough evidence.
|
|
343
343
|
|
|
344
344
|
### Watch context and cache health in the statusline
|
|
345
345
|
|
|
@@ -357,7 +357,17 @@ The audit command skips oversized transcript files and JSONL records by default
|
|
|
357
357
|
--ledger-jsonl bench/cost-shift.jsonl --report-json bench/report.json
|
|
358
358
|
```
|
|
359
359
|
|
|
360
|
-
|
|
360
|
+
Read the report through its claim boundaries before writing any savings statement:
|
|
361
|
+
|
|
362
|
+
- Successful baseline/variant runs are compared by real tokens and `cost_usd + external_cost_usd`; byte reductions stay proxy evidence.
|
|
363
|
+
- Token-savings claims require `primary_tokens_measured` on both sides of a matched task.
|
|
364
|
+
- `matched_pair_evidence` links each successful task bucket to the transform, measurement availability, quality gate, and claim boundary.
|
|
365
|
+
- `wall_time_seconds`, `provider_cached_tokens`, and `provider_cached_tokens_measured` are diagnostic telemetry, not proof of ContextGuard-caused token or cost savings.
|
|
366
|
+
- Optional `self_hosted_metrics` from provider payloads are stored as per-row JSONL ledger sidecars, kept out of CSV/report summaries, and must not be folded into hosted API token/cost savings claims.
|
|
367
|
+
- If cost fields are zero or unavailable, the report can still mark token savings but will not claim shifted-cost savings.
|
|
368
|
+
- CSV schemas are strict; after upgrading the benchmark helper, start a new `--csv` file or migrate the header named in the mismatch error.
|
|
369
|
+
|
|
370
|
+
See [`docs/benchmark-report.example.json`](docs/benchmark-report.example.json) for a minimal report-shape example, [`docs/benchmark-workflow-examples.md`](docs/benchmark-workflow-examples.md) for workflow-specific synthetic examples, and [`docs/experimental-benchmark-fixtures.md`](docs/experimental-benchmark-fixtures.md) for fixture-only experimental task/variant starters.
|
|
361
371
|
|
|
362
372
|
## What is not yet shipped
|
|
363
373
|
|
|
@@ -60,12 +60,12 @@ python3 context-guard-kit/sanitize_output.py -- git diff
|
|
|
60
60
|
|
|
61
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를 대체한다고 주장하지 않습니다.
|
|
62
62
|
|
|
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를
|
|
63
|
+
`benchmark_runner.py`는 `research/benchmark-plan.md`의 고정 task/variant 실험을 실행합니다. `variant_prompt_files`는 선택된 task/variant를 필터링한 뒤 필요한 file-backed prompt만 읽으므로 선택하지 않은 fixture의 누락 파일이 선택된 실행을 깨지 않습니다. `--ledger-jsonl`은 subagent·artifact 등 외부 실행 표면으로 옮겨간 token/cost와 run별 측정 가능 여부를 남기고, 선택적 `self_hosted_metrics` provider payload는 run별 sidecar로만 기록합니다. `--report-json`은 baseline 대비 실제 token/cost 절감과 proxy byte 감소를 분리한 A/B report를 생성하며, `self_hosted_metrics`는 CSV/report 요약에 접지 않습니다. Report의 `matched_pair_evidence`는 성공한 baseline/variant task bucket을 transform, quality gate, 측정 가능 여부, claim boundary와 연결하므로 절감 주장을 쓰기 전에 이 항목을 확인하세요.
|
|
64
64
|
|
|
65
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로 유지합니다.
|
|
66
66
|
|
|
67
67
|
`claude_transcript_cost_audit.py --recommend`의 기본 출력은 공유 시 안전하도록 transcript 경로를 `basename#hash`, 명령을 `command#hash` 형태로 익명화합니다. 로컬 원문 식별자가 꼭 필요할 때만 `--show-paths` 또는 `--show-commands`를 추가하세요.
|
|
68
|
-
대용량/손상 transcript 방어를 위해 파일 단위 `--max-file-bytes`, JSONL record 단위 `--max-line-bytes` 제한도 기본 적용되며, 건너뛴 항목은 skip count와 warning으로 표시됩니다. JSON summary/feasibility 출력의 `cache_friendliness`는 제한된 정제 segment hash로 안정적인 prefix와 volatile prefix/tail 신호를 비교하는 휴리스틱입니다. `cache_layout_advice`는 그 신호를 긴 세션 분리, prefix 안정화, diet 점검 같은 순위화된 확인/실험으로 연결하지만, 관측 issue와 가설/입증 cause를 분리합니다. 원문 prompt text는 출력하지 않고, provider cache token field
|
|
68
|
+
대용량/손상 transcript 방어를 위해 파일 단위 `--max-file-bytes`, JSONL record 단위 `--max-line-bytes` 제한도 기본 적용되며, 건너뛴 항목은 skip count와 warning으로 표시됩니다. JSON summary/feasibility 출력의 `cache_friendliness`는 제한된 정제 segment hash로 안정적인 prefix와 volatile prefix/tail 신호를 비교하는 휴리스틱입니다. `cache_layout_advice`는 그 신호를 긴 세션 분리, prefix 안정화, diet 점검 같은 순위화된 확인/실험으로 연결하지만, 관측 issue와 가설/입증 cause를 분리합니다. `--feasibility-json`은 macOS-visible prototype 같은 consumer가 안정적인 top-level field에만 바인딩하도록 `mac_visibility` 계약도 함께 제공합니다. 원문 prompt text는 출력하지 않고, provider cache token field와 historical token total은 ContextGuard가 만든 토큰 절감 또는 live headroom 증거가 아니라 별도 진단 텔레메트리로 해석하세요.
|
|
69
69
|
|
|
70
70
|
`context_guard_diet.py scan`은 항상 로컬에서만 읽는 read-only 스캐너입니다. 기본 출력은 project root를 익명화하고 상대경로 중심으로 보고합니다. `--top`은 보고서의 context-like file 목록과 context-exclusion recommendation 목록에 공통으로 적용됩니다. `--show-paths`는 로컬/비공개 디버깅에서만 쓰세요.
|
|
71
71
|
|
|
@@ -27,6 +27,7 @@ Task fixture (`tasks.json`): 각 task 는 다음 필드를 가진다.
|
|
|
27
27
|
"max_turns": 3,
|
|
28
28
|
"max_budget_usd": 1.0,
|
|
29
29
|
"allowed_tools": ["Read", "Edit", "Bash(npm test*)"],
|
|
30
|
+
"variant_prompt_files": {"context_hygiene": "t01.context_hygiene.prompt.md"},
|
|
30
31
|
"success_command": "npm test -- auth/session",
|
|
31
32
|
"success_cwd": "."
|
|
32
33
|
}
|
|
@@ -183,6 +184,13 @@ MAX_USAGE_COST_USD = 10**9
|
|
|
183
184
|
TOKEN_PROXY_BYTES_PER_TOKEN = 4
|
|
184
185
|
BENCH_RUN_EVIDENCE_SCHEMA_VERSION = "contextguard.bench.run-evidence.v1"
|
|
185
186
|
MATCHED_PAIR_EVIDENCE_SCHEMA_VERSION = "contextguard.bench.matched-pair.v1"
|
|
187
|
+
SELF_HOSTED_METRICS_SCHEMA_VERSION = "contextguard.bench.self-hosted-metrics.v1"
|
|
188
|
+
SELF_HOSTED_METRICS_KEY = "self_hosted_metrics"
|
|
189
|
+
SELF_HOSTED_METRICS_CLAIM_BOUNDARY = "self_hosted_metrics_only_not_hosted_api_token_or_cost_savings"
|
|
190
|
+
MAX_SELF_HOSTED_LABEL_CHARS = 120
|
|
191
|
+
MAX_SELF_HOSTED_LATENCY_MS = 7 * 24 * 60 * 60 * 1000
|
|
192
|
+
MAX_SELF_HOSTED_MEMORY_MB = 10_000_000
|
|
193
|
+
MAX_VARIANT_PROMPT_FILE_BYTES = 128_000
|
|
186
194
|
CLAUDE_OUTPUT_MAX_BYTES = 1_000_000
|
|
187
195
|
SUCCESS_COMMAND_OUTPUT_MAX_BYTES = 64_000
|
|
188
196
|
VERSION_OUTPUT_MAX_BYTES = 16_000
|
|
@@ -354,6 +362,8 @@ class TaskFixture:
|
|
|
354
362
|
allowed_tools: list[str] = field(default_factory=list)
|
|
355
363
|
success_command: str | None = None
|
|
356
364
|
success_cwd: str = "."
|
|
365
|
+
variant_prompt_files: dict[str, str] = field(default_factory=dict)
|
|
366
|
+
variant_prompt_texts: dict[str, str] = field(default_factory=dict)
|
|
357
367
|
|
|
358
368
|
|
|
359
369
|
@dataclass
|
|
@@ -387,6 +397,7 @@ class RunResult:
|
|
|
387
397
|
provider_cached_tokens: int = 0
|
|
388
398
|
provider_cached_tokens_measured: bool = False
|
|
389
399
|
primary_tokens_measured: bool = False
|
|
400
|
+
self_hosted_metrics: dict[str, Any] | None = None
|
|
390
401
|
|
|
391
402
|
|
|
392
403
|
@dataclass
|
|
@@ -433,6 +444,22 @@ def parse_string_list(value: Any, *, field: str, owner: str) -> list[str]:
|
|
|
433
444
|
return items
|
|
434
445
|
|
|
435
446
|
|
|
447
|
+
def parse_string_map(value: Any, *, field: str, owner: str) -> dict[str, str]:
|
|
448
|
+
"""Parse a JSON fixture field that must be an object of non-empty string values."""
|
|
449
|
+
if value is None:
|
|
450
|
+
return {}
|
|
451
|
+
if not isinstance(value, dict):
|
|
452
|
+
raise SystemExit(f"{owner} {field} must be a JSON object of strings")
|
|
453
|
+
items: dict[str, str] = {}
|
|
454
|
+
for raw_key, raw_value in value.items():
|
|
455
|
+
if not isinstance(raw_key, str) or not raw_key.strip():
|
|
456
|
+
raise SystemExit(f"{owner} {field} keys must be non-empty strings")
|
|
457
|
+
if not isinstance(raw_value, str) or not raw_value.strip():
|
|
458
|
+
raise SystemExit(f"{owner} {field}.{raw_key} must be a non-empty string")
|
|
459
|
+
items[raw_key] = raw_value
|
|
460
|
+
return items
|
|
461
|
+
|
|
462
|
+
|
|
436
463
|
def validate_variant_extra_args(extra_args: list[str], *, owner: str) -> list[str]:
|
|
437
464
|
for index, arg in enumerate(extra_args):
|
|
438
465
|
flag = arg.split("=", 1)[0]
|
|
@@ -443,6 +470,101 @@ def validate_variant_extra_args(extra_args: list[str], *, owner: str) -> list[st
|
|
|
443
470
|
return extra_args
|
|
444
471
|
|
|
445
472
|
|
|
473
|
+
def validate_variant_prompt_file_path(raw_path: str, *, owner: str) -> Path:
|
|
474
|
+
"""Return a safe relative prompt-file path, or fail before any file read."""
|
|
475
|
+
rel_path = Path(raw_path)
|
|
476
|
+
if rel_path.is_absolute():
|
|
477
|
+
raise SystemExit(f"{owner} variant_prompt_files path must be relative: {raw_path}")
|
|
478
|
+
if not rel_path.parts or rel_path == Path("."):
|
|
479
|
+
raise SystemExit(f"{owner} variant_prompt_files path must name a file")
|
|
480
|
+
if any(part in ("", ".", "..") for part in rel_path.parts):
|
|
481
|
+
raise SystemExit(f"{owner} variant_prompt_files path must not contain '.', '..', or empty components: {raw_path}")
|
|
482
|
+
return rel_path
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def validate_variant_prompt_file_references(
|
|
486
|
+
tasks: list[TaskFixture],
|
|
487
|
+
variants: list["Variant"],
|
|
488
|
+
) -> None:
|
|
489
|
+
"""Validate variant prompt-file keys and paths without dereferencing files.
|
|
490
|
+
|
|
491
|
+
Unknown variant keys and unsafe relative paths are rejected before any file
|
|
492
|
+
read. Missing prompt files are intentionally not checked here so a run
|
|
493
|
+
narrowed by --task-id/--variant is not blocked by unselected prompt files.
|
|
494
|
+
"""
|
|
495
|
+
known_variants = {variant.name for variant in variants}
|
|
496
|
+
for task in tasks:
|
|
497
|
+
unknown = sorted(set(task.variant_prompt_files) - known_variants)
|
|
498
|
+
if unknown:
|
|
499
|
+
raise SystemExit(
|
|
500
|
+
f"task {task.id} variant_prompt_files references unknown variant(s): {', '.join(unknown)}"
|
|
501
|
+
)
|
|
502
|
+
for variant_name, raw_path in task.variant_prompt_files.items():
|
|
503
|
+
validate_variant_prompt_file_path(
|
|
504
|
+
raw_path,
|
|
505
|
+
owner=f"task {task.id} variant {variant_name}",
|
|
506
|
+
)
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def read_variant_prompt_file(path: Path, *, owner: str, display_path: str | None = None) -> str:
|
|
510
|
+
"""Read one selected prompt file with no-follow IO and an argv-safe size cap."""
|
|
511
|
+
label = display_path or path.name
|
|
512
|
+
try:
|
|
513
|
+
fd = _open_regular_no_symlink(path)
|
|
514
|
+
except OSError as exc:
|
|
515
|
+
detail = exc.strerror or exc.__class__.__name__
|
|
516
|
+
raise SystemExit(f"{owner} variant_prompt_files could not read prompt file: {label}: {detail}") from None
|
|
517
|
+
try:
|
|
518
|
+
size = os.fstat(fd).st_size
|
|
519
|
+
if size > MAX_VARIANT_PROMPT_FILE_BYTES:
|
|
520
|
+
raise SystemExit(
|
|
521
|
+
f"{owner} variant_prompt_files prompt file exceeds "
|
|
522
|
+
f"{MAX_VARIANT_PROMPT_FILE_BYTES} bytes: {label}"
|
|
523
|
+
)
|
|
524
|
+
try:
|
|
525
|
+
with os.fdopen(fd, "r", encoding="utf-8") as handle:
|
|
526
|
+
fd = -1
|
|
527
|
+
text = handle.read()
|
|
528
|
+
except UnicodeDecodeError as exc:
|
|
529
|
+
raise SystemExit(
|
|
530
|
+
f"{owner} variant_prompt_files prompt file must be UTF-8 text: "
|
|
531
|
+
f"{label}: {exc.reason}"
|
|
532
|
+
) from None
|
|
533
|
+
except OSError as exc:
|
|
534
|
+
detail = exc.strerror or exc.__class__.__name__
|
|
535
|
+
raise SystemExit(f"{owner} variant_prompt_files could not read prompt file: {label}: {detail}") from None
|
|
536
|
+
finally:
|
|
537
|
+
if fd != -1:
|
|
538
|
+
os.close(fd)
|
|
539
|
+
if len(text.encode("utf-8", errors="replace")) > MAX_VARIANT_PROMPT_FILE_BYTES:
|
|
540
|
+
raise SystemExit(
|
|
541
|
+
f"{owner} variant_prompt_files prompt text exceeds "
|
|
542
|
+
f"{MAX_VARIANT_PROMPT_FILE_BYTES} bytes after decoding: {label}"
|
|
543
|
+
)
|
|
544
|
+
return text
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def load_variant_prompt_files_for_targets(
|
|
548
|
+
targets: list[tuple[TaskFixture, "Variant"]],
|
|
549
|
+
*,
|
|
550
|
+
task_file_dir: Path,
|
|
551
|
+
) -> None:
|
|
552
|
+
"""Load file-backed prompts only for selected (task, variant) targets."""
|
|
553
|
+
for task, variant in targets:
|
|
554
|
+
raw_path = task.variant_prompt_files.get(variant.name)
|
|
555
|
+
if raw_path is None:
|
|
556
|
+
continue
|
|
557
|
+
rel_path = validate_variant_prompt_file_path(
|
|
558
|
+
raw_path,
|
|
559
|
+
owner=f"task {task.id} variant {variant.name}",
|
|
560
|
+
)
|
|
561
|
+
task.variant_prompt_texts[variant.name] = read_variant_prompt_file(
|
|
562
|
+
task_file_dir / rel_path,
|
|
563
|
+
owner=f"task {task.id} variant {variant.name}",
|
|
564
|
+
display_path=str(rel_path),
|
|
565
|
+
)
|
|
566
|
+
|
|
567
|
+
|
|
446
568
|
def normalize_usage_token(value: Any) -> int | None:
|
|
447
569
|
"""Return a safe non-negative token count, or None for invalid metrics."""
|
|
448
570
|
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
@@ -469,7 +591,7 @@ def normalize_usage_cost(value: Any) -> float | None:
|
|
|
469
591
|
return numeric
|
|
470
592
|
|
|
471
593
|
|
|
472
|
-
def parse_tasks(path: Path) -> list[TaskFixture]:
|
|
594
|
+
def parse_tasks(path: Path, variants: list["Variant"] | None = None) -> list[TaskFixture]:
|
|
473
595
|
raw = json.loads(_read_text_no_follow(path))
|
|
474
596
|
if not isinstance(raw, list):
|
|
475
597
|
raise SystemExit(f"tasks file must be a JSON list: {path}")
|
|
@@ -488,21 +610,33 @@ def parse_tasks(path: Path) -> list[TaskFixture]:
|
|
|
488
610
|
raise SystemExit(f"task {item.get('id')} max_budget_usd must be finite and > 0 (use null for unlimited)")
|
|
489
611
|
else:
|
|
490
612
|
budget = None
|
|
613
|
+
task_id = str(item["id"])
|
|
614
|
+
if "variant_prompts" in item:
|
|
615
|
+
raise SystemExit(
|
|
616
|
+
f"task {task_id} variant_prompts is not supported; use file-backed variant_prompt_files"
|
|
617
|
+
)
|
|
491
618
|
fixtures.append(TaskFixture(
|
|
492
|
-
id=
|
|
619
|
+
id=task_id,
|
|
493
620
|
prompt=str(item["prompt"]),
|
|
494
621
|
model=str(item.get("model", "sonnet")),
|
|
495
622
|
effort=str(effort_raw) if effort_raw is not None else None,
|
|
496
|
-
max_turns=parse_positive_int(item.get("max_turns", 3), field="max_turns", owner=f"task {
|
|
623
|
+
max_turns=parse_positive_int(item.get("max_turns", 3), field="max_turns", owner=f"task {task_id}"),
|
|
497
624
|
max_budget_usd=budget,
|
|
498
625
|
allowed_tools=parse_string_list(
|
|
499
626
|
item.get("allowed_tools", []),
|
|
500
627
|
field="allowed_tools",
|
|
501
|
-
owner=f"task {
|
|
628
|
+
owner=f"task {task_id}",
|
|
502
629
|
),
|
|
503
630
|
success_command=item.get("success_command"),
|
|
504
631
|
success_cwd=str(item.get("success_cwd", ".")),
|
|
632
|
+
variant_prompt_files=parse_string_map(
|
|
633
|
+
item.get("variant_prompt_files"),
|
|
634
|
+
field="variant_prompt_files",
|
|
635
|
+
owner=f"task {task_id}",
|
|
636
|
+
),
|
|
505
637
|
))
|
|
638
|
+
if variants is not None:
|
|
639
|
+
validate_variant_prompt_file_references(fixtures, variants)
|
|
506
640
|
return fixtures
|
|
507
641
|
|
|
508
642
|
|
|
@@ -717,6 +851,102 @@ def collect_shift_metrics(payload: Any) -> dict[str, int | float | bool]:
|
|
|
717
851
|
return metrics
|
|
718
852
|
|
|
719
853
|
|
|
854
|
+
def normalize_self_hosted_metric(value: Any, *, maximum: float) -> float | None:
|
|
855
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
856
|
+
return None
|
|
857
|
+
number = float(value)
|
|
858
|
+
if not math.isfinite(number) or number < 0 or number > maximum:
|
|
859
|
+
return None
|
|
860
|
+
return number
|
|
861
|
+
|
|
862
|
+
|
|
863
|
+
def sanitize_self_hosted_label(value: Any) -> str | None:
|
|
864
|
+
if not isinstance(value, str):
|
|
865
|
+
return None
|
|
866
|
+
text = sanitize_note_text(value)
|
|
867
|
+
if not text:
|
|
868
|
+
return None
|
|
869
|
+
if len(text) > MAX_SELF_HOSTED_LABEL_CHARS:
|
|
870
|
+
text = text[:MAX_SELF_HOSTED_LABEL_CHARS - 12].rstrip() + "…[truncated]"
|
|
871
|
+
return text
|
|
872
|
+
|
|
873
|
+
|
|
874
|
+
def normalize_self_hosted_metrics(raw: Any, *, source: str) -> dict[str, Any] | None:
|
|
875
|
+
if not isinstance(raw, dict):
|
|
876
|
+
return None
|
|
877
|
+
metrics: dict[str, float] = {}
|
|
878
|
+
labels: dict[str, str] = {}
|
|
879
|
+
availability = {
|
|
880
|
+
"latency_ms": False,
|
|
881
|
+
"peak_memory_mb": False,
|
|
882
|
+
"quality_score": False,
|
|
883
|
+
}
|
|
884
|
+
latency = normalize_self_hosted_metric(raw.get("latency_ms"), maximum=MAX_SELF_HOSTED_LATENCY_MS)
|
|
885
|
+
if latency is not None:
|
|
886
|
+
metrics["latency_ms"] = latency
|
|
887
|
+
availability["latency_ms"] = True
|
|
888
|
+
peak_memory = normalize_self_hosted_metric(raw.get("peak_memory_mb"), maximum=MAX_SELF_HOSTED_MEMORY_MB)
|
|
889
|
+
if peak_memory is not None:
|
|
890
|
+
metrics["peak_memory_mb"] = peak_memory
|
|
891
|
+
availability["peak_memory_mb"] = True
|
|
892
|
+
quality = normalize_self_hosted_metric(raw.get("quality_score"), maximum=1.0)
|
|
893
|
+
if quality is not None:
|
|
894
|
+
metrics["quality_score"] = quality
|
|
895
|
+
availability["quality_score"] = True
|
|
896
|
+
for key in ("model_server", "optimization", "quality_metric"):
|
|
897
|
+
label = sanitize_self_hosted_label(raw.get(key))
|
|
898
|
+
if label is not None:
|
|
899
|
+
labels[key] = label
|
|
900
|
+
if not metrics:
|
|
901
|
+
return None
|
|
902
|
+
return {
|
|
903
|
+
"schema_version": SELF_HOSTED_METRICS_SCHEMA_VERSION,
|
|
904
|
+
"source": source,
|
|
905
|
+
"metrics": metrics,
|
|
906
|
+
"labels": labels,
|
|
907
|
+
"measurement_availability": availability,
|
|
908
|
+
"claim_boundary": {
|
|
909
|
+
"id": SELF_HOSTED_METRICS_CLAIM_BOUNDARY,
|
|
910
|
+
"hosted_api_token_savings_claim_allowed": False,
|
|
911
|
+
"hosted_api_cost_savings_claim_allowed": False,
|
|
912
|
+
"requires_provider_measured_matched_tasks_for_hosted_claims": True,
|
|
913
|
+
"reason": (
|
|
914
|
+
"Self-hosted local/model-server latency, memory, and quality metrics "
|
|
915
|
+
"are not hosted API token or cost telemetry."
|
|
916
|
+
),
|
|
917
|
+
},
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
|
|
921
|
+
def collect_self_hosted_metrics(payload: Any) -> dict[str, Any] | None:
|
|
922
|
+
"""Collect explicit self-hosted metric sidecars without broad key inference.
|
|
923
|
+
|
|
924
|
+
Only explicit top-level telemetry envelopes are considered. Do not infer
|
|
925
|
+
from incidental keys like `self_hosted_latency_ms` or arbitrary nested model
|
|
926
|
+
message content: that would make local/model-server telemetry too easy to
|
|
927
|
+
mix into hosted API claim surfaces.
|
|
928
|
+
"""
|
|
929
|
+
if not isinstance(payload, dict):
|
|
930
|
+
return None
|
|
931
|
+
candidates = [
|
|
932
|
+
(
|
|
933
|
+
payload.get(SELF_HOSTED_METRICS_KEY),
|
|
934
|
+
f"explicit_provider_payload.{SELF_HOSTED_METRICS_KEY}",
|
|
935
|
+
)
|
|
936
|
+
]
|
|
937
|
+
metrics_envelope = payload.get("metrics")
|
|
938
|
+
if isinstance(metrics_envelope, dict):
|
|
939
|
+
candidates.append((
|
|
940
|
+
metrics_envelope.get(SELF_HOSTED_METRICS_KEY),
|
|
941
|
+
f"explicit_provider_payload.metrics.{SELF_HOSTED_METRICS_KEY}",
|
|
942
|
+
))
|
|
943
|
+
for raw, source in candidates:
|
|
944
|
+
normalized = normalize_self_hosted_metrics(raw, source=source)
|
|
945
|
+
if normalized is not None:
|
|
946
|
+
return normalized
|
|
947
|
+
return None
|
|
948
|
+
|
|
949
|
+
|
|
720
950
|
def claude_version(claude_bin: str) -> str:
|
|
721
951
|
try:
|
|
722
952
|
proc = run_bounded_command(
|
|
@@ -747,7 +977,7 @@ def build_claude_argv(claude_bin: str, task: TaskFixture, variant: Variant) -> l
|
|
|
747
977
|
argv.extend(["--allowedTools", ",".join(task.allowed_tools)])
|
|
748
978
|
argv.extend(variant.extra_args)
|
|
749
979
|
argv.append("--")
|
|
750
|
-
argv.append(task.prompt)
|
|
980
|
+
argv.append(task.variant_prompt_texts.get(variant.name, task.prompt))
|
|
751
981
|
return argv
|
|
752
982
|
|
|
753
983
|
|
|
@@ -1003,6 +1233,7 @@ def run_fixture(task: TaskFixture, variant: Variant, claude_bin: str,
|
|
|
1003
1233
|
tokens, cost, cost_measured, primary_tokens_measured = collect_usage(payload)
|
|
1004
1234
|
provider_cached_tokens, provider_cached_tokens_measured = collect_provider_cache_telemetry(payload)
|
|
1005
1235
|
shift_metrics = collect_shift_metrics(payload)
|
|
1236
|
+
self_hosted_metrics = collect_self_hosted_metrics(payload)
|
|
1006
1237
|
success, success_note = run_success_command(task, project_root)
|
|
1007
1238
|
return RunResult(
|
|
1008
1239
|
task_id=task.id, variant=variant.name, model=task.model, effort=task.effort,
|
|
@@ -1021,6 +1252,7 @@ def run_fixture(task: TaskFixture, variant: Variant, claude_bin: str,
|
|
|
1021
1252
|
external_cost_measured=bool(shift_metrics["external_cost_measured"]),
|
|
1022
1253
|
provider_cached_tokens=provider_cached_tokens,
|
|
1023
1254
|
provider_cached_tokens_measured=provider_cached_tokens_measured,
|
|
1255
|
+
self_hosted_metrics=self_hosted_metrics,
|
|
1024
1256
|
)
|
|
1025
1257
|
|
|
1026
1258
|
|
|
@@ -1169,6 +1401,7 @@ def append_cost_shift_ledger(path: Path, claude_ver: str, result: RunResult) ->
|
|
|
1169
1401
|
"provider_cache": result.provider_cached_tokens_measured,
|
|
1170
1402
|
"byte_metrics": byte_metrics_observed,
|
|
1171
1403
|
"wall_time": result.wall_time_seconds >= 0,
|
|
1404
|
+
"self_hosted_metrics": result.self_hosted_metrics is not None,
|
|
1172
1405
|
},
|
|
1173
1406
|
"proxy_metrics": {
|
|
1174
1407
|
"byte_metrics_observed": byte_metrics_observed,
|
|
@@ -1177,6 +1410,8 @@ def append_cost_shift_ledger(path: Path, claude_ver: str, result: RunResult) ->
|
|
|
1177
1410
|
"claim_boundary": "proxy_only_not_hosted_token_savings",
|
|
1178
1411
|
},
|
|
1179
1412
|
}
|
|
1413
|
+
if result.self_hosted_metrics is not None:
|
|
1414
|
+
payload["self_hosted_metrics"] = result.self_hosted_metrics
|
|
1180
1415
|
with csv_file_lock(path, create_parent=True):
|
|
1181
1416
|
fd = _open_regular_no_symlink(path, os.O_CREAT | os.O_APPEND | os.O_WRONLY, 0o600, create_parent=True)
|
|
1182
1417
|
try:
|
|
@@ -2090,8 +2325,8 @@ def main() -> int:
|
|
|
2090
2325
|
require_no_follow_file_ops_supported()
|
|
2091
2326
|
validate_distinct_output_paths(args.csv, args.ledger_jsonl, args.report_json)
|
|
2092
2327
|
|
|
2093
|
-
tasks = parse_tasks(args.tasks)
|
|
2094
2328
|
variants = parse_variants(args.variants)
|
|
2329
|
+
tasks = parse_tasks(args.tasks, variants=variants)
|
|
2095
2330
|
targets = filter_targets(tasks, variants, args.task_id, args.variant)
|
|
2096
2331
|
if not targets:
|
|
2097
2332
|
print("no (task, variant) targets matched the filters", file=sys.stderr)
|
|
@@ -2122,6 +2357,9 @@ def main() -> int:
|
|
|
2122
2357
|
print(f"claude binary not found: {args.claude_bin}", file=sys.stderr)
|
|
2123
2358
|
return 2
|
|
2124
2359
|
|
|
2360
|
+
if runnable_targets:
|
|
2361
|
+
load_variant_prompt_files_for_targets(runnable_targets, task_file_dir=args.tasks.parent)
|
|
2362
|
+
|
|
2125
2363
|
project_root = args.project_root.resolve()
|
|
2126
2364
|
claude_ver = "dry-run" if args.dry_run else (claude_version(args.claude_bin) if runnable_targets else "skipped")
|
|
2127
2365
|
|