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
46 changes: 45 additions & 1 deletion .claude/skills/gaia/references/plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -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, <title>` 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, <title> (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 "<SPEC-NNN from README's Source SPEC, or the plan slug if none>" \
--plan-slug "<plan slug = basename of the plan dir>" \
--out-dir "<absolute plan 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/<slug>` (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 <N> --json state`. Parse the JSON; require `.state == "MERGED"`. If not merged, do NOT proceed, surface to user and stop.
Expand Down Expand Up @@ -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:

Expand Down
17 changes: 16 additions & 1 deletion .claude/skills/gaia/references/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <spec-path>` and surfaces findings.
Expand Down Expand Up @@ -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: <intent first line>, see <SPEC_ABS>[, with adversarial audit at <AUDIT_ABS>]
Expand Down
1 change: 1 addition & 0 deletions .gaia/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
7 changes: 5 additions & 2 deletions .gaia/scripts/link-worktree.sh
Original file line number Diff line number Diff line change
@@ -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:
#
# <worktree>/.gaia/local/setup-state.json -> <main>/.gaia/local/setup-state.json
# <worktree>/.gaia/local/mentorship.json -> <main>/.gaia/local/mentorship.json
# <worktree>/.gaia/cache/ -> <main>/.gaia/cache/
# <worktree>/.gaia/local/audit/ -> <main>/.gaia/local/audit/
# <worktree>/.gaia/local/telemetry/ -> <main>/.gaia/local/telemetry/
#
# Behavior:
# - Idempotent: re-running on an already-linked worktree is a no-op.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Original file line number Diff line number Diff line change
@@ -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}}}
Original file line number Diff line number Diff line change
@@ -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"}
Original file line number Diff line number Diff line change
@@ -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}}}
Original file line number Diff line number Diff line change
@@ -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}}}
Original file line number Diff line number Diff line change
@@ -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}}}
Original file line number Diff line number Diff line change
@@ -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}}}
Original file line number Diff line number Diff line change
@@ -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"}}
44 changes: 39 additions & 5 deletions .gaia/scripts/tests/link-worktree.bats
Original file line number Diff line number Diff line change
Expand Up @@ -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) ----------
Expand All @@ -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 ----------
Expand All @@ -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\.')"
Expand All @@ -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) ----------
Expand Down Expand Up @@ -173,13 +188,16 @@ 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).
[ -L "$LINKED/.gaia/cache" ]
[ -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) ----------
Expand Down Expand Up @@ -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}' ]
}
Loading