diff --git a/.claude/skills/gaia/references/plan.md b/.claude/skills/gaia/references/plan.md
index 6b9ab526..02bc8879 100644
--- a/.claude/skills/gaia/references/plan.md
+++ b/.claude/skills/gaia/references/plan.md
@@ -187,6 +187,20 @@ Then write the following files directly to `{PLAN_DIR}/`:
- **Phase findings ledger (`{PLAN_DIR}/SUMMARY.md`).** Append-only file the orchestrator maintains across the run, so sub-agent observations survive context compression. After each phase, the orchestrator appends a `## Phase N,
` block containing the phase's commit short-SHA and the merged `Notes for orchestrator` content from every sub-agent in that phase. If a phase produced no notes (all sub-agents reported only routine status), append the phase heading with `_No notes._` so the ledger reflects the full run. Sub-agents do not write to this file directly; the orchestrator owns it.
- **Stop conditions.** On any sub-agent failure or quality-gate failure: STOP and surface to the user. Do not "fix and continue", do not commit, do not push. Before stopping, append the failure context (which phase, which sub-agent, error) to `SUMMARY.md` under a `## Phase N, (HALTED)` block so the user and any follow-up session see the same record.
- **Final summary.** After all implementation phases pass and the final commit is pushed, before awaiting merge confirmation, **read `{PLAN_DIR}/SUMMARY.md`** and print a brief summary to the user: phases completed, sub-agents run, files touched (count), commits pushed (count + short SHAs), PR URL, quality-gate status, and the highest-signal findings/deviations/follow-ups drawn from `SUMMARY.md` so nothing is lost to context compression. Keep it tight, a few lines plus the surfaced notes, not a recap of every change.
+
+ **Token tally (execute-time).** After the pre-merge `code-review-audit`'s clean-pass marker is written and before the Final self-cleanup phase deletes the plan folder, the orchestrator runs the token tally for this KICKOFF execution and reports the printed four-bucket total and wall-clock elapsed to the user, so the run's dominant sub-agent fan-outs (including the pre-merge audit) are all counted, and the `--out-dir` still exists (the ledger itself lives in the main checkout and survives cleanup). Substitute the plan's real SPEC id (from the `## Source SPEC` section of `README.md`, or the plan slug if the plan has no SPEC), the real plan slug, and the absolute plan directory:
+
+ ```bash
+ if [ -x .gaia/scripts/token-tally.sh ]; then
+ bash .gaia/scripts/token-tally.sh \
+ --action execute \
+ --spec-id "" \
+ --plan-slug "" \
+ --out-dir "" || true
+ fi
+ ```
+
+ This attributes the whole execution session (main transcript plus every phase sub-agent sidecar, deduped to ground truth) to the plan, appends a durable ledger record keyed to the plan, and reports the tally to the user. It never blocks: the `-x` guard and trailing `|| true` mean a missing or failing helper degrades silently. Because the tally runs after the audit's sub-agent fan-out and counts every sidecar, the reported total is at least the sidecar-only sum.
- **Final self-cleanup phase (last step before merge).** After all implementation phases pass and the user has reviewed the PR and confirmed it is ready to merge, the orchestrator deletes its own plan folder so scaffolding does not persist locally. Delete it by its literal repo-relative path: `rm -rf .gaia/local/plans/` (substitute the plan's slug). The literal path matches the project's `rm -rf .gaia/local/plans/*` permission and the `block-rm-rf.sh` whitelist, so it clears without a prompt; do not reconstruct an absolute path from variables (`"$ROOT/$PLAN_REL"`), which both misses that permission match and trips the empty-variable rm guard. This removes `SUMMARY.md` along with everything else, by this point its content has already been surfaced in the Final summary. Then check `git check-ignore .gaia/local/plans/`, if it is gitignored (the GAIA default), the deletion is invisible to git: skip the commit and report "plan folder removed locally; gitignored, no commit needed." If the path is tracked, commit and push the deletion as the final commit on the PR. If the user explicitly asks to keep the plan folder for archival, the orchestrator skips the deletion and reports.
- **Post-merge worktree cleanup (worktree-mode runs only).** When the orchestrator's pre-flight chose worktree mode (or the run was dispatched into a worktree by upstream tooling), the post-merge phase runs the cleanup procedure below AFTER the user confirms the PR is merged. The procedure detects the squash-merge state and discards the worktree without prompting (the SPEC clarifications.answered confirms pre-consent: the orchestrator told the user "after merge, the worktree will be discarded" before opening the PR; the user merging the PR is the consent).
1. Confirm merge via `gh pr view --json state`. Parse the JSON; require `.state == "MERGED"`. If not merged, do NOT proceed, surface to user and stop.
@@ -367,9 +381,39 @@ Notes:
- The emit fires only when `SPEC_PATH` is set, because `plan_revised` requires `--spec-id`. Plans authored without a SPEC reference do not currently emit revision events; that surface lands when consumer-driven cloud event payloads ship.
- The emit is not gated on mentorship opt-in here; the CLI itself short-circuits for mentorship-disabled state and always emits the cloud projection.
+### 4.8. Token tally
+
+The plan folder is written and verified, so every planner/auditor sub-agent this action spawned has
+flushed its sidecar to disk. Tally the `/gaia-plan` session's ground-truth token cost before the
+handoff. The call sums `message.usage` across the main transcript AND every sub-agent sidecar
+(deduped by message id, so it equals what the API billed), appends one record keyed to the plan slug
+to the durable ledger resolved to the main checkout (so it survives deletion of the plan folder and
+a linked worktree), writes `tokens.md` into the plan folder, and prints the four billing buckets
+plus a total and the wall-clock elapsed. Derive the ledger key from the SPEC folder name (the parent
+dir of `SPEC_PATH`), falling back to the plan slug for a SPEC-less plan:
+
+```bash
+PLAN_SLUG="$(basename "$PLAN_DIR")"
+if [[ -n "${SPEC_PATH:-}" ]]; then
+ TALLY_SPEC_ID="$(basename "$(dirname "$SPEC_PATH")")" # -> SPEC-NNN
+else
+ TALLY_SPEC_ID="$PLAN_SLUG" # SPEC-less plan: key by slug
+fi
+bash .gaia/scripts/token-tally.sh \
+ --action plan \
+ --spec-id "$TALLY_SPEC_ID" \
+ --plan-slug "$PLAN_SLUG" \
+ --out-dir "$PLAN_DIR" || true
+```
+
+The helper always exits 0, and the trailing `|| true` is defense-in-depth: this **never blocks the
+handoff**, and unreadable input degrades to a partial figure with a marker rather than a fabricated
+number. It is a mechanical helper call, not a prompt, so it runs identically in interactive and auto
+(`AskUserQuestion`-less) mode. Surface the printed tally in the step-5 report to the user.
+
### 5. Report to user
-Output a short summary of what's in `$PLAN_DIR/`, then emit the copy-paste prompt the user drops into a fresh Claude Code session to start the orchestrator cold.
+Output a short summary of what's in `$PLAN_DIR/` (including the token tally printed in step 4.8), then emit the copy-paste prompt the user drops into a fresh Claude Code session to start the orchestrator cold.
The prompt is a single line, exactly:
diff --git a/.claude/skills/gaia/references/spec.md b/.claude/skills/gaia/references/spec.md
index 55039adc..5ec4272b 100644
--- a/.claude/skills/gaia/references/spec.md
+++ b/.claude/skills/gaia/references/spec.md
@@ -773,6 +773,19 @@ rm -f "$CACHE"
The emit is the strongest signal for the intent-clarity-gap pattern; the `|| true` guard ensures emit failures never block the save. The cache file is deleted unconditionally after the emit attempt so a re-saved SPEC starts a fresh session window.
+5. **Token tally (never blocks):** tally the session's ground-truth token cost and record it. `${SPEC_ID}`'s folder already exists from the canonical save, so `tokens.md` lands beside `SPEC.md`. This call never blocks or fails the save; on unreadable input it degrades to a partial figure with a marker, never a fabricated number.
+
+```bash
+bash .gaia/scripts/token-tally.sh \
+ --action spec \
+ --spec-id "$SPEC_ID" \
+ --out-dir ".gaia/local/specs/${SPEC_ID}" || true
+```
+
+The helper reads `CLAUDE_CODE_SESSION_ID` from the environment, sums `message.usage` across the main transcript and every sub-agent sidecar (deduped to ground truth), appends one record keyed to `SPEC_ID` to the durable ledger (`.gaia/local/telemetry/tokens.jsonl`, resolved to the main checkout so a worktree run still records there), writes `tokens.md` into the SPEC folder, and prints the four-bucket tally, total, and wall-clock elapsed. Surface the helper's printed tally to the user as part of the save confirmation, so the readout is reported when the action finishes.
+
+**Auto-mode:** the tally fires identically in interactive and auto mode; it is a mechanical helper call, not a user prompt, so no auto-mode branch is needed. In auto mode the printed tally simply lands in the transcript, nothing to prompt.
+
### 10. after_specify hook (immutability lint)
Spec-kit fires this hook automatically after the spec is written. The agent receives an `EXECUTE_COMMAND: speckit.gaia.lint` directive and invokes `/speckit-gaia-lint`, which runs `bash .specify/extensions/gaia/lib/lint.sh ` and surfaces findings.
@@ -821,7 +834,9 @@ where:
Print the handoff to the user as one cohesive block and stop: the status line, a `/clear`-and-paste instruction, then a single fenced code block whose contents are the full `/gaia-plan` invocation (command prefix included). Prepending `/gaia-plan ` makes the block a runnable command, not a bare argument, so the user copies exactly one thing:
-> SPEC-NNN saved to `.gaia/local/specs/SPEC-NNN/SPEC.md`. To plan it, /clear and paste this:
+> SPEC-NNN saved to `.gaia/local/specs/SPEC-NNN/SPEC.md`.
+>
+> To plan it, /clear and paste this:
>
> ```
> /gaia-plan SPEC-NNN: , see [, with adversarial audit at ]
diff --git a/.gaia/manifest.json b/.gaia/manifest.json
index 1bfbe2da..c296f0a6 100644
--- a/.gaia/manifest.json
+++ b/.gaia/manifest.json
@@ -173,6 +173,7 @@
".gaia/scripts/read-audit-ci-config.sh": "owned",
".gaia/scripts/red-ledger/extract-test-signals.mjs": "owned",
".gaia/scripts/red-ledger/README.md": "owned",
+ ".gaia/scripts/token-tally.sh": "owned",
".gaia/statusline/gaia-statusline.sh": "owned",
".gaia/templates/ci-issues/priority-critical-revert-failed.md": "owned",
".gaia/templates/ci-issues/priority-high-revert-opened.md": "owned",
diff --git a/.gaia/scripts/link-worktree.sh b/.gaia/scripts/link-worktree.sh
index 161b35d0..02805e03 100755
--- a/.gaia/scripts/link-worktree.sh
+++ b/.gaia/scripts/link-worktree.sh
@@ -1,14 +1,15 @@
#!/usr/bin/env bash
# GAIA worktree shared-state symlink hook (SPEC-005).
#
-# Creates four symlinks from the current linked worktree into the main
+# Creates five symlinks from the current linked worktree into the main
# checkout so gitignored shared state (setup-state.json, mentorship.json,
-# cache/, audit/) does not diverge per-worktree:
+# cache/, audit/, telemetry/) does not diverge per-worktree:
#
# /.gaia/local/setup-state.json -> /.gaia/local/setup-state.json
# /.gaia/local/mentorship.json -> /.gaia/local/mentorship.json
# /.gaia/cache/ -> /.gaia/cache/
# /.gaia/local/audit/ -> /.gaia/local/audit/
+# /.gaia/local/telemetry/ -> /.gaia/local/telemetry/
#
# Behavior:
# - Idempotent: re-running on an already-linked worktree is a no-op.
@@ -64,6 +65,7 @@ ts="$(date +%Y%m%d-%H%M%S)"
# ---------- ensure main-side targets exist (so symlinks don't dangle) ----------
mkdir -p "$main_root/.gaia/local" 2>/dev/null
mkdir -p "$main_root/.gaia/local/audit" 2>/dev/null
+mkdir -p "$main_root/.gaia/local/telemetry" 2>/dev/null
mkdir -p "$main_root/.gaia/cache" 2>/dev/null
# `setup-state.json` and `mentorship.json` are files: do NOT pre-create them.
# If one doesn't exist on main, the symlink will dangle until the main checkout
@@ -131,5 +133,6 @@ link_one ".gaia/local/setup-state.json" ".gaia/local"
link_one ".gaia/local/mentorship.json" ".gaia/local"
link_one ".gaia/cache" ".gaia"
link_one ".gaia/local/audit" ".gaia/local"
+link_one ".gaia/local/telemetry" ".gaia/local"
exit 0
diff --git a/.gaia/scripts/tests/fixtures/token-tally/malformed/projects/proj-hash-m/fixturemalformed0001.jsonl b/.gaia/scripts/tests/fixtures/token-tally/malformed/projects/proj-hash-m/fixturemalformed0001.jsonl
new file mode 100644
index 00000000..5cd7d125
--- /dev/null
+++ b/.gaia/scripts/tests/fixtures/token-tally/malformed/projects/proj-hash-m/fixturemalformed0001.jsonl
@@ -0,0 +1 @@
+{"type":"assistant","uuid":"u-mal1","timestamp":"not-a-timestamp","message":{"id":"mal1","role":"assistant","usage":{"input_tokens":11,"cache_creation_input_tokens":22,"cache_read_input_tokens":33,"output_tokens":44}}}
diff --git a/.gaia/scripts/tests/fixtures/token-tally/projects/proj-hash-a/fixturesession0001.jsonl b/.gaia/scripts/tests/fixtures/token-tally/projects/proj-hash-a/fixturesession0001.jsonl
new file mode 100644
index 00000000..349c8cde
--- /dev/null
+++ b/.gaia/scripts/tests/fixtures/token-tally/projects/proj-hash-a/fixturesession0001.jsonl
@@ -0,0 +1,6 @@
+{"type":"user","uuid":"u-user-0","timestamp":"2026-07-02T16:59:30.000Z","message":{"role":"user","content":"kickoff prompt (decoy: precedes the first billed model turn)"}}
+{"type":"assistant","uuid":"u-m1-a","timestamp":"2026-07-02T17:00:00.000Z","message":{"id":"m1","role":"assistant","usage":{"input_tokens":10,"cache_creation_input_tokens":100,"cache_read_input_tokens":1000,"output_tokens":1}}}
+{"type":"assistant","uuid":"u-m1-b","timestamp":"2026-07-02T17:00:00.500Z","message":{"id":"m1","role":"assistant","usage":{"input_tokens":10,"cache_creation_input_tokens":100,"cache_read_input_tokens":1000,"output_tokens":1}}}
+{"type":"assistant","uuid":"u-m1-c","timestamp":"2026-07-02T17:00:01.000Z","message":{"id":"m1","role":"assistant","usage":{"input_tokens":10,"cache_creation_input_tokens":100,"cache_read_input_tokens":1000,"output_tokens":1}}}
+{"type":"assistant","uuid":"u-m2-a","timestamp":"2026-07-02T17:00:30.000Z","message":{"id":"m2","role":"assistant","usage":{"input_tokens":20,"cache_creation_input_tokens":200,"cache_read_input_tokens":2000,"output_tokens":2}}}
+{"type":"pr-link","uuid":"u-prlink-0","timestamp":"2026-07-02T17:10:00.000Z","prUrl":"https://example.com/pr/1"}
diff --git a/.gaia/scripts/tests/fixtures/token-tally/projects/proj-hash-a/fixturesession0001/subagents/agent-0001.jsonl b/.gaia/scripts/tests/fixtures/token-tally/projects/proj-hash-a/fixturesession0001/subagents/agent-0001.jsonl
new file mode 100644
index 00000000..b05a2bb2
--- /dev/null
+++ b/.gaia/scripts/tests/fixtures/token-tally/projects/proj-hash-a/fixturesession0001/subagents/agent-0001.jsonl
@@ -0,0 +1 @@
+{"type":"assistant","uuid":"u-m3-a","timestamp":"2026-07-02T17:01:00.000Z","message":{"id":"m3","role":"assistant","usage":{"input_tokens":30,"cache_creation_input_tokens":300,"cache_read_input_tokens":3000,"output_tokens":3}}}
diff --git a/.gaia/scripts/tests/fixtures/token-tally/projects/proj-hash-a/fixturesession0001/subagents/agent-0001.meta.json b/.gaia/scripts/tests/fixtures/token-tally/projects/proj-hash-a/fixturesession0001/subagents/agent-0001.meta.json
new file mode 100644
index 00000000..ff1358da
--- /dev/null
+++ b/.gaia/scripts/tests/fixtures/token-tally/projects/proj-hash-a/fixturesession0001/subagents/agent-0001.meta.json
@@ -0,0 +1 @@
+{"type":"assistant","uuid":"u-meta-decoy","timestamp":"2026-07-02T17:01:30.000Z","message":{"id":"meta-decoy","role":"assistant","usage":{"input_tokens":999999,"cache_creation_input_tokens":999999,"cache_read_input_tokens":999999,"output_tokens":999999}}}
diff --git a/.gaia/scripts/tests/fixtures/token-tally/projects/proj-hash-a/fixturesession0001/subagents/agent-0002.jsonl b/.gaia/scripts/tests/fixtures/token-tally/projects/proj-hash-a/fixturesession0001/subagents/agent-0002.jsonl
new file mode 100644
index 00000000..a1c5d3bd
--- /dev/null
+++ b/.gaia/scripts/tests/fixtures/token-tally/projects/proj-hash-a/fixturesession0001/subagents/agent-0002.jsonl
@@ -0,0 +1 @@
+{"type":"assistant","uuid":"u-m4-a","timestamp":"2026-07-02T17:02:05.000Z","message":{"id":"m4","role":"assistant","usage":{"input_tokens":40,"cache_creation_input_tokens":400,"cache_read_input_tokens":4000,"output_tokens":4}}}
diff --git a/.gaia/scripts/tests/fixtures/token-tally/single/projects/proj-hash-s/fixturesingle0001.jsonl b/.gaia/scripts/tests/fixtures/token-tally/single/projects/proj-hash-s/fixturesingle0001.jsonl
new file mode 100644
index 00000000..29c07b7a
--- /dev/null
+++ b/.gaia/scripts/tests/fixtures/token-tally/single/projects/proj-hash-s/fixturesingle0001.jsonl
@@ -0,0 +1 @@
+{"type":"assistant","uuid":"u-s1","timestamp":"2026-07-02T18:00:00.000Z","message":{"id":"s1","role":"assistant","usage":{"input_tokens":5,"cache_creation_input_tokens":50,"cache_read_input_tokens":500,"output_tokens":7}}}
diff --git a/.gaia/scripts/tests/fixtures/token-tally/zero/projects/proj-hash-z/fixturezero0001.jsonl b/.gaia/scripts/tests/fixtures/token-tally/zero/projects/proj-hash-z/fixturezero0001.jsonl
new file mode 100644
index 00000000..a1378481
--- /dev/null
+++ b/.gaia/scripts/tests/fixtures/token-tally/zero/projects/proj-hash-z/fixturezero0001.jsonl
@@ -0,0 +1 @@
+{"type":"user","uuid":"u-z1","timestamp":"2026-07-02T18:05:00.000Z","message":{"role":"user","content":"a timestamped non-usage line; no billed model turn in this session"}}
diff --git a/.gaia/scripts/tests/link-worktree.bats b/.gaia/scripts/tests/link-worktree.bats
index f3905daf..b5ac0126 100644
--- a/.gaia/scripts/tests/link-worktree.bats
+++ b/.gaia/scripts/tests/link-worktree.bats
@@ -47,22 +47,25 @@ run_in() {
}
# ---------- 1. Fresh worktree, no pre-existing files ----------
-@test "fresh worktree: creates all three symlinks" {
+@test "fresh worktree: creates all shared symlinks" {
run run_in "$LINKED"
[ "$status" -eq 0 ]
[ -L "$LINKED/.gaia/local/setup-state.json" ]
[ -L "$LINKED/.gaia/cache" ]
[ -L "$LINKED/.gaia/local/audit" ]
+ [ -L "$LINKED/.gaia/local/telemetry" ]
# Targets are absolute paths into MAIN.
[ "$(readlink "$LINKED/.gaia/local/setup-state.json")" = "$MAIN/.gaia/local/setup-state.json" ]
[ "$(readlink "$LINKED/.gaia/cache")" = "$MAIN/.gaia/cache" ]
[ "$(readlink "$LINKED/.gaia/local/audit")" = "$MAIN/.gaia/local/audit" ]
+ [ "$(readlink "$LINKED/.gaia/local/telemetry")" = "$MAIN/.gaia/local/telemetry" ]
# Each "linked:" log appears once on stderr.
[[ "$output" == *"linked: $LINKED/.gaia/local/setup-state.json"* ]]
[[ "$output" == *"linked: $LINKED/.gaia/cache"* ]]
[[ "$output" == *"linked: $LINKED/.gaia/local/audit"* ]]
+ [[ "$output" == *"linked: $LINKED/.gaia/local/telemetry"* ]]
}
# ---------- 2. Already-linked worktree (idempotent) ----------
@@ -71,20 +74,22 @@ run_in() {
run run_in "$LINKED"
[ "$status" -eq 0 ]
- # All three symlinks still present.
+ # All shared symlinks still present.
[ -L "$LINKED/.gaia/local/setup-state.json" ]
[ -L "$LINKED/.gaia/cache" ]
[ -L "$LINKED/.gaia/local/audit" ]
+ [ -L "$LINKED/.gaia/local/telemetry" ]
# No backup files anywhere.
run bash -c "find '$LINKED' -name '*.bak.*' -print"
[ -z "$output" ]
- # All three logged "already-linked".
+ # All shared paths logged "already-linked".
run run_in "$LINKED"
[[ "$output" == *"already-linked: $LINKED/.gaia/local/setup-state.json"* ]]
[[ "$output" == *"already-linked: $LINKED/.gaia/cache"* ]]
[[ "$output" == *"already-linked: $LINKED/.gaia/local/audit"* ]]
+ [[ "$output" == *"already-linked: $LINKED/.gaia/local/telemetry"* ]]
}
# ---------- 3. Worktree with pre-existing plain files ----------
@@ -101,13 +106,18 @@ run_in() {
mkdir -p "$LINKED/.gaia/local/audit"
printf 'plain-audit-content' > "$LINKED/.gaia/local/audit/marker.txt"
+ # Create a plain telemetry/ directory with content.
+ mkdir -p "$LINKED/.gaia/local/telemetry"
+ printf 'plain-telemetry-content' > "$LINKED/.gaia/local/telemetry/marker.txt"
+
run run_in "$LINKED"
[ "$status" -eq 0 ]
- # All three are now symlinks.
+ # All are now symlinks.
[ -L "$LINKED/.gaia/local/setup-state.json" ]
[ -L "$LINKED/.gaia/cache" ]
[ -L "$LINKED/.gaia/local/audit" ]
+ [ -L "$LINKED/.gaia/local/telemetry" ]
# Backups exist with the original content preserved.
setup_bak="$(ls "$LINKED/.gaia/local/" | grep '^setup-state\.json\.bak\.')"
@@ -122,10 +132,15 @@ run_in() {
[ -n "$audit_bak" ]
[ "$(cat "$LINKED/.gaia/local/$audit_bak/marker.txt")" = "plain-audit-content" ]
- # All three logged "linked-after-backup".
+ telemetry_bak="$(ls "$LINKED/.gaia/local/" | grep '^telemetry\.bak\.')"
+ [ -n "$telemetry_bak" ]
+ [ "$(cat "$LINKED/.gaia/local/$telemetry_bak/marker.txt")" = "plain-telemetry-content" ]
+
+ # All logged "linked-after-backup".
[[ "$output" == *"linked-after-backup: $LINKED/.gaia/local/setup-state.json"* ]]
[[ "$output" == *"linked-after-backup: $LINKED/.gaia/cache"* ]]
[[ "$output" == *"linked-after-backup: $LINKED/.gaia/local/audit"* ]]
+ [[ "$output" == *"linked-after-backup: $LINKED/.gaia/local/telemetry"* ]]
}
# ---------- 4. Worktree with broken symlink (target does not exist) ----------
@@ -173,6 +188,7 @@ run_in() {
# Main-side targets now exist.
[ -d "$MAIN/.gaia/local" ]
[ -d "$MAIN/.gaia/local/audit" ]
+ [ -d "$MAIN/.gaia/local/telemetry" ]
[ -d "$MAIN/.gaia/cache" ]
# Symlinks resolve (no dangling).
@@ -180,6 +196,8 @@ run_in() {
[ -d "$LINKED/.gaia/cache" ] # follows symlink: dir exists.
[ -L "$LINKED/.gaia/local/audit" ]
[ -d "$LINKED/.gaia/local/audit" ]
+ [ -L "$LINKED/.gaia/local/telemetry" ]
+ [ -d "$LINKED/.gaia/local/telemetry" ]
}
# ---------- 7. Symlink-permission failure (simulated) ----------
@@ -216,3 +234,19 @@ FAKE
[ "$status" -eq 0 ]
[[ "$output" == *"not a git repo"* ]]
}
+
+# ---------- 9. Telemetry ledger durability (write-through to main) ----------
+@test "telemetry: a ledger write on the worktree side lands in the main checkout" {
+ run run_in "$LINKED"
+ [ "$status" -eq 0 ]
+
+ # The telemetry dir is a symlink into MAIN.
+ [ -L "$LINKED/.gaia/local/telemetry" ]
+ [ "$(readlink "$LINKED/.gaia/local/telemetry")" = "$MAIN/.gaia/local/telemetry" ]
+
+ # A ledger append on the worktree side is visible in the main checkout, so a
+ # worktree KICKOFF run records to the surviving main ledger (SPEC-013 UAT-008).
+ printf '%s\n' '{"action":"execute","total":42}' >> "$LINKED/.gaia/local/telemetry/tokens.jsonl"
+ [ -f "$MAIN/.gaia/local/telemetry/tokens.jsonl" ]
+ [ "$(cat "$MAIN/.gaia/local/telemetry/tokens.jsonl")" = '{"action":"execute","total":42}' ]
+}
diff --git a/.gaia/scripts/tests/token-tally.bats b/.gaia/scripts/tests/token-tally.bats
new file mode 100644
index 00000000..5ca661ba
--- /dev/null
+++ b/.gaia/scripts/tests/token-tally.bats
@@ -0,0 +1,367 @@
+#!/usr/bin/env bats
+#
+# Bats suite for .gaia/scripts/token-tally.sh (SPEC-013 task-tally-helper).
+#
+# The anchor fixture under fixtures/token-tally/ is the non-circular oracle
+# (UAT-006): its per-bucket sums and duration window are HAND-COMPUTED below,
+# never derived by running the helper.
+#
+# Anchor fixture (session fixturesession0001), hand-computed:
+# messages (deduped by message.id): m1 (main, streamed 3x -> counted once),
+# m2 (main), m3 (agent-0001 sidecar), m4 (agent-0002 sidecar)
+# m1 usage: fresh=10 cwrite=100 cread=1000 out=1
+# m2 usage: fresh=20 cwrite=200 cread=2000 out=2
+# m3 usage: fresh=30 cwrite=300 cread=3000 out=3
+# m4 usage: fresh=40 cwrite=400 cread=4000 out=4
+# expected buckets: fresh=100 cwrite=1000 cread=10000 out=10 total=11110
+# (four non-zero, mutually distinct -> UAT-004; hardcoded-zero / collapsed /
+# wrong-field-mapping helpers all fail)
+# main-only sub-sum (m1+m2) = 30 / 300 / 3000 / 3 -> 3333
+# sidecar-only sub-sum(m3+m4) = 70 / 700 / 7000 / 7 -> 7777 (total >= this)
+# naive UNDEDUPED total would count m1 3x -> 11110 + 2*1111 = 13332 (!= 11110)
+# the agent-0001.meta.json decoy carries usage 999999 in every field; it must
+# be ignored (the agent-*.jsonl glob excludes it).
+#
+# Duration (PL-001), hand-computed over the usage-bearing lines only:
+# started_at 2026-07-02T17:00:00.000Z (m1 first streaming line, GLOBAL MIN)
+# ended_at 2026-07-02T17:02:05.000Z (m4 in a SIDECAR, GLOBAL MAX)
+# duration_seconds 125 duration_available true human "2m5s"
+# decoys excluded: user line 16:59:30 (before min), pr-link 17:10:00 (after max)
+# regression traps: all-lines->630s, main-only->30s, deduped-min->124s
+# the ledger records the raw UTC endpoints; the human surfaces (stdout,
+# tokens.md) render them in the machine's LOCAL zone (proven below under a
+# pinned TZ=UTC baseline and a TZ=JST-9 non-UTC conversion)
+#
+# Degradation fixtures (isolated trees):
+# single/ -> one usage line, valid ts -> duration 0, available true
+# zero/ -> one timestamped non-usage line -> available false, null, buckets 0, partial false
+# malformed/ -> one usage line, unparseable ts -> available false, buckets 11/22/33/44, partial false
+
+setup() {
+ SCRIPT_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)"
+ SCRIPT="$SCRIPT_DIR/token-tally.sh"
+ FIX="$(cd "$(dirname "$BATS_TEST_FILENAME")/fixtures/token-tally" && pwd)"
+
+ ANCHOR="$FIX/projects"
+ SESSION="fixturesession0001"
+
+ OUTDIR="$BATS_TEST_TMPDIR/out"
+ LEDGER="$BATS_TEST_TMPDIR/ledger.jsonl"
+
+ # Git identity for the worktree sandbox (CI without a configured user).
+ export GIT_AUTHOR_NAME="GAIA Test"
+ export GIT_AUTHOR_EMAIL="gaia-test@example.com"
+ export GIT_COMMITTER_NAME="GAIA Test"
+ export GIT_COMMITTER_EMAIL="gaia-test@example.com"
+}
+
+# Run the helper against the anchor fixture with an ISOLATED ledger (never the
+# machine's real .gaia/local/telemetry/tokens.jsonl).
+run_anchor() {
+ run bash "$SCRIPT" \
+ --action execute --spec-id SPEC-013 --plan-slug spec-013-token-accounting \
+ --out-dir "$OUTDIR" --session-id "$SESSION" \
+ --projects-root "$ANCHOR" --ledger "$LEDGER" "$@"
+}
+
+led() { jq -r "$1" "$LEDGER"; }
+
+# ---------- 1. UAT-006 exact sums + UAT-004 non-zero, distinct ----------
+@test "anchor: exact per-bucket sums across main + sidecars (UAT-006/UAT-004)" {
+ run_anchor
+ [ "$status" -eq 0 ]
+
+ # stdout carries the four buckets + total.
+ [[ "$output" == *"Fresh input: 100"* ]]
+ [[ "$output" == *"Cache write: 1000"* ]]
+ [[ "$output" == *"Cache read: 10000"* ]]
+ [[ "$output" == *"Output: 10"* ]]
+ [[ "$output" == *"Total: 11110"* ]]
+
+ # Ledger record carries the same five figures.
+ [ "$(led '.buckets.fresh_input')" -eq 100 ]
+ [ "$(led '.buckets.cache_write')" -eq 1000 ]
+ [ "$(led '.buckets.cache_read')" -eq 10000 ]
+ [ "$(led '.buckets.output')" -eq 10 ]
+ [ "$(led '.total')" -eq 11110 ]
+
+ # tokens.md carries the same five figures.
+ grep -q "| Fresh input | 100 |" "$OUTDIR/tokens.md"
+ grep -q "| Cache write | 1000 |" "$OUTDIR/tokens.md"
+ grep -q "| Cache read | 10000 |" "$OUTDIR/tokens.md"
+ grep -q "| Output | 10 |" "$OUTDIR/tokens.md"
+ grep -q "| \*\*Total\*\* | 11110 |" "$OUTDIR/tokens.md"
+
+ # UAT-004: every bucket non-zero and mutually distinct (kills hardcoded-zero,
+ # single-collapsed-input, and wrong-field-mapping helpers).
+ f="$(led '.buckets.fresh_input')"; w="$(led '.buckets.cache_write')"
+ r="$(led '.buckets.cache_read')"; o="$(led '.buckets.output')"
+ for v in "$f" "$w" "$r" "$o"; do [ "$v" -ne 0 ]; done
+ [ "$f" -ne "$w" ]; [ "$f" -ne "$r" ]; [ "$f" -ne "$o" ]
+ [ "$w" -ne "$r" ]; [ "$w" -ne "$o" ]; [ "$r" -ne "$o" ]
+}
+
+# ---------- 2. Dedup (documented) ----------
+@test "anchor: dedups streamed message.id (output=10 deduped, not 12 naive)" {
+ run_anchor
+ [ "$status" -eq 0 ]
+ # m1 is streamed 3x with identical usage; deduped output is 1+2+3+4 = 10.
+ # A naive per-line sum would count m1 three times -> output 12, total 13332.
+ [ "$(led '.buckets.output')" -eq 10 ]
+ [ "$(led '.total')" -eq 11110 ]
+ [ "$(led '.total')" -ne 13332 ]
+}
+
+# ---------- 3. Sidecar inclusion (UAT-003) ----------
+@test "anchor: total includes sidecars (>= sidecar-only, > main-only)" {
+ run_anchor
+ [ "$status" -eq 0 ]
+ total="$(led '.total')"
+ # hand-computed sub-sums: main-only 3333, sidecar-only 7777
+ [ "$total" -ge 7777 ] # >= sidecar-only sum (UAT-003)
+ [ "$total" -gt 3333 ] # strictly > main-only sum -> sidecars contributed
+ [ "$total" -eq 11110 ] # = 3333 + 7777
+}
+
+# ---------- 4. agent-*.meta.json excluded ----------
+@test "meta.json sibling is never read (adding one does not change the tally)" {
+ cp -R "$ANCHOR" "$BATS_TEST_TMPDIR/projcopy"
+ sub="$BATS_TEST_TMPDIR/projcopy/proj-hash-a/$SESSION/subagents"
+
+ run bash "$SCRIPT" --action execute --spec-id SPEC-013 --plan-slug slug \
+ --out-dir "$OUTDIR" --session-id "$SESSION" \
+ --projects-root "$BATS_TEST_TMPDIR/projcopy" --ledger "$BATS_TEST_TMPDIR/l1.jsonl"
+ [ "$status" -eq 0 ]
+ before="$(jq -r '.total' "$BATS_TEST_TMPDIR/l1.jsonl")"
+
+ # Add a second .meta.json full of usage; the glob must still ignore it.
+ printf '%s\n' '{"type":"assistant","uuid":"x","message":{"id":"meta2","usage":{"input_tokens":999999,"cache_creation_input_tokens":999999,"cache_read_input_tokens":999999,"output_tokens":999999}}}' \
+ > "$sub/agent-9999.meta.json"
+
+ run bash "$SCRIPT" --action execute --spec-id SPEC-013 --plan-slug slug \
+ --out-dir "$OUTDIR" --session-id "$SESSION" \
+ --projects-root "$BATS_TEST_TMPDIR/projcopy" --ledger "$BATS_TEST_TMPDIR/l2.jsonl"
+ [ "$status" -eq 0 ]
+ after="$(jq -r '.total' "$BATS_TEST_TMPDIR/l2.jsonl")"
+
+ [ "$before" -eq 11110 ]
+ [ "$after" -eq "$before" ]
+}
+
+# ---------- 5. tokens.md content (UAT-001) ----------
+@test "tokens.md exists in --out-dir with all five figures (not empty/placeholder)" {
+ run_anchor
+ [ "$status" -eq 0 ]
+ [ -f "$OUTDIR/tokens.md" ]
+ [ -s "$OUTDIR/tokens.md" ]
+ # all five figures present
+ grep -q "100" "$OUTDIR/tokens.md"
+ grep -q "1000" "$OUTDIR/tokens.md"
+ grep -q "10000" "$OUTDIR/tokens.md"
+ grep -q " 10 " "$OUTDIR/tokens.md"
+ grep -q "11110" "$OUTDIR/tokens.md"
+}
+
+# ---------- 6. Ledger keyed + durable (UAT-005) ----------
+@test "ledger record is valid JSON, keyed, and survives out-dir (plan folder) deletion" {
+ run bash "$SCRIPT" \
+ --action plan --spec-id SPEC-013 --plan-slug spec-013-token-accounting \
+ --out-dir "$OUTDIR" --session-id "$SESSION" \
+ --projects-root "$ANCHOR" --ledger "$BATS_TEST_TMPDIR/durable.jsonl"
+ [ "$status" -eq 0 ]
+
+ # exactly one valid JSON line with the right key fields
+ [ "$(wc -l < "$BATS_TEST_TMPDIR/durable.jsonl")" -eq 1 ]
+ run jq -e . "$BATS_TEST_TMPDIR/durable.jsonl"
+ [ "$status" -eq 0 ]
+ [ "$(jq -r '.action' "$BATS_TEST_TMPDIR/durable.jsonl")" = "plan" ]
+ [ "$(jq -r '.spec_id' "$BATS_TEST_TMPDIR/durable.jsonl")" = "SPEC-013" ]
+ [ "$(jq -r '.plan_slug' "$BATS_TEST_TMPDIR/durable.jsonl")" = "spec-013-token-accounting" ]
+ [ "$(jq -r '.total' "$BATS_TEST_TMPDIR/durable.jsonl")" -eq 11110 ]
+
+ # deleting the plan folder (out-dir) leaves the ledger record intact (UAT-005)
+ rm -rf "$OUTDIR"
+ [ -f "$BATS_TEST_TMPDIR/durable.jsonl" ]
+ [ "$(jq -r '.total' "$BATS_TEST_TMPDIR/durable.jsonl")" -eq 11110 ]
+}
+
+# ---------- 7. Ledger -> main checkout under a worktree (UAT-008) ----------
+# Uses the REAL default ledger resolution (no --ledger), entirely inside a
+# throwaway tmp git repo, so the "real" ledger is the tmp repo's, not the machine's.
+@test "worktree run writes the ledger to the main checkout, not the worktree" {
+ MAIN="$(cd "$BATS_TEST_TMPDIR" && pwd -P)/main"
+ WT="$(cd "$BATS_TEST_TMPDIR" && pwd -P)/wt"
+ mkdir -p "$MAIN"
+ git -C "$MAIN" init -q
+ git -C "$MAIN" commit --allow-empty -q -m "init"
+ git -C "$MAIN" worktree add -q "$WT" -b "feature/kickoff"
+
+ run bash -c "cd '$WT' && bash '$SCRIPT' \
+ --action execute --spec-id SPEC-013 --plan-slug spec-013-token-accounting \
+ --out-dir '$WT/out' --session-id '$SESSION' --projects-root '$ANCHOR'"
+ [ "$status" -eq 0 ]
+
+ # Ledger landed in the MAIN checkout ...
+ [ -f "$MAIN/.gaia/local/telemetry/tokens.jsonl" ]
+ [ "$(jq -r '.total' "$MAIN/.gaia/local/telemetry/tokens.jsonl")" -eq 11110 ]
+ # ... and NOT under the worktree.
+ [ ! -f "$WT/.gaia/local/telemetry/tokens.jsonl" ]
+
+ # Ledger survives worktree removal (UAT-008 durability).
+ git -C "$MAIN" worktree remove --force "$WT" 2>/dev/null || rm -rf "$WT"
+ [ -f "$MAIN/.gaia/local/telemetry/tokens.jsonl" ]
+}
+
+# ---------- 8. Graceful degradation (UAT-007) ----------
+@test "non-existent session: exit 0, partial, buckets 0, marker in all three surfaces" {
+ run bash "$SCRIPT" \
+ --action spec --spec-id SPEC-013 \
+ --out-dir "$OUTDIR" --session-id "no-such-session-9999" \
+ --projects-root "$ANCHOR" --ledger "$LEDGER"
+ [ "$status" -eq 0 ]
+
+ # stdout marker + zero buckets
+ [[ "$output" == *"(partial: figures are a lower bound"* ]]
+ [[ "$output" == *"Total: 0"* ]]
+
+ # ledger partial:true, buckets 0
+ [ "$(led '.partial')" = "true" ]
+ [ "$(led '.total')" -eq 0 ]
+
+ # tokens.md marker present
+ grep -q "_Partial:" "$OUTDIR/tokens.md"
+}
+
+@test "malformed sidecar line: exit 0, partial, still tallies the readable files" {
+ cp -R "$ANCHOR" "$BATS_TEST_TMPDIR/projcopy"
+ sub="$BATS_TEST_TMPDIR/projcopy/proj-hash-a/$SESSION/subagents"
+ # A NEW sidecar whose line is not valid JSON: it must flip partial and
+ # contribute nothing, while the good main + sidecars still sum to 11110.
+ printf '%s\n' 'this is not json {{{' > "$sub/agent-0009.jsonl"
+
+ run bash "$SCRIPT" --action execute --spec-id SPEC-013 --plan-slug slug \
+ --out-dir "$OUTDIR" --session-id "$SESSION" \
+ --projects-root "$BATS_TEST_TMPDIR/projcopy" --ledger "$LEDGER"
+ [ "$status" -eq 0 ]
+ [ "$(led '.partial')" = "true" ]
+ [ "$(led '.total')" -eq 11110 ] # readable files still tallied, nothing fabricated
+}
+
+@test "empty session id (no --session-id, unset env): exit 0, partial, no crash" {
+ run env -u CLAUDE_CODE_SESSION_ID bash "$SCRIPT" \
+ --action spec --spec-id SPEC-013 \
+ --out-dir "$OUTDIR" --projects-root "$ANCHOR" --ledger "$LEDGER"
+ [ "$status" -eq 0 ]
+ [ "$(led '.partial')" = "true" ]
+ [ "$(led '.total')" -eq 0 ]
+}
+
+# ---------- 10. Elapsed exact span (PL-001) ----------
+# The ledger records the raw UTC endpoints (timezone-independent); the human
+# surfaces render them in the machine's LOCAL zone. Pinned to TZ=UTC here so the
+# local display is deterministic (UTC clock, labelled UTC); the conversion to a
+# non-UTC zone is proven in the next test.
+@test "anchor: elapsed span 125s/2m5s, ledger raw UTC, human surfaces local (TZ=UTC)" {
+ run env TZ=UTC bash "$SCRIPT" \
+ --action execute --spec-id SPEC-013 --plan-slug spec-013-token-accounting \
+ --out-dir "$OUTDIR" --session-id "$SESSION" \
+ --projects-root "$ANCHOR" --ledger "$LEDGER"
+ [ "$status" -eq 0 ]
+
+ # ledger: raw UTC endpoints (durable machine record)
+ [ "$(led '.duration_seconds')" -eq 125 ]
+ [ "$(led '.duration_available')" = "true" ]
+ [ "$(led '.started_at')" = "2026-07-02T17:00:00.000Z" ]
+ [ "$(led '.ended_at')" = "2026-07-02T17:02:05.000Z" ]
+
+ # stdout pinned human format + LOCAL window (kills 0, ms=125000, all-lines=630s,
+ # main-only=30s, deduped-min=124s). TZ=UTC -> local clock == UTC, labelled UTC.
+ [[ "$output" == *"Elapsed: 2m5s (first to last model turn: 2026-07-02 17:00:00 UTC to 2026-07-02 17:02:05 UTC)"* ]]
+
+ # tokens.md elapsed line (below the total row, not a table row)
+ grep -q "^\*\*Elapsed (first to last model turn):\*\* 2m5s (2026-07-02 17:00:00 UTC to 2026-07-02 17:02:05 UTC)$" "$OUTDIR/tokens.md"
+}
+
+# ---------- 10b. Endpoints render in the machine's LOCAL zone (owner request) ----------
+# Under a fixed non-UTC, non-DST zone (JST = UTC+9, POSIX "JST-9" so no tzdata is
+# required), the human surfaces show the endpoints converted to local time,
+# crossing midnight (17:00Z -> next-day 02:00 JST), while the ledger still stores
+# the raw UTC. Proves the display applies the system zone rather than hardcoding
+# UTC, and that the span (125s) is timezone-independent.
+@test "human surfaces render endpoints in the local zone; ledger stays UTC (TZ=JST-9)" {
+ run env TZ=JST-9 bash "$SCRIPT" \
+ --action execute --spec-id SPEC-013 --plan-slug spec-013-token-accounting \
+ --out-dir "$OUTDIR" --session-id "$SESSION" \
+ --projects-root "$ANCHOR" --ledger "$LEDGER"
+ [ "$status" -eq 0 ]
+
+ # ledger unchanged: raw UTC, span unchanged
+ [ "$(led '.started_at')" = "2026-07-02T17:00:00.000Z" ]
+ [ "$(led '.ended_at')" = "2026-07-02T17:02:05.000Z" ]
+ [ "$(led '.duration_seconds')" -eq 125 ]
+
+ # human surfaces: +0900 local, crossing midnight into 2026-07-03
+ [[ "$output" == *"first to last model turn: 2026-07-03 02:00:00 JST to 2026-07-03 02:02:05 JST"* ]]
+ [[ "$output" == *"Elapsed: 2m5s"* ]]
+ grep -q "^\*\*Elapsed (first to last model turn):\*\* 2m5s (2026-07-03 02:00:00 JST to 2026-07-03 02:02:05 JST)$" "$OUTDIR/tokens.md"
+}
+
+# ---------- 11. Max in a sidecar (documents sidecar inclusion) ----------
+@test "removing the sidecar holding the global max shortens the reported span" {
+ cp -R "$ANCHOR" "$BATS_TEST_TMPDIR/projcopy"
+ rm -f "$BATS_TEST_TMPDIR/projcopy/proj-hash-a/$SESSION/subagents/agent-0002.jsonl"
+
+ run bash "$SCRIPT" --action execute --spec-id SPEC-013 --plan-slug slug \
+ --out-dir "$OUTDIR" --session-id "$SESSION" \
+ --projects-root "$BATS_TEST_TMPDIR/projcopy" --ledger "$LEDGER"
+ [ "$status" -eq 0 ]
+ # global max was 17:02:05 (agent-0002); removing it leaves m3 (agent-0001) at
+ # 17:01:00 as the new max -> span 60s < 125s.
+ [ "$(led '.duration_seconds')" -eq 60 ]
+ [ "$(led '.ended_at')" = "2026-07-02T17:01:00.000Z" ]
+}
+
+# ---------- 12. Single / zero ----------
+@test "single usage line: duration 0, available true" {
+ run bash "$SCRIPT" --action spec --spec-id SPEC-000 \
+ --out-dir "$OUTDIR" --session-id "fixturesingle0001" \
+ --projects-root "$FIX/single/projects" --ledger "$LEDGER"
+ [ "$status" -eq 0 ]
+ [ "$(led '.duration_seconds')" -eq 0 ]
+ [ "$(led '.duration_available')" = "true" ]
+ [ "$(led '.total')" -eq 562 ]
+}
+
+@test "zero usage lines (timestamped non-usage only): unavailable/null, buckets 0, partial false" {
+ run bash "$SCRIPT" --action spec --spec-id SPEC-000 \
+ --out-dir "$OUTDIR" --session-id "fixturezero0001" \
+ --projects-root "$FIX/zero/projects" --ledger "$LEDGER"
+ [ "$status" -eq 0 ]
+ [ "$(led '.duration_available')" = "false" ]
+ [ "$(led '.duration_seconds')" = "null" ]
+ [ "$(led '.total')" -eq 0 ]
+ [ "$(led '.partial')" = "false" ]
+ [[ "$output" == *"Elapsed: unavailable (no readable turn timestamps)"* ]]
+}
+
+# ---------- 13. Malformed timestamp + flag independence ----------
+@test "malformed extremal timestamp: unavailable, buckets intact, partial false (flag independence)" {
+ run bash "$SCRIPT" --action spec --spec-id SPEC-000 \
+ --out-dir "$OUTDIR" --session-id "fixturemalformed0001" \
+ --projects-root "$FIX/malformed/projects" --ledger "$LEDGER"
+ [ "$status" -eq 0 ]
+
+ # duration unavailable ...
+ [ "$(led '.duration_available')" = "false" ]
+ [ "$(led '.duration_seconds')" = "null" ]
+ [ "$(led '.started_at')" = "null" ]
+ [ "$(led '.ended_at')" = "null" ]
+
+ # ... while the four buckets equal their hand-computed sums and partial is FALSE.
+ # Only this case falsifies a helper that conflates partial with duration_available.
+ [ "$(led '.partial')" = "false" ]
+ [ "$(led '.buckets.fresh_input')" -eq 11 ]
+ [ "$(led '.buckets.cache_write')" -eq 22 ]
+ [ "$(led '.buckets.cache_read')" -eq 33 ]
+ [ "$(led '.buckets.output')" -eq 44 ]
+}
diff --git a/.gaia/scripts/token-tally.sh b/.gaia/scripts/token-tally.sh
new file mode 100755
index 00000000..611924ea
--- /dev/null
+++ b/.gaia/scripts/token-tally.sh
@@ -0,0 +1,369 @@
+#!/usr/bin/env bash
+# GAIA token-accounting tally helper (SPEC-013).
+#
+# Reads the ground-truth token usage the API already recorded for a GAIA action
+# (/gaia-spec, /gaia-plan, or a KICKOFF plan-execution run) and turns it into a
+# per-action readout: four billing buckets plus a total, a wall-clock elapsed
+# span, a durable machine-local ledger record, a human-readable tokens.md, and a
+# printed tally block.
+#
+# It sums `.message.usage` across the session's MAIN transcript
+# (/*/.jsonl) AND every sub-agent sidecar
+# (/*//subagents/agent-*.jsonl). A single assistant
+# message is streamed across MULTIPLE JSONL lines that repeat the same
+# `.message.id` and the same usage, so the tally DEDUPS by `.message.id`
+# (fallback `.uuid`) before summing; without this it overcounts output ~3x.
+#
+# Alongside the tokens it reports wall-clock elapsed = max(.timestamp) -
+# min(.timestamp) over the usage-bearing lines (first to last billed model turn),
+# across main + sidecars. Bookkeeping lines (pr-link/system/attachment) and
+# leading user think-time carry no usage and are excluded, so the span sits on
+# real work. Epoch conversion is jq-only (`fromdateiso8601`, UTC on every
+# platform); the `date` binary is NEVER used to parse transcript timestamps
+# (`date -j -f` ignores the Z and halves a span across a DST boundary, and
+# `date -j` is macOS-only, which breaks the Linux CI bats run).
+#
+# The ledger stores the raw UTC endpoints (C2, the durable machine record); the
+# HUMAN surfaces (stdout, tokens.md) render those two endpoints in the machine's
+# LOCAL zone (jq for the clock, `date +%Z` for the zone label — jq's own %z/%Z
+# misreport the offset across a DST boundary on some builds, while `date +%Z`
+# reads the effective system zone, is identical on macOS and Linux, and parses
+# no timestamp, so it is outside the epoch-parsing ban above).
+#
+# Behavior / contract (README C1-C5):
+# - Exit code is ALWAYS 0. The helper never blocks or fails its caller.
+# Every failure mode degrades to a partial/absent figure with a marker;
+# no number is ever fabricated.
+# - stdout carries ONLY the tally block; all diagnostics go to stderr.
+# - Side effects: append one ledger record; write /tokens.md.
+# - `partial` flips when the session id is empty, the main transcript matched
+# no file, or any matched file failed to parse. An empty sidecar set is NOT
+# partial. `duration_available` is a SEPARATE flag: tokens can be complete
+# while duration is unavailable (unparseable extremal timestamp), and the
+# reverse.
+#
+# CLI (README C1):
+# bash .gaia/scripts/token-tally.sh \
+# --action --spec-id [--plan-slug ] \
+# --out-dir [--session-id ] [--projects-root ] [--ledger ]
+#
+# DO NOT add `set -e`; each step is guarded independently so one failure cannot
+# abort the never-block guarantee.
+
+log() {
+ printf '%s\n' "$*" >&2
+}
+
+is_uint() {
+ case "$1" in
+ ''|*[!0-9]*) return 1 ;;
+ *) return 0 ;;
+ esac
+}
+
+# Pinned human duration format: hms, dropping any LEADING zero-valued
+# unit (45s, 6m39s, 2h4m10s), second granularity, lossless vs duration_seconds.
+human_duration() {
+ local total="$1" h m s
+ h=$(( total / 3600 ))
+ m=$(( (total % 3600) / 60 ))
+ s=$(( total % 60 ))
+ if (( h > 0 )); then
+ printf '%dh%dm%ds' "$h" "$m" "$s"
+ elif (( m > 0 )); then
+ printf '%dm%ds' "$m" "$s"
+ else
+ printf '%ds' "$s"
+ fi
+}
+
+# Render a raw UTC transcript timestamp (…Z) in the machine's LOCAL zone, for the
+# human surfaces only. jq renders the local clock (DST-correct for H:M:S); the
+# zone LABEL is $ZONE_LABEL (resolved once via `date +%Z`). Falls back to the raw
+# input if jq cannot parse it (never fabricates, never blocks).
+ZONE_LABEL=""
+to_local() {
+ local iso="$1" clock
+ clock="$(jq -rn --arg t "$iso" '
+ $t | sub("\\.[0-9]+Z$"; "Z") | fromdateiso8601 | strflocaltime("%Y-%m-%d %H:%M:%S")
+ ' 2>/dev/null || true)"
+ if [[ -n "$clock" ]]; then
+ printf '%s%s' "$clock" "${ZONE_LABEL:+ $ZONE_LABEL}"
+ else
+ printf '%s' "$iso"
+ fi
+}
+
+# ---------- argument parsing (never crash on a bad/missing flag) ----------
+ACTION=""
+SPEC_ID=""
+PLAN_SLUG=""
+OUT_DIR=""
+SESSION_ID_ARG=""
+PROJECTS_ROOT_ARG=""
+LEDGER_OVERRIDE=""
+
+while [[ $# -gt 0 ]]; do
+ key="$1"
+ case "$key" in
+ --action|--spec-id|--plan-slug|--out-dir|--session-id|--projects-root|--ledger)
+ val="${2:-}"
+ case "$key" in
+ --action) ACTION="$val" ;;
+ --spec-id) SPEC_ID="$val" ;;
+ --plan-slug) PLAN_SLUG="$val" ;;
+ --out-dir) OUT_DIR="$val" ;;
+ --session-id) SESSION_ID_ARG="$val" ;;
+ --projects-root) PROJECTS_ROOT_ARG="$val" ;;
+ --ledger) LEDGER_OVERRIDE="$val" ;;
+ esac
+ # `shift 2` fails (and does NOT shift) when a flag is the final arg with no
+ # value, which would spin this loop forever; fall back to a single shift.
+ shift 2 2>/dev/null || shift
+ ;;
+ *)
+ log "token-tally: ignoring unknown argument: $key"
+ shift
+ ;;
+ esac
+done
+
+SESSION_ID="${SESSION_ID_ARG:-${CLAUDE_CODE_SESSION_ID:-}}"
+PROJECTS_ROOT="${PROJECTS_ROOT_ARG:-$HOME/.claude/projects}"
+
+partial=0
+
+# Missing required flags are belt-and-suspenders (callers pass well-formed args);
+# degrade to partial rather than crash.
+[[ -z "$ACTION" ]] && { log "token-tally: missing --action"; partial=1; }
+[[ -z "$SPEC_ID" ]] && { log "token-tally: missing --spec-id"; partial=1; }
+[[ -z "$OUT_DIR" ]] && { log "token-tally: missing --out-dir"; partial=1; }
+if [[ "$ACTION" == "plan" || "$ACTION" == "execute" ]] && [[ -z "$PLAN_SLUG" ]]; then
+ log "token-tally: missing --plan-slug for action=$ACTION"
+ partial=1
+fi
+[[ -z "$SESSION_ID" ]] && { log "token-tally: no session id (--session-id or CLAUDE_CODE_SESSION_ID)"; partial=1; }
+
+# ---------- single-pass tally over main transcript + sidecars ----------
+# Per file, ONE streaming read emits {usage:[{id,u}], tmin, tmax} where usage is
+# deduped within the file (last-wins) and tmin/tmax range over EVERY usage line
+# (not the deduped survivors). A file that fails to parse flips `partial` and
+# contributes nothing, but never aborts the run or the other files.
+tmp="$(mktemp 2>/dev/null)" || tmp=""
+[[ -z "$tmp" ]] && { log "token-tally: mktemp failed; degrading to partial"; partial=1; }
+
+emit_file() {
+ jq -cn '
+ reduce inputs as $x (
+ {usage:{}, tmin:null, tmax:null};
+ if $x.message.usage != null
+ then .usage[($x.message.id // $x.uuid)] = $x.message.usage
+ | (if ($x.timestamp | type) == "string"
+ then .tmin = (if .tmin == null or $x.timestamp < .tmin then $x.timestamp else .tmin end)
+ | .tmax = (if .tmax == null or $x.timestamp > .tmax then $x.timestamp else .tmax end)
+ else . end)
+ else . end)
+ | {usage: (.usage | to_entries | map({id: .key, u: .value})), tmin, tmax}
+ ' "$1" >>"$tmp" 2>/dev/null || partial=1
+}
+
+if [[ -n "$SESSION_ID" && -n "$tmp" ]]; then
+ # Main transcript: first match of /*/.jsonl.
+ main_found=0
+ for f in "$PROJECTS_ROOT"/*/"$SESSION_ID".jsonl; do
+ if [[ -f "$f" ]]; then
+ emit_file "$f"
+ main_found=1
+ break
+ fi
+ done
+ [[ "$main_found" -eq 0 ]] && { log "token-tally: no main transcript for session $SESSION_ID"; partial=1; }
+
+ # Sidecars: zero matches is fine (a session may fan out no sub-agents), NOT
+ # partial. The agent-*.jsonl glob excludes the sibling agent-*.meta.json files.
+ for f in "$PROJECTS_ROOT"/*/"$SESSION_ID"/subagents/agent-*.jsonl; do
+ [[ -f "$f" ]] && emit_file "$f"
+ done
+fi
+
+# ---------- aggregate: global dedup + bucket sums + global min/max ----------
+FRESH=0
+CWRITE=0
+CREAD=0
+OUT=0
+TMIN=""
+TMAX=""
+if [[ -n "$tmp" && -s "$tmp" ]]; then
+ IFS=$'\t' read -r FRESH CWRITE CREAD OUT TMIN TMAX < <(
+ jq -rs '
+ ((map(.usage) | add // []) | reduce .[] as $x ({}; .[$x.id] = $x.u) | [.[]]) as $u
+ | [ ($u | map(.input_tokens // 0) | add // 0),
+ ($u | map(.cache_creation_input_tokens // 0) | add // 0),
+ ($u | map(.cache_read_input_tokens // 0) | add // 0),
+ ($u | map(.output_tokens // 0) | add // 0),
+ (map(.tmin) | map(select(. != null)) | min // ""),
+ (map(.tmax) | map(select(. != null)) | max // "") ]
+ | @tsv
+ ' "$tmp" 2>/dev/null || printf '0\t0\t0\t0\t\t\n'
+ )
+fi
+[[ -n "$tmp" ]] && rm -f "$tmp" 2>/dev/null
+
+is_uint "$FRESH" || FRESH=0
+is_uint "$CWRITE" || CWRITE=0
+is_uint "$CREAD" || CREAD=0
+is_uint "$OUT" || OUT=0
+TOTAL=$(( FRESH + CWRITE + CREAD + OUT ))
+
+# ---------- duration: convert ONLY the two extremes in jq (never `date`) ----------
+# A malformed extremal timestamp -> unavailable (own flag), never a fabricated 0,
+# never an abort. The subtraction stays inside jq so empty vars can't leak a 0.
+DUR_SECONDS=""
+DUR_AVAIL=false
+if [[ -n "$TMIN" && -n "$TMAX" ]]; then
+ DUR_SECONDS="$(jq -rn --arg a "$TMIN" --arg b "$TMAX" '
+ def toe: sub("\\.[0-9]+Z$"; "Z") | fromdateiso8601;
+ (($b | toe) - ($a | toe))
+ ' 2>/dev/null || true)"
+ if is_uint "$DUR_SECONDS"; then
+ DUR_AVAIL=true
+ else
+ DUR_SECONDS=""
+ fi
+fi
+
+# Human-facing duration + LOCAL-zone endpoint strings (the ledger keeps raw UTC).
+# Resolve the zone label once; both endpoints share it.
+HUMAN=""
+LOCAL_START=""
+LOCAL_END=""
+if [[ "$DUR_AVAIL" == "true" ]]; then
+ HUMAN="$(human_duration "$DUR_SECONDS")"
+ ZONE_LABEL="$(date +%Z 2>/dev/null || true)"
+ LOCAL_START="$(to_local "$TMIN")"
+ LOCAL_END="$(to_local "$TMAX")"
+fi
+
+# ---------- shared generation stamp (date -u here is fine; the ban is only on
+# parsing transcript timestamps to epoch, which stays jq-only) ----------
+TS="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
+
+# Display titles: stdout uses `/`, tokens.md uses ` / `.
+if [[ "$ACTION" == "spec" ]]; then
+ out_title="$ACTION $SPEC_ID"
+ md_title="$ACTION $SPEC_ID"
+else
+ out_title="$ACTION $SPEC_ID/$PLAN_SLUG"
+ md_title="$ACTION $SPEC_ID / $PLAN_SLUG"
+fi
+
+# ---------- ledger record (README C2), resolved to the main checkout ----------
+# main_root = dirname(absolute(git rev-parse --git-common-dir)), so a KICKOFF run
+# inside a linked worktree records to the surviving main ledger, not the
+# worktree's discarded copy. --ledger overrides (test seam).
+resolve_ledger() {
+ if [[ -n "$LEDGER_OVERRIDE" ]]; then
+ printf '%s' "$LEDGER_OVERRIDE"
+ return 0
+ fi
+ local common_dir abs main_root
+ common_dir="$(git rev-parse --git-common-dir 2>/dev/null)"
+ [[ -z "$common_dir" ]] && return 1
+ case "$common_dir" in
+ /*) abs="$common_dir" ;;
+ *) abs="$PWD/$common_dir" ;;
+ esac
+ main_root="$(cd "$(dirname "$abs")" 2>/dev/null && pwd)"
+ [[ -z "$main_root" ]] && return 1
+ printf '%s' "$main_root/.gaia/local/telemetry/tokens.jsonl"
+}
+
+partial_bool=false
+[[ "$partial" -ne 0 ]] && partial_bool=true
+
+rec="$(jq -nc \
+ --arg action "$ACTION" \
+ --arg spec_id "$SPEC_ID" \
+ --arg plan_slug "$PLAN_SLUG" \
+ --arg session_id "$SESSION_ID" \
+ --argjson fresh "$FRESH" \
+ --argjson cwrite "$CWRITE" \
+ --argjson cread "$CREAD" \
+ --argjson out "$OUT" \
+ --argjson total "$TOTAL" \
+ --argjson partial "$partial_bool" \
+ --arg started "$TMIN" \
+ --arg ended "$TMAX" \
+ --argjson dur "${DUR_SECONDS:-null}" \
+ --argjson avail "$DUR_AVAIL" \
+ --arg ts "$TS" \
+ '
+ {action: $action, spec_id: $spec_id}
+ + (if $action == "spec" then {} else {plan_slug: $plan_slug} end)
+ + {
+ session_id: $session_id,
+ buckets: {fresh_input: $fresh, cache_write: $cwrite, cache_read: $cread, output: $out},
+ total: $total,
+ partial: $partial,
+ started_at: (if $avail then $started else null end),
+ ended_at: (if $avail then $ended else null end),
+ duration_seconds: (if $avail then $dur else null end),
+ duration_available: $avail,
+ ts: $ts
+ }
+ ' 2>/dev/null || true)"
+
+if [[ -n "$rec" ]]; then
+ if ledger="$(resolve_ledger)" && [[ -n "$ledger" ]]; then
+ mkdir -p "$(dirname "$ledger")" 2>/dev/null
+ printf '%s\n' "$rec" >>"$ledger" 2>/dev/null || log "token-tally: ledger write failed: $ledger"
+ else
+ log "token-tally: could not resolve ledger path; skipping ledger append"
+ fi
+else
+ log "token-tally: failed to build ledger record; skipping ledger append"
+fi
+
+# ---------- tokens.md (README C3) ----------
+if [[ -n "$OUT_DIR" ]]; then
+ mkdir -p "$OUT_DIR" 2>/dev/null
+ {
+ printf '# Token cost: %s\n\n' "$md_title"
+ printf '| Bucket | Tokens |\n'
+ printf '| --- | --- |\n'
+ printf '| Fresh input | %s |\n' "$FRESH"
+ printf '| Cache write | %s |\n' "$CWRITE"
+ printf '| Cache read | %s |\n' "$CREAD"
+ printf '| Output | %s |\n' "$OUT"
+ printf '| **Total** | %s |\n\n' "$TOTAL"
+ if [[ "$DUR_AVAIL" == "true" ]]; then
+ printf '**Elapsed (first to last model turn):** %s (%s to %s)\n' "$HUMAN" "$LOCAL_START" "$LOCAL_END"
+ else
+ printf '_Elapsed: unavailable (no readable turn timestamps)._\n'
+ fi
+ if [[ "$partial" -ne 0 ]]; then
+ printf '\n_Partial: one or more transcript inputs were missing or unparseable; figures are a lower bound._\n'
+ fi
+ # Backticks below are literal markdown (inline-code the session id), not a command sub.
+ # shellcheck disable=SC2016
+ printf '\nSession `%s` · generated %s\n' "$SESSION_ID" "$TS"
+ } >"$OUT_DIR/tokens.md" 2>/dev/null || log "token-tally: tokens.md write failed: $OUT_DIR/tokens.md"
+fi
+
+# ---------- stdout tally block (README C4) ----------
+printf 'Token cost (%s):\n' "$out_title"
+printf ' Fresh input: %s\n' "$FRESH"
+printf ' Cache write: %s\n' "$CWRITE"
+printf ' Cache read: %s\n' "$CREAD"
+printf ' Output: %s\n' "$OUT"
+printf ' Total: %s\n' "$TOTAL"
+if [[ "$DUR_AVAIL" == "true" ]]; then
+ printf ' Elapsed: %s (first to last model turn: %s to %s)\n' "$HUMAN" "$LOCAL_START" "$LOCAL_END"
+else
+ printf ' Elapsed: unavailable (no readable turn timestamps)\n'
+fi
+if [[ "$partial" -ne 0 ]]; then
+ printf ' (partial: figures are a lower bound; some inputs were unreadable)\n'
+fi
+
+exit 0
diff --git a/.gaia/specs.json b/.gaia/specs.json
index 887b09db..87981223 100644
--- a/.gaia/specs.json
+++ b/.gaia/specs.json
@@ -108,8 +108,24 @@
"id": "SPEC-014",
"allocated_at": "2026-07-02T15:53:09Z",
"source": "allocated",
+ "status": "merged",
+ "intent": "GAIA's per-machine `setup finalize` command stamps setup as complete without",
+ "merged_at": "2026-07-02T17:54:47Z"
+ },
+ {
+ "id": "SPEC-015",
+ "allocated_at": "2026-07-03T02:42:11Z",
+ "source": "allocated",
+ "status": "merged",
+ "intent": "GAIA registers the Serena MCP server for Claude Code with the wrong",
+ "merged_at": "2026-07-03T04:10:52Z"
+ },
+ {
+ "id": "SPEC-016",
+ "allocated_at": "2026-07-03T06:55:33Z",
+ "source": "allocated",
"status": "specified",
- "intent": "GAIA's per-machine `setup finalize` command stamps setup as complete without"
+ "intent": "GAIA adopters who run Serena for code intelligence get symbol-level help"
}
]
}
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 65fa43ec..87bc6cdf 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -16,6 +16,7 @@ A release change that requires the adopter to act, run a command or hand-migrate
### Added
+- a per-action ground-truth token and wall-clock cost readout for `/gaia-spec`, `/gaia-plan`, and KICKOFF plan execution. At each action's completion point a new `.gaia/scripts/token-tally.sh` helper sums the four billing buckets (fresh input, cache write, cache read, output) plus a total across the session's main transcript and every sub-agent sidecar, deduped to what the API actually billed, reports the wall-clock elapsed over billed model turns with the start and end shown in the machine's local timezone, writes a `tokens.md` beside the spec or plan, and appends a durable machine-local ledger record resolved to the main checkout so it survives deletion of the plan folder and a linked worktree. The tally never blocks or fails its action and degrades to a partial figure with a marker on unreadable input rather than fabricating a number (#536)
- a Serena code-search enforcement guard (`.claude/hooks/serena-code-search-guard.sh`): a PreToolUse hook that blocks a bare-identifier `Grep` over `app/**` or `test/**` TS/TSX and routes it to Serena's LSP-backed symbol tools, closing the gap where the edit-scoped routing rule was absent from context during exploration. Re-running the identical grep passes for a genuine string or comment search, and the guard no-ops unless Serena is a registered MCP server with a `tsconfig.json` present, so adopters without Serena are unaffected (#511)
- `gaia.updateDepsHold`, a committed per-package version hold for `/update-deps`: a `{ package: ceiling }` map in `package.json` that caps a package to a version-prefix line (`"8.0"` holds the highest `8.0.x`, `"8.0.16"` freezes exactly). Unlike the CI-ignored local snooze ledger, the hold is committed, so it applies in interactive and CI runs until the maintainer lifts it, and fails closed when the ceiling matches no published version. The `/update-deps` preview surfaces active holds (#500)
- output-verification hardening for three GAIA self-audit workflows, so a single-pass judgment can no longer drive an edit or a clean verdict unchecked. `/gaia-audit` gains an optional classification-verification round before its Apply / Discuss / Decline gate (biased toward keeping any flagged memory delete it cannot confirm, since those deletes are irreversible); `/health-audit` (maintainer-only) gains a false-clean challenger that can revoke a clean exit; and `/gaia-fitness`'s final verify cycle now re-runs all seven categories, so a regression in a zero-finding lane is caught before it reports A+. All rounds are recommended-but-optional and never block (#472)