Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
4606847
feat: implement issue #1565 — Auto-pause companion: usage-telemetry a…
don-petry Sep 22, 2026
1e6c4a6
Merge branch 'main' into dev-lead/issue-1565-20260922-1659
don-petry Sep 22, 2026
e3a60d3
Merge branch 'main' into dev-lead/issue-1565-20260922-1659
don-petry Sep 22, 2026
782cb9f
Merge branch 'main' into dev-lead/issue-1565-20260922-1659
don-petry Sep 23, 2026
762d6d0
Merge branch 'main' into dev-lead/issue-1565-20260922-1659
don-petry Sep 23, 2026
8d287a7
Merge branch 'main' into dev-lead/issue-1565-20260922-1659
don-petry Sep 23, 2026
0f32a09
Merge branch 'main' into dev-lead/issue-1565-20260922-1659
don-petry Sep 23, 2026
9974897
Merge branch 'main' into dev-lead/issue-1565-20260922-1659
don-petry Sep 24, 2026
42e89fa
Merge branch 'main' into dev-lead/issue-1565-20260922-1659
don-petry Sep 24, 2026
b850b6d
Merge branch 'main' into dev-lead/issue-1565-20260922-1659
don-petry Sep 24, 2026
7b1e7c6
Merge branch 'main' into dev-lead/issue-1565-20260922-1659
don-petry Sep 24, 2026
c9f3021
Merge branch 'main' into dev-lead/issue-1565-20260922-1659
don-petry Sep 25, 2026
44e08a9
Merge branch 'main' into dev-lead/issue-1565-20260922-1659
don-petry Sep 25, 2026
3e1c095
Merge branch 'main' into dev-lead/issue-1565-20260922-1659
don-petry Sep 25, 2026
5d959ee
Merge branch 'main' into dev-lead/issue-1565-20260922-1659
don-petry Sep 26, 2026
74578fb
Merge branch 'main' into dev-lead/issue-1565-20260922-1659
don-petry Sep 26, 2026
c6da825
Merge branch 'main' into dev-lead/issue-1565-20260922-1659
don-petry Sep 27, 2026
d26cef5
Merge branch 'main' into dev-lead/issue-1565-20260922-1659
don-petry Sep 27, 2026
7cbcbd8
Merge branch 'main' into dev-lead/issue-1565-20260922-1659
don-petry Sep 29, 2026
10f9b48
Merge branch 'main' into dev-lead/issue-1565-20260922-1659
don-petry Sep 29, 2026
1e67afa
Merge branch 'main' into dev-lead/issue-1565-20260922-1659
don-petry Sep 30, 2026
f0ed090
Merge branch 'main' into dev-lead/issue-1565-20260922-1659
don-petry Sep 30, 2026
f8182cf
Merge branch 'main' into dev-lead/issue-1565-20260922-1659
don-petry Oct 1, 2026
947fb8a
Merge branch 'main' into dev-lead/issue-1565-20260922-1659
don-petry Oct 1, 2026
2fad3e8
Merge branch 'main' into dev-lead/issue-1565-20260922-1659
don-petry Oct 1, 2026
8411a2b
Merge branch 'main' into dev-lead/issue-1565-20260922-1659
don-petry Oct 1, 2026
39ccecb
Merge branch 'main' into dev-lead/issue-1565-20260922-1659
don-petry Oct 1, 2026
ff6d2fe
Merge branch 'main' into dev-lead/issue-1565-20260922-1659
don-petry Oct 1, 2026
55d5b0b
Merge branch 'main' into dev-lead/issue-1565-20260922-1659
don-petry Oct 1, 2026
fbbd4ba
Merge branch 'main' into dev-lead/issue-1565-20260922-1659
don-petry Oct 1, 2026
0c829cf
fix(reviews): address review comments [skip ci-relay]
don-petry Oct 2, 2026
5d8a604
fix(reviews): address review comments [skip ci-relay]
don-petry Oct 2, 2026
5391058
Merge branch 'main' into dev-lead/issue-1565-20260922-1659
don-petry Oct 2, 2026
c867941
fix(reviews): address review comments [skip ci-relay]
don-petry Oct 2, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,8 @@
tests/dev-lead/unit/test_fewshot_assemble.bats \
tests/dev-lead/unit/test_net_diff_guard.bats \
tests/dev-lead/unit/test_verify_approval_review.bats \
tests/dev-lead/unit/test_usage_telemetry.bats \
tests/dev-lead/integration/test_usage_telemetry_roundtrip.bats \
tests/test_qa_lead_test_surface.bats \
tests/test_qa_lead_advisory_gate.bats \
tests/test_qa_lead_holdout_calibration.bats \
Expand Down Expand Up @@ -294,7 +296,7 @@
timeout-minutes: 5
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# NOTE: persist-credentials is intentionally left at the default (true) —

Check warning on line 299 in .github/workflows/lint.yml

View workflow job for this annotation

GitHub Actions / Lint

299:9 [comments-indentation] comment not indented like content
# vci_resolve_reusable needs `git fetch origin` to authenticate against this
# private repo when resolving same-repo channel tags. Setting
# persist-credentials: false would silently push every such caller into the
Expand Down
18 changes: 18 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -647,6 +647,24 @@ deleting the variable or setting it to anything other than `true`. If the
secret is genuinely missing while unpaused, the reusable's "Engine token
preflight" step fails with a single actionable error naming the secret.

**Planned automatic budget pause (companion, #1565; poller not yet implemented — only the telemetry adapter ships today).** The same `AGENTS_PAUSED` switch can
be flipped automatically from the Claude subscription usage budget. The transport
half — `scripts/lib/usage-telemetry.sh` — reads the OAuth usage endpoint and emits
the envelope the org token-budget breaker consumes; the scheduled poller that acts
on it sets `AGENTS_PAUSED=true` **and** records `AGENTS_PAUSE_SOURCE` (e.g.
`weekly-glide` / `five-hour`) so an automatic pause is legible rather than looking
like a quiet fleet. **Auto-resume is scoped by that source:** after the window's
`resets_at` passes the poller clears `AGENTS_PAUSED` **only** when
`AGENTS_PAUSE_SOURCE` marks it automation-set — a pause a human set by hand (no
`AGENTS_PAUSE_SOURCE`, per the manual path above) is never cleared by automation.
- **Override / hard stop:** set `AGENTS_PAUSED=true` **without** `AGENTS_PAUSE_SOURCE`
(the manual path) to hold the fleet paused regardless of budget — the poller
leaves a human-set pause untouched. To force a resume, delete `AGENTS_PAUSED`
(and any `AGENTS_PAUSE_SOURCE`).
- **Status:** the poller defaults to **dry-run** (logs the decision, writes no
variable) until the epic's human sign-off; on a telemetry error it fails **open**
(allow), so a budget-read outage never stops the fleet.

### Initiative Planner — blocking open-questions gate

`scripts/initiative-planner/apply-plan.sh` will **not** materialize an epic + sub-issue
Expand Down
230 changes: 230 additions & 0 deletions scripts/lib/usage-telemetry.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
# shellcheck shell=bash
# scripts/lib/usage-telemetry.sh — Private transport adapter for the Claude
# subscription OAuth usage endpoint (#1565).
#
# This library owns exactly one thing: the HTTP call to
# GET https://api.anthropic.com/api/oauth/usage
# and the normalized TRANSPORT ENVELOPE the shipped public token-budget breaker
# (petry-projects/.github `scripts/lib/agent-rate-limit.sh`) already consumes via
# its adapter seam:
#
# { "status": <http_status>, "retry_after": <int?>, "observed_at": <epoch?>, "body": <raw upstream> }
#
# The envelope is a TRANSPORT wrapper, not a normalization: `body` carries the
# upstream response UNMODIFIED. Field extraction (percent, resets_at, is_active,
# limits[] vs. flattened five_hour/seven_day) belongs to the public library's own
# arl_token_extract_* helpers. Re-parsing the body here would create a second,
# drifting parser — the exact failure model-pricing.tsv exists to prevent.
#
# ----------------------------------------------------------------------------
# Caller contract
# ----------------------------------------------------------------------------
# Sourced by a parent script (`# shellcheck source=scripts/lib/usage-telemetry.sh`).
# It is `set -euo pipefail`-safe, calls `set` on nothing, and runs nothing at
# source time. Reads (all optional, with defaults):
# - $CLAUDE_CODE_OAUTH_TOKEN — the OAuth bearer token; unset => status=0
# envelope (degraded; the library fails safe to
# allow, never a hard failure).
# - $CLAUDE_CODE_VERSION — pins the User-Agent version; else derived from
# the `claude` CLI, else "unknown".
# - $USAGE_TELEMETRY_ENDPOINT — override the usage URL (testability).
# - $USAGE_TELEMETRY_BETA — the anthropic-beta value (default oauth-2025-04-20).
# - $USAGE_TELEMETRY_TIMEOUT — curl --max-time seconds (default 10).
# - $USAGE_TELEMETRY_CURL — the curl binary/shim (default `curl`; tests mock it).
# - $USAGE_TELEMETRY_NOW — epoch override for observed_at (testability).
#
# Fail-safe direction (ADR §7): any non-200, transport error, malformed body, or
# missing token yields an envelope the public library reads as allow-with-warning.
# The one blocking signal — a fresh 429 with a positive retry-after — is expressed
# by populating `status`, `retry_after`, and `observed_at`; the library's
# arl_token_transport_decision turns that into a defer.

USAGE_TELEMETRY_ENDPOINT="${USAGE_TELEMETRY_ENDPOINT:-https://api.anthropic.com/api/oauth/usage}"
USAGE_TELEMETRY_BETA="${USAGE_TELEMETRY_BETA:-oauth-2025-04-20}"

# ---------------------------------------------------------------------------
# usage_telemetry_log <msg> — human-readable reasoning to stderr so a caller can
# capture the machine-readable envelope on stdout uncluttered.
# ---------------------------------------------------------------------------
usage_telemetry_log() { printf 'usage-telemetry: %s\n' "$*" >&2; }

# ---------------------------------------------------------------------------
# usage_telemetry_now — current epoch seconds, honoring $USAGE_TELEMETRY_NOW.
# ---------------------------------------------------------------------------
usage_telemetry_now() {
if [ -n "${USAGE_TELEMETRY_NOW:-}" ]; then
printf '%s' "$USAGE_TELEMETRY_NOW"
return 0
fi
date +%s
}

# ---------------------------------------------------------------------------
# usage_telemetry_int <value> [default] — echo <value> when it is a non-negative
# integer, else <default> (default 0). The fail-safe integer idiom.
# ---------------------------------------------------------------------------
usage_telemetry_int() {
local value="${1:-}" fallback="${2:-0}"
if [[ "$value" =~ ^[0-9]+$ ]]; then
printf '%s' "$value"
else
printf '%s' "$fallback"
fi
}

# ---------------------------------------------------------------------------
# usage_telemetry_user_agent — resolve the load-bearing `claude-code/<version>`
# User-Agent. Omitting it hits an aggressively rate-limited bucket and returns
# persistent 429s (issue Scope §1), so a version is always produced ("unknown"
# as a last resort rather than an empty UA).
# ---------------------------------------------------------------------------
usage_telemetry_user_agent() {
local ver=""
if [ -n "${CLAUDE_CODE_VERSION:-}" ]; then
ver="$CLAUDE_CODE_VERSION"
elif command -v claude >/dev/null 2>&1; then
ver="$(claude --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -n1 || printf '')"
fi
[ -z "$ver" ] && ver="unknown"
printf 'claude-code/%s' "$ver"
}

# ---------------------------------------------------------------------------
# usage_telemetry_retry_after <header_file> — echo the integer Retry-After header
# value (seconds) from a curl `-D` header dump, or empty when absent/non-integer.
# ---------------------------------------------------------------------------
usage_telemetry_retry_after() {
local hdrfile="${1:-}" now_epoch="${2:-}" value target
[ -n "$hdrfile" ] && [ -f "$hdrfile" ] || return 0
value="$(grep -i '^retry-after:' "$hdrfile" 2>/dev/null | head -n1 \
| cut -d: -f2- | tr -d '[:space:]\r' || printf '')"
if [[ "$value" =~ ^[0-9]+$ ]]; then
printf '%s' "$value"
return 0
fi
# RFC 9110 also allows an HTTP-date; convert to delta seconds (GNU, then BSD date).
value="$(grep -i '^retry-after:' "$hdrfile" 2>/dev/null | head -n1 \
| cut -d: -f2- | tr -d '\r' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' || printf '')"
[ -n "$value" ] || return 0
target="$(date -u -d "$value" +%s 2>/dev/null \
|| date -u -j -f '%a, %d %b %Y %H:%M:%S GMT' "$value" +%s 2>/dev/null || printf '')"
[[ "$target" =~ ^[0-9]+$ ]] || return 0
[ -n "$now_epoch" ] || now_epoch="$(usage_telemetry_now)"
if [ "$target" -gt "$now_epoch" ]; then
printf '%s' "$((target - now_epoch))"
fi
Comment thread
don-petry marked this conversation as resolved.
}

# ---------------------------------------------------------------------------
# usage_telemetry_envelope <status> <retry_after> <observed_at> <body_raw> —
# assemble the normalized envelope JSON. `retry_after` and `body_raw` are omitted
# when empty / non-integer / not valid JSON (the library reads `.retry_after?`
# and `.body? // {}`, so an absent key is the safe default). The body is embedded
# UNMODIFIED when it parses as JSON.
# ---------------------------------------------------------------------------
usage_telemetry_envelope() {
local status="$1" retry_after="$2" observed_at="$3" body_raw="$4"
local -a args=(--argjson status "$(usage_telemetry_int "$status" 0)")
local filter='{status: $status}'

if [ -n "$observed_at" ]; then
args+=(--argjson observed_at "$(usage_telemetry_int "$observed_at" 0)")
filter+=' | .observed_at = $observed_at'
fi
if [[ "$retry_after" =~ ^[0-9]+$ ]]; then
args+=(--argjson retry_after "$retry_after")
filter+=' | .retry_after = $retry_after'
fi
if [ -n "$body_raw" ] && jq -e . <<<"$body_raw" >/dev/null 2>&1; then
args+=(--argjson body "$body_raw")
filter+=' | .body = $body'
fi

jq -cn "${args[@]}" "$filter" 2>/dev/null || printf '{"status":0}'
}

# ---------------------------------------------------------------------------
# usage_telemetry_fetch — perform the single OAuth usage read and echo the
# envelope. Never fails hard: a missing token, transport error, or non-200 all
# yield an envelope the public library reads as allow-with-warning.
# ---------------------------------------------------------------------------
usage_telemetry_fetch() {
local now token ua curl_bin hdrfile raw status body retry_after
now="$(usage_telemetry_now)"
token="${CLAUDE_CODE_OAUTH_TOKEN:-}"

if [ -z "$token" ]; then
usage_telemetry_log "CLAUDE_CODE_OAUTH_TOKEN unset — status=0 (degraded; library fails safe to allow)"
usage_telemetry_envelope 0 "" "$now" ""
return 0
fi

ua="$(usage_telemetry_user_agent)"
curl_bin="${USAGE_TELEMETRY_CURL:-curl}"
hdrfile="$(mktemp "${TMPDIR:-/tmp}/usage-telemetry-hdr.XXXXXX")" || {
usage_telemetry_log "failed to create temporary file for headers — status=0 (degraded; fail-safe allow)"
usage_telemetry_envelope 0 "" "$now" ""
return 0
}

# Body (any internal newlines) plus a trailing status line on stdout; response
# headers to $hdrfile. No `-o`, so the body rides stdout and `-w` appends the
# status as the final line.
# The bearer token is fed via a stdin curl config (`-K -`), never argv, so it
# is not visible in process listings.
if ! raw="$(printf 'header = "Authorization: Bearer %s"\n' "$token" | "$curl_bin" -sS \
-K - \
--max-time "${USAGE_TELEMETRY_TIMEOUT:-10}" \
-D "$hdrfile" \
-w $'\n%{http_code}' \
-H "anthropic-beta: ${USAGE_TELEMETRY_BETA}" \
-H "User-Agent: ${ua}" \
"$USAGE_TELEMETRY_ENDPOINT" 2>/dev/null)"; then
usage_telemetry_log "curl transport error reaching usage endpoint — status=0 (degraded; fail-safe allow)"
rm -f "$hdrfile"
usage_telemetry_envelope 0 "" "$now" ""
return 0
fi

status="$(usage_telemetry_int "${raw##*$'\n'}" 0)"
body="${raw%$'\n'*}"
# When the body was empty, the strip above leaves "" (raw was "\n<status>").

retry_after=""
if [ "$status" = "429" ]; then
retry_after="$(usage_telemetry_retry_after "$hdrfile" "$now")"
fi
rm -f "$hdrfile"

if [ "$status" != "200" ]; then
usage_telemetry_log "usage endpoint returned HTTP ${status} (non-200; library fails safe)"
fi

usage_telemetry_envelope "$status" "$retry_after" "$now" "$body"
Comment thread
don-petry marked this conversation as resolved.
}

# ---------------------------------------------------------------------------
# usage_telemetry_publish_file <envelope> [file] — write <envelope> to <file>
# (a fresh temp file when omitted) and export AGENT_TOKEN_BUDGET_TELEMETRY_FILE —
# the existing seam the public library reads. Echoes the file path. This is the
# sanctioned wiring: no new seam is introduced.
# ---------------------------------------------------------------------------
usage_telemetry_publish_file() {
local envelope="$1" file="${2:-${AGENT_TOKEN_BUDGET_TELEMETRY_FILE:-}}"
if [ -z "$file" ]; then
file="$(mktemp "${TMPDIR:-/tmp}/agent-token-telemetry.XXXXXX")" || {
usage_telemetry_log "failed to create temporary file for telemetry"
return 1
}
fi
# Write atomically (temp + rename) so concurrent readers never see a
# truncated, half-written file.
local tmp
tmp="$(mktemp "${file}.XXXXXX")" || return 1
if ! { printf '%s' "$envelope" > "$tmp" && mv -f "$tmp" "$file"; }; then
rm -f "$tmp"
return 1
fi
Comment thread
don-petry marked this conversation as resolved.
export AGENT_TOKEN_BUDGET_TELEMETRY_FILE="$file"
printf '%s' "$file"
}
Loading
Loading