Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
27 changes: 23 additions & 4 deletions .github/workflows/aeon.yml
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,10 @@ on:
description: 'Correlation ID for chain dispatches'
required: false
type: string
trigger:
description: 'What started the run: cron (Scheduler) or chain (chain-runner); empty = manual'
required: false
type: string
workflow_call:
inputs:
skill:
Expand All @@ -118,6 +122,10 @@ on:
description: 'Correlation ID for chain dispatches'
required: false
type: string
trigger:
description: 'What started the run: cron (Scheduler) or chain (chain-runner); empty = manual'
required: false
type: string
issues:
types: [labeled]

Expand All @@ -137,6 +145,10 @@ env:
# actually see them. (Step-level env is NOT available in step `if:` clauses.)
FLEET_ENDPOINT: ${{ secrets.FLEET_ENDPOINT }}
FLEET_TOKEN: ${{ secrets.FLEET_TOKEN }}
# Langfuse trigger label (scripts/langfuse-otel.sh): the Scheduler passes
# trigger=cron and chain-runner trigger=chain, so a cron run, a chain step and
# a manual Run now no longer all read workflow_dispatch.
AEON_DISPATCH_TRIGGER: ${{ inputs.trigger }}

concurrency:
# Keyed by skill name alone, `var` was ignored entirely: dispatching the
Expand Down Expand Up @@ -1773,11 +1785,15 @@ jobs:
# below is tuned for that (composer/pi/vibe/kimi lack a native schema flag).
RH="${GITHUB_WORKSPACE}/harness-adapter/run-harness"
SCORE_ARGS=(--mode read-only --no-sandbox --timeout 300)
SCORE_CWD="$GITHUB_WORKSPACE"
case "$HARNESS" in
claude)
# Runs under the gateway env sourced above; SCORE_MODEL is the cheap
# scoring model (haiku by default).
SCORE_ARGS+=(--model "$SCORE_MODEL") ;;
# scoring model (haiku by default). Lean, like the feed render:
# --no-tools drops the tool schemas and an empty scratch cwd skips the
# repo CLAUDE.md, which made every grade carry ~37k tokens.
SCORE_ARGS+=(--model "$SCORE_MODEL" --no-tools)
SCORE_CWD=$(mktemp -d "${RUNNER_TEMP:-/tmp}/scorer.XXXXXX") ;;
grok)
# Pin grok-4.7, the grok harness default and a model the X-account
# (GROK_CREDENTIALS) login exposes (`grok models`, verified 2026-10-01).
Expand All @@ -1790,7 +1806,7 @@ jobs:
# RH_MODEL_ARG is the same --model the run used (empty = harness default).
[ -n "${RH_MODEL_ARG:-}" ] && SCORE_ARGS+=(--model "$RH_MODEL_ARG") ;;
esac
if ! RAW=$(echo "$ANALYSIS_PROMPT" | bash "$RH" "$HARNESS" "${SCORE_ARGS[@]}" 2>/dev/null); then
if ! RAW=$(cd "$SCORE_CWD" && echo "$ANALYSIS_PROMPT" | bash "$RH" "$HARNESS" "${SCORE_ARGS[@]}" 2>/dev/null); then
echo "::notice::Skill scoring skipped — $HARNESS scorer unavailable (non-fatal)"
exit 0
fi
Expand Down Expand Up @@ -1977,7 +1993,10 @@ jobs:
# !cancelled() (NOT a bare `mode != ''`, which GitHub implies success() onto
# and would skip on a failed skill): a FAILED skill still delivers what it
# queued (#912 R1). Cancellation is excluded so a killed job doesn't half-send.
if: ${{ !cancelled() && steps.work.outputs.mode != '' }}
# Default branch only, like the health-issue step: a test dispatch on a
# verify/* branch must not send real messages. Set the repo variable
# NOTIFY_ON_BRANCH=1 to see a test card; skipped payloads are dropped.
if: ${{ !cancelled() && steps.work.outputs.mode != '' && (github.ref_name == github.event.repository.default_branch || vars.NOTIFY_ON_BRANCH == '1') }}
env:
# #912 Phase 2: THIS step owns the channel tokens. ./notify (the queue-writer)
# no longer reads them in the skill run env; notify-deliver.sh consumes them
Expand Down
13 changes: 10 additions & 3 deletions .github/workflows/chain-runner.yml
Original file line number Diff line number Diff line change
Expand Up @@ -101,9 +101,16 @@ jobs:
expected_title+=" [dispatch: $dispatch_id]"

echo " Dispatching: $skill"
if ! gh workflow run aeon.yml "${args[@]}"; then
echo "::error::gh workflow run failed for skill: $skill"
return 1
# trigger=chain labels the run as a chain step (Langfuse trigger). An
# aeon.yml without the trigger input rejects the field before any run
# starts, so retry once without it.
local out
if ! out=$(gh workflow run aeon.yml "${args[@]}" -f trigger=chain 2>&1); then
if ! { printf '%s' "$out" | grep -qi 'unexpected inputs' && gh workflow run aeon.yml "${args[@]}"; }; then
printf '%s\n' "$out" >&2
echo "::error::gh workflow run failed for skill: $skill"
return 1
fi
fi

# Poll for the run to appear (up to 60s). Match the exact run-name
Expand Down
19 changes: 13 additions & 6 deletions .github/workflows/scheduler.yml
Original file line number Diff line number Diff line change
Expand Up @@ -491,12 +491,19 @@ jobs:

VAR="${SKILL_VAR[$SKILL]:-}"
DISPATCH_OK=true
if [ -n "$VAR" ]; then
echo "Dispatching: $SKILL (var: $VAR)${SKILL_DEPS[$SKILL]:+ [depends_on: ${SKILL_DEPS[$SKILL]}]}"
gh workflow run aeon.yml -f skill="$SKILL" -f var="$VAR" || DISPATCH_OK=false
else
echo "Dispatching: $SKILL${SKILL_DEPS[$SKILL]:+ [depends_on: ${SKILL_DEPS[$SKILL]}]}"
gh workflow run aeon.yml -f skill="$SKILL" || DISPATCH_OK=false
# trigger=cron labels the run as scheduled (Langfuse trigger). An
# aeon.yml without the trigger input (an older fork) rejects the field
# before any run starts, so retry once without it.
ARGS=(-f skill="$SKILL")
[ -n "$VAR" ] && ARGS+=(-f var="$VAR")
echo "Dispatching: $SKILL${VAR:+ (var: $VAR)}${SKILL_DEPS[$SKILL]:+ [depends_on: ${SKILL_DEPS[$SKILL]}]}"
if ! OUT=$(gh workflow run aeon.yml "${ARGS[@]}" -f trigger=cron 2>&1); then
if printf '%s' "$OUT" | grep -qi 'unexpected inputs'; then
gh workflow run aeon.yml "${ARGS[@]}" || DISPATCH_OK=false
else
printf '%s\n' "$OUT" >&2
DISPATCH_OK=false
fi
fi

# Stamp state ONLY for a dispatch GitHub actually accepted. A rejected
Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ At the start of every task, read `memory/MEMORY.md` for high-level context and c
After completing any task, append a log entry to `memory/logs/YYYY-MM-DD.md` under a `### <skill-name>` heading, as bullet points (the health loop parses this shape). **Exception: `mode: read-only` skills don't self-log** - the workflow appends that `### <skill-name>` entry for you from your final output (see Capability mode), so a self-written entry would be a duplicate.

### Memory structure
- **`memory/MEMORY.md`** — Short index (~50 lines): current goals, active topics, and pointers to topic files. A table of contents, not a dumping ground.
- **`memory/MEMORY.md`** - Short index (under 8 KB, ~50 lines, no line over 300 chars): current goals, active topics, and pointers to topic files. A table of contents, not a dumping ground.
- **`memory/topics/`** — Detailed notes by topic (e.g. `crypto.md`, `research.md`). When a topic outgrows a few lines in MEMORY.md, move it here and link.
- **`memory/logs/`** — Daily activity logs (`YYYY-MM-DD.md`), append-only.
- **`memory/issues/`** — Structured issue tracker for skill failures and degradations. **The health skill (`skill-health`) files issues; repair skills (`skill-repair`) close them.** The schema (frontmatter fields, severity, categories, lifecycle) is owned by `skills/skill-health/SKILL.md`; the end-to-end loop is documented in `docs/CORE.md`. Only active once `INDEX.md` exists.
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ At the start of every task, read `memory/MEMORY.md` for high-level context and c
After completing any task, append a log entry to `memory/logs/YYYY-MM-DD.md` under a `### <skill-name>` heading, as bullet points (the health loop parses this shape). **Exception: `mode: read-only` skills don't self-log** - the workflow appends that `### <skill-name>` entry for you from your final output (see Capability mode), so a self-written entry would be a duplicate.

### Memory structure
- **`memory/MEMORY.md`** — Short index (~50 lines): current goals, active topics, and pointers to topic files. A table of contents, not a dumping ground.
- **`memory/MEMORY.md`** - Short index (under 8 KB, ~50 lines, no line over 300 chars): current goals, active topics, and pointers to topic files. A table of contents, not a dumping ground.
- **`memory/topics/`** — Detailed notes by topic (e.g. `crypto.md`, `research.md`). When a topic outgrows a few lines in MEMORY.md, move it here and link.
- **`memory/logs/`** — Daily activity logs (`YYYY-MM-DD.md`), append-only.
- **`memory/issues/`** — Structured issue tracker for skill failures and degradations. **The health skill (`skill-health`) files issues; repair skills (`skill-repair`) close them.** The schema (frontmatter fields, severity, categories, lifecycle) is owned by `skills/skill-health/SKILL.md`; the end-to-end loop is documented in `docs/CORE.md`. Only active once `INDEX.md` exists.
Expand Down
2 changes: 2 additions & 0 deletions docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,8 @@ Set the secret → channel activates. No code changes needed.

Want ~1s Telegram replies instead of up-to-5-min polling? See [Telegram instant mode](../apps/webhook/README.md).

**Test runs on other branches stay quiet.** Only runs on the default branch deliver notifications; a skill dispatched on a test branch (e.g. `verify/*`) queues its message and drops it. Set the repo variable `NOTIFY_ON_BRANCH=1` to deliver from every branch while you test a card.

## Audit log

Every run writes an append-only JSON-lines record of the privileged actions that reached outside it - notify sends, `secretcurl` calls, and git pushes - and uploads it as the `audit-log-<run_id>` artifact on the run (kept 30 days). It answers "what did this run actually do" without reading the whole Actions transcript. One line per action:
Expand Down
13 changes: 11 additions & 2 deletions docs/langfuse.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,13 @@ when the run fails). It carries the trace name (`aeon:<component>:<skill>`), tag
`skill:<name>` and `component:<name>`, and one `usage <model>` GENERATION per
model with the run's real token counts from the envelope's `modelUsage`.
Langfuse prices those from its own model table, so traces, sessions and the cost
dashboards show real tokens and dollars. Claude Code's own `total_cost_usd` is
dashboards show real tokens and dollars. Each row sends
`langfuse.observation.usage_details` with the Langfuse price keys (`input`,
`output`, `input_cache_read`, `input_cache_creation_1h`, `input_cache_creation_5m`),
so 1-hour cache writes (what Claude Code on a subscription writes) get the 1h
price, 2x input, not the 5-minute 1.25x. The 1h/5m split comes from the
envelope's `usage.cache_creation`; with more than one model each gets the run's
1h share of its writes. Claude Code's own `total_cost_usd` is
kept only as `aeon.cost_usd_reported` (it misprices model ids it does not know).

The json-render feed convert (`scripts/notify-jsonrender.sh`) calls `claude -p`
Expand Down Expand Up @@ -105,7 +111,10 @@ first: rc 124 is `timeout`, `error_max_*` is `budget_cap`, API status 429 /
output of a run that left no envelope) goes through the shared rule table. That
text only picks the class and is never sent. The scorer root gets the same
fields. Tool spans come from Claude Code and carry no class. Its metadata
carries `permission_denials`, the number of tool calls the allowlist refused. Trace metadata also carries `instance` (repo name) and `trigger` (event).
carries `permission_denials`, the number of tool calls the allowlist refused. Trace metadata also carries `instance` (repo name) and `trigger`: `cron` for a
Scheduler dispatch (it passes the `trigger=cron` input), `chain` for a chain step
(chain-runner passes `trigger=chain`), `manual` for any other Run now, else the
event name.
The usage rows carry the same trace name and tags, so cost groups by skill.
They are zero-length at the end of the run and carry metadata `rollup=true`
(a per-run token rollup, not a model call), so per-model latency charts show only
Expand Down
1 change: 1 addition & 0 deletions harness-adapter/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ ones by name. Proven in CI against a fake model and a fake stdio server (fast,
| `--timeout <s>` | wall-clock guard (default 600) |
| `--no-sandbox` | skip the wrapper OS sandbox on read-only runs |
| `--no-compat-rules` | skip the Claude-idiom preamble on non-claude harnesses |
| `--no-tools` | claude: no tools at all (`--tools ""`), for one-shot text calls like the post-run scorer; other harnesses ignore it |

## How each layer is translated

Expand Down
2 changes: 2 additions & 0 deletions harness-adapter/adapters/claude.sh
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ command -v claude >/dev/null 2>&1 || {
ARGS=(-p - --output-format json --permission-mode default)
[ -n "${RH_MODEL:-}" ] && ARGS+=(--model "$RH_MODEL")
[ -n "${RH_ALLOWED_TOOLS:-}" ] && ARGS+=(--allowedTools "$RH_ALLOWED_TOOLS")
# --no-tools: no tool schemas in the prompt (a one-shot text call like the scorer).
[ "${RH_NO_TOOLS:-}" = 1 ] && ARGS+=(--tools "")
if [ -n "${RH_MCP_CONFIG:-}" ] && [ -f "${RH_MCP_CONFIG:-}" ]; then
ARGS+=(--mcp-config "$RH_MCP_CONFIG" --strict-mcp-config)
fi
Expand Down
19 changes: 16 additions & 3 deletions harness-adapter/lib/otel-span.sh
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,10 @@ rh_trace_end() {
elif $e.usage then [{m: $model, in: ($e.usage.input_tokens // 0), out: ($e.usage.output_tokens // 0),
cr: ($e.usage.cache_read_input_tokens // 0), cc: ($e.usage.cache_creation_input_tokens // 0)}]
else [] end) as $gens
# Cache-write TTL split. Only the top-level usage has it (modelUsage gives one
# cacheCreationInputTokens per model), so each model gets the 1h share of the run
# of its writes. Claude Code writes 1h caches, priced 2x input vs 1.25x for 5m.
| [($e.usage.cache_creation.ephemeral_1h_input_tokens // 0), ($e.usage.cache_creation.ephemeral_5m_input_tokens // 0)] as [$w1h, $w5m]
| {resourceSpans: [{
resource: {attributes: ([$ra[] | s(.k; .v)] + (if $rm["service.name"] then [] else [s("service.name"; $svc)] end))},
scopeSpans: [{scope: {name: "aeon.run-harness"}, spans: (
Expand All @@ -257,17 +261,26 @@ rh_trace_end() {
status: {code: (if $err then 2 else 1 end)}}
+ (if $parent != "" then {parentSpanId: $parent} else {} end)]
+ [$gens | to_entries[] | select(.key < ($gids | length)) | .key as $n | .value as $g
# langfuse.observation.usage_details wins over the gen_ai.usage.* counts
# in Langfuse and names the priced keys, so 1h cache writes get the 1h
# price (gen_ai.usage.* has one cache-write count, priced at the 5m
# rate). Without a TTL split it sends the one count as before.
| (if ($w1h + $w5m) > 0 and $g.cc > 0
then (($g.cc * $w1h / ($w1h + $w5m)) | round) as $c1h
| {input_cache_creation_1h: $c1h, input_cache_creation_5m: ($g.cc - $c1h)}
else {input_cache_creation: $g.cc} end) as $cw
# Zero-length at the end of the run: the row is a per-run token rollup,
# not a model call, so giving it the whole run duration skewed the
# per-model latency charts. metadata.rollup lets views filter it out.
| {traceId: $tid, spanId: $gids[$n], parentSpanId: $sid, name: ("usage " + $g.m), kind: 3,
startTimeUnixNano: $end, endTimeUnixNano: $end,
# Langfuse treats gen_ai.usage.input_tokens as cache-inclusive and
# subtracts the two cache counts from it; Anthropic reports input
# without cache, so send the sum to get the real input back.
# The gen_ai.usage.* counts stay for other OTLP backends. They read
# input_tokens as cache-inclusive and subtract the two cache counts;
# Anthropic reports input without cache, so send the sum.
attributes: ($lfa + [s("langfuse.observation.type"; "generation"), s("gen_ai.system"; "anthropic"),
s("langfuse.observation.metadata.rollup"; "true"),
s("gen_ai.request.model"; $g.m), s("gen_ai.response.model"; $g.m),
s("langfuse.observation.usage_details"; {input: $g.in, output: $g.out, input_cache_read: $g.cr} + $cw | tojson),
i("gen_ai.usage.input_tokens"; $g.in + $g.cr + $g.cc), i("gen_ai.usage.output_tokens"; $g.out),
i("gen_ai.usage.cache_read_input_tokens"; $g.cr), i("gen_ai.usage.cache_creation_input_tokens"; $g.cc)])}]
)}]}]}' > "$body" 2>/dev/null; then
Expand Down
6 changes: 4 additions & 2 deletions harness-adapter/run-harness
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
# --timeout <seconds> wall-clock guard (default 600)
# --no-sandbox skip the wrapper OS sandbox on read-only runs
# --no-compat-rules skip the Claude-idiom compatibility preamble on non-claude harnesses
# --no-tools claude: no tools at all (--tools ""), for one-shot text calls; others ignore it
#
# contract (stdout): {"result": "...", "usage": {input_tokens, output_tokens,
# cache_read_input_tokens, cache_creation_input_tokens}, [session_id], [total_cost_usd]}
Expand All @@ -34,7 +35,7 @@ HARNESS="$1"; shift
[ -f "$HERE/adapters/$HARNESS.sh" ] || { echo "unknown harness '$HARNESS'" >&2; usage; }

MODEL="" MODE="" ALLOWED="" MCP="" MAXT="" SCHEMA="" APPEND=""
TIMEOUT=600 SANDBOX=auto COMPAT=auto
TIMEOUT=600 SANDBOX=auto COMPAT=auto NOTOOLS=
while [ $# -gt 0 ]; do
case "$1" in
--model) MODEL="$2"; shift 2 ;;
Expand All @@ -47,6 +48,7 @@ while [ $# -gt 0 ]; do
--timeout) TIMEOUT="$2"; shift 2 ;;
--no-sandbox) SANDBOX=off; shift ;;
--no-compat-rules) COMPAT=off; shift ;;
--no-tools) NOTOOLS=1; shift ;;
--output-format) [ "$2" = "json" ] || { echo "only --output-format json is supported" >&2; exit 2; }; shift 2 ;;
*) echo "unknown option: $1" >&2; usage ;;
esac
Expand Down Expand Up @@ -114,7 +116,7 @@ export RH_HARNESS="$HARNESS"
export RH_MODEL="$MODEL" RH_MODE="$MODE" RH_ALLOWED_TOOLS="$ALLOWED"
export RH_MCP_CONFIG="$MCP" RH_MAX_TURNS="$MAXT" RH_JSON_SCHEMA="$SCHEMA"
export RH_APPEND_SYSTEM_PROMPT="$APPEND" RH_COMPAT_RULES="$COMPAT_RULES"
export RH_TIMEOUT="$TIMEOUT"
export RH_TIMEOUT="$TIMEOUT" RH_NO_TOOLS="$NOTOOLS"

CMD=(bash "$HERE/adapters/$HARNESS.sh")

Expand Down
7 changes: 5 additions & 2 deletions scripts/fleet-scorecard.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,10 @@ for (const repo of repos) {
// ---- pricing + formatters ----
// Anthropic list prices, $ per 1M tokens, keyed by model VERSION (a family regex
// alone mispriced every row: Opus 4.5+ is 5/25, not the old 15/75). Cache write
// is 1.25x input (5-minute TTL); cache read is the listed read price.
// is 2x input, the 1-hour TTL price: Claude Code on a Claude subscription writes
// 1h caches (usage.cache_creation.ephemeral_1h_input_tokens), and token-usage.csv
// has no TTL split. The 5-minute price (1.25x) priced every write 37.5% low.
// Cache read is the listed read price.
// Checked against models.dev (Anthropic rows) 2026-10-16.
// Ids are normalized first (vendor prefix dropped, trailing -YYYYMMDD dropped,
// dots to dashes), so claude-haiku-4-5-20251001, anthropic/claude-opus-5.5 and
Expand Down Expand Up @@ -144,7 +147,7 @@ function priceFor(model) {
if (family) row = CLAUDE_PRICES[CLAUDE_FAMILY_FALLBACK[family]];
}
if (!row) return null;
return { in: row.in / 1e6, out: row.out / 1e6, cw: (row.in * 1.25) / 1e6, cr: row.cr / 1e6 };
return { in: row.in / 1e6, out: row.out / 1e6, cw: (row.in * 2) / 1e6, cr: row.cr / 1e6 };
}
const commafy = (x) => Math.trunc(x).toLocaleString('en-US');
const hum = (n) => n >= 1e9 ? `${(n / 1e9).toFixed(2)}B` : n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${(n / 1e3).toFixed(1)}K` : `${Math.trunc(n)}`;
Expand Down
Loading
Loading