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
3 changes: 2 additions & 1 deletion .claude/skills/codetrial-contribute/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,8 @@ diff and actual test results. Do not paste a conversation transcript.

Use [codetrial-verify](../codetrial-verify/SKILL.md) for the appropriate validation
and [codetrial-web](../codetrial-web/SKILL.md) when browser or wire behavior is
involved. Report commands and outcomes, including failures and skipped or unrun
involved, [codetrial-livekit](../codetrial-livekit/SKILL.md) when the room or
the LiveKit crates are. Report commands and outcomes, including failures and skipped or unrun
checks with reasons. Local success is not evidence that remote CI passed, and
a PR is not ready for review until CI is green.

Expand Down
6 changes: 5 additions & 1 deletion .claude/skills/codetrial-conventions/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,11 @@ for duplicates and incomplete reports, and sweeping the open pull requests, is
[codetrial-triage](../codetrial-triage/SKILL.md). Approving a held workflow
run, resolving an outdated review thread, posting the fixed rebase request and
hiding the copies it supersedes need no per-thread approval of text; that
skill says when a sweep applies them without a separate confirmation.
skill says when a sweep applies them without a separate confirmation. A bare
`/codetrial-triage` also posts, without a per-thread approval, the fixed-text
hygiene review ([pulls.md](../codetrial-triage/pulls.md#contributor-hygiene)), and the inline findings and similar-work notices
of its review of low and blocked PRs ([review-prs.md](../codetrial-triage/review-prs.md)), each verified at the
head first; the maintainer approved those forms by asking for them.

## Pull request branches and review replies

Expand Down
90 changes: 90 additions & 0 deletions .claude/skills/codetrial-livekit/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
---
name: codetrial-livekit
description: Where CodeTrial's LiveKit code lives and how to change it - the Rust interviewer's room loop, join tokens, agent dispatch, the management API, agent state, egress and the vendored transport, looking LiveKit facts up for the pinned crate, and upgrading the livekit crates. Use when touching src/livekit*, src/dispatch.rs, src/token.rs or src/web/token.rs, debugging "can't hear Jim", a 401, a 429 or an empty room, checking LiveKit credentials, asking whether LiveKit supports something, or reaching for lk agent debugger, console, simulate or deploy, which need an Agents SDK project this repo does not have. Data-channel topics are codetrial-web; validating a change or bringing the server up is codetrial-verify.
---

# LiveKit in CodeTrial

The interviewer is a Rust participant: the `web` server mints the browser's
join token and dispatches one agent per room, which joins with the `livekit`
crate and bridges the room to Gemini Live. No Agents SDK worker exists, so `lk
agent` cannot drive it. A room change is checked by the credentialed browser
lanes in codetrial-verify, and a full live interview only when those cannot
show it.

## Where LiveKit is touched

| Concern | Where |
| --- | --- |
| The room loop, media, and the management API over HTTP | `src/livekit.rs` and `src/livekit/`, whose module header maps the regions |
| Join tokens | `src/token.rs`, served by `src/web/token.rs` |
| One agent per room, no-show release | `src/dispatch.rs` |
| Agent state the page reads (`lk.agent.state`) | set in `src/livekit.rs`, decided by `docs/avatar-contract.md` |
| Recording through egress | `src/recording/`, decided by `docs/recording-contract.md` |
| The SDK's transport patch | `vendor/livekit-net`, whose README says what changed and when to delete it |

`docs/providers.md` covers connection minutes and refused credentials.

## Look a fact up for the crate this tree pins

A remembered LiveKit flag, default or signature is a guess, and a wrong one
costs a full rebuild. Check, in this order:

1. `--help` on the installed `lk`, for any CLI question.
2. The source of the version `Cargo.lock` pins, in the cargo registry, for
anything the Rust SDK does. LiveKit's docs mostly show the Python and Node
SDKs, so they say what the server and protocol do, not this crate's API.
3. `lk docs` for server behavior, limits and changelogs.

```bash
lk docs search "room composite egress" # see lk docs --help
```

Say so when a fact could not be confirmed, and mark a call site written
without a lookup as unverified.

## When the browser cannot hear Jim

Read the console's exact error string, not the banner, against the table in
`docs/livekit-connection-troubleshooting.md`, and never reload on a `429`:
every retry extends the limit. Check first whether the agent is in the room:

```bash
lk room participants list --room "$room" # see --help
```

`lk` reads `LIVEKIT_URL`, `LIVEKIT_API_KEY` and `LIVEKIT_API_SECRET` from the
environment. The primary project's are in `config/codetrial.env.local`, and
each extra project's in its own file (`docs/providers.md`). Load one the way
`scripts/browser-check.sh` does (`set -a`, source the file, `set +a`) in the
command's own shell, and never print, paste or log them. Checking a key and
secret against a project without the browser is in the troubleshooting doc's
[credentials section](../../../docs/livekit-connection-troubleshooting.md#verifying-credentials-without-the-browser).

## Rules for room code

- Time to first audio is what the candidate feels. Measure it with the phases
in `docs/latency-observability.md` before changing anything that claims to
speed it up.
- The nudges and the watchdog are set in `docs/provider-cost-and-degradation.md`
and when a turn ends in `docs/turn-timing.md`; change silence behavior there
rather than by feel.
- The model interprets; Rust owns the state. The timer, the report, integrity
evidence and every effect stay in code, and a tool call is the model's
reading, not proof.
- Work after the interview (the report and its delivery) completes inside the
room's lifecycle, or a dropped connection cuts it off.
- Each subcommand builds its tokio runtime in `src/main.rs`, and room code runs
on it already async: never build another runtime under it or block on a
future inside the loop, which stalls media for every room the process hosts.
- The lifecycle logs name their room (`room=`, as in `src/dispatch.rs`), so one
interview can be traced from dispatch to report. Give a new lifecycle line the
room too.

## Upgrading the LiveKit crates

The crates are pinned in `Cargo.lock`, and a bump is slow to build
(`docs/development.md` says why). Read the changelog first, check whether the
bump meets the `vendor/livekit-net` README's removal condition, then run the
Rust lanes and a live interview through codetrial-verify: some regressions only
show in a real room with a real browser.
21 changes: 17 additions & 4 deletions .claude/skills/codetrial-triage/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,14 @@ description: >
deciding whether a symptom already has an issue, explicit PR sweeps, ranking
open PRs by size and merge confidence, reporting what moved since the last
sweep, approving GitHub Actions runs held for first-time contributors, asking
conflicting PRs to rebase, and applying requested label, assignee, issue type,
sub-issue or blocked-by changes. Default to a read-only report; a PR sweep has
narrowly defined actions in pulls.md. Use codetrial-contribute for drafts on
a contributor's behalf, new issues or PRs, and focused maintainer questions.
conflicting PRs to rebase, requesting changes for recurring contributor
mistakes (hooks, indent, merge commits, missing screenshots), reviewing low
and blocked PRs with several agents, posting the verified inline findings and
similar-work reminders, and applying requested label, assignee, issue type,
sub-issue or blocked-by changes. Default to a read-only report; a PR sweep
has narrowly defined actions in pulls.md. Use codetrial-contribute for drafts
on a contributor's behalf, new issues or PRs, and focused maintainer
questions.
---

# Triage CodeTrial issues and pull requests
Expand All @@ -34,6 +38,15 @@ follows [pulls.md](pulls.md) after the scope below. The duplicate reasoning
here applies to PRs too, and the confirmed-list rule governs every comment it
drafts.

A bare `/codetrial-triage` is both halves at once, and it runs often, so run
it wide. One call starts `issue-sweep.sh` and `pr-sweep.sh` together (each
in the background of one shell, then `wait`). The next response does
everything that needs only their output in parallel: the no-ask actions in
[pulls.md](pulls.md), `hygiene.sh` for every open PR, one review agent per low
or blocked PR from [review-prs.md](review-prs.md), and the issue body, comment
and search reads below. Each PR's review is verified and posted the moment it lands, and the
report waits only for the last of them.

How work moves on this tracker (who the maintainers are, how a PR is scoped,
what the one-line review requests mean) is in
[collaboration.md](../codetrial-contribute/collaboration.md). Recheck linked PR
Expand Down
5 changes: 5 additions & 0 deletions .claude/skills/codetrial-triage/agent-marker.jq
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# A commit message that credits an AI agent, never a person: a human co-author
# keeps the credit (codetrial-conventions). Matched on the address agents commit
# with, never on a name, since a person may well be called Claude. Its own file
# so the tests check every address in one jq call; hygiene.sh includes it.
def ai_marker: test("(^|\n)co-authored-by:[^\n]*<(noreply@anthropic\\.com|codex@openai\\.com|cursoragent@cursor\\.com|([0-9]+\\+)?(copilot|(copilot-swe-agent|devin-ai-integration|claude|cursor|codex|gemini-code-assist|openhands-agent)\\[bot\\])@users\\.noreply\\.github\\.com|copilot@github\\.com)>|(^|\n)[^a-z\n]*generated (with|by) \\[?(claude code|codex|github copilot|cursor|gemini)"; "i");
156 changes: 156 additions & 0 deletions .claude/skills/codetrial-triage/hygiene.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
#!/bin/sh
# Read-only: which of the requests maintainers keep typing by hand a pull
# request needs at its current head. Each is a rule a script can decide, and
# each carries the wording the maintainers already used for it, so a sweep posts
# the result as a Request changes review without drafting anything (pulls.md,
# "Contributor hygiene"). A conflict is not here: rebase.md owns it.
#
# Usage: hygiene.sh <pr> [repo]
# Prints {pr, head, skip, items: [{key, text}], body}. skip says why
# the PR gets nothing (the skip calls below), and items is then empty.
# body is the review text, empty when no item applies.
# Exits 2 on bad arguments.
#
# An item a maintainer already raised at this head, in a review left on it or a
# comment after GitHub's first check suite for it (the push, by GitHub's clock
# rather than the contributor's), is left out: that request stands.

set -eu

usage='usage: hygiene.sh <pr> [repo]'
pr=${1:?$usage}
repo=${2:-sysprog21/codetrial}
case $pr in
*[!0-9]*)
echo "hygiene.sh: $pr is not a pull request number" >&2
exit 2
;;
esac
root=$(cd "$(dirname "$0")/../../.." && pwd)

work=$(mktemp -d)
trap 'rm -rf "$work"' EXIT

gh pr view "$pr" --repo "$repo" --json \
number,headRefOid,headRefName,isCrossRepository,isDraft,baseRefName,mergeable,author,body,files,reviews,comments \
Comment thread
jserv marked this conversation as resolved.
> "$work/pr.json"
head=$(jq -r .headRefOid "$work/pr.json")

# A PR nobody should be asked about is decided before the costly reads: the
# commits, and ci-failure.sh's job logs.
skip()
{
jq -n --arg why "$1" --slurpfile p "$work/pr.json" \
'{pr: $p[0].number, head: $p[0].headRefOid, skip: $why, items: [], body: ""}'
exit 0
}
why=$(jq -r 'if .isDraft then "draft"
elif .baseRefName != "main" then "base is not main"
elif .author.is_bot or (.author.login | test("\\[bot\\]$|^(app/)?dependabot"))
then "a bot'\''s pull request"
else "" end' "$work/pr.json")
[ -z "$why" ] || skip "$why"
author=$(jq -r .author.login "$work/pr.json")

# As in pr-sweep.sh: on a refusal gh prints the error body to stdout, so only a
# successful call is read, and a pipe would hide the failure behind jq's status.
# Without the listing nobody can be told apart as a maintainer, and a request
# one of them already made would be asked again. Listing it needs push access,
# so the viewer is on it, and their own PR is skipped as a maintainer's.
logins=$(gh api --paginate "repos/$repo/collaborators?permission=push" \
--jq '.[].login' 2> /dev/null) || skip "the push listing failed"
printf '%s\n' "$logins" | grep -qxF "$author" && skip "a maintainer's pull request"
pushers=$(printf '%s\n' "$logins" | jq -Rn '[inputs | select(length > 0)]')

# When the head reached GitHub, by GitHub's clock: its first check suite. The
# committer date is set by the contributor's clock and dates nothing about the
# push, so without a check suite the PR is skipped rather than guessed.
pushed=$(gh api "repos/$repo/commits/$head/check-suites" \
--jq '[.check_suites[].created_at] | min // empty' 2> /dev/null) || pushed=
[ -n "$pushed" ] || skip "no check suite dates the push"
gh api "repos/$repo/pulls/$pr/commits" --paginate > "$work/commits.json"

# Inline review comments, where a maintainer may ask on a line rather than in
# the review body.
gh api "repos/$repo/pulls/$pr/comments" --paginate > "$work/inline.json"

# Each commit through the commit-msg hook's own rules, the ones CI applies too.
# Merges are git's wording and get their own item; Dependabot's are skipped by
# author, as check-commit-log.sh does. One JSON string per line, so a message's
# own newlines survive the loop.
bad_messages=0
jq -s -c 'add | .[]
| select((.parents | length) < 2
and .commit.author.email != "49699333+dependabot[bot]@users.noreply.github.com")
| .commit.message' "$work/commits.json" > "$work/messages"
while IFS= read -r message; do
printf '%s\n' "$message" | jq -r . \
| sh "$root/scripts/git-commit-msg.sh" - > /dev/null 2>&1 \
|| bad_messages=$((bad_messages + 1))
done < "$work/messages"

# Failing checks at this head, and whether main shares them. A diagnosis that
# fails, or that read another head, yields no CI item rather than a guess.
# HYGIENE_CI names the ci-failure.sh to ask, for tests.
sh "${HYGIENE_CI:-$root/.claude/skills/codetrial-verify/ci-failure.sh}" "$pr" "$repo" \
> "$work/ci.json" 2> /dev/null || echo '{}' > "$work/ci.json"
# Every read above describes one head only if the PR did not move meanwhile.
again=$(gh pr view "$pr" --repo "$repo" --json headRefOid --jq .headRefOid)

jq -n -L "$(dirname "$0")" --slurpfile p "$work/pr.json" --slurpfile c "$work/commits.json" \
--slurpfile ci "$work/ci.json" --slurpfile inline "$work/inline.json" \
--argjson bad "$bad_messages" \
--argjson pushers "$pushers" --arg pushed "$pushed" \
--arg again "$again" '
include "agent-marker";
$p[0] as $pr | ($c | add) as $commits
| (if $ci[0].head == $pr.headRefOid then $ci[0] else {} end) as $ci
| def staff: .author.login | IN($pushers[]);
# What maintainers already said at this head.
([$pr.reviews[] | select(staff and .commit.oid == $pr.headRefOid) | .body]
+ [$pr.comments[] | select(staff and .createdAt > $pushed) | .body]
+ [$inline | add // [] | .[]
| select((.user.login | IN($pushers[])) and .commit_id == $pr.headRefOid)
| .body]
| join("\n")) as $said
# The aggregate check job only collects the others, so it proves nothing.
# A job whose log could not be read has null gates: no evidence either way.
| ($ci.failing // [] | map(select(.current == true and .name != "check"
and .gates != null))) as $failing
| def item($key; $when; $marker; $text):
if $when and ($said | test($marker; "i") | not)
then {key: $key, text: $text} else empty end;
(if $again != $pr.headRefOid or ($commits | last | .sha) != $pr.headRefOid
then "the head moved while it was read"
else null end) as $skip
| (if $skip then [] else [
item("fork_main"; $pr.isCrossRepository and $pr.headRefName == "main";
"branch of your fork|topic branch";
"This pull request is opened from the `main` branch of your fork, so every later commit you push there joins it. Open a new pull request from a topic branch and close this one; see `CONTRIBUTING.md`."),
item("merge_commit"; any($commits[]; (.parents | length) > 1);
"git merge|merge commit";
"Do not merge `main` into this branch with `git merge`. Drop the merge commits, put your own commits on top of the latest `main`, and force-push."),
item("commit_rules"; $bad > 0; "cbea\\.ms|chris\\.beams|make hooks";
"Improve git commit messages per https://cbea.ms/git-commit/ and `CONTRIBUTING.md`; CI applies the same rules. Hint: run `make hooks` once per clone, so the hooks check every message before you push."),
item("co_authored"; any($commits[]; .commit.message | ai_marker);
"co-authored|attribution|agent marker";
"Remove AI agent markers (`Co-authored-by` an agent, \"Generated with\") from Git commit messages, as this attribution does not improve collaboration."),
item("indent"; any($failing[]; .on_main == false
and any(.gates[]; . == "indent")); "make indent";
"CI fails on the `indent` gate. Run `make indent`, then amend and force-push."),
# A conflicting PR is asked to rebase first, and a rebase may be the
# whole answer to the failure; UNKNOWN may still turn out a conflict.
item("ci_regression";
$pr.mergeable == "MERGEABLE" and ($ci.stale_base != true)
and any($failing[]; .on_main == false and .gates != ["indent"]);
"regression|rebase";

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The ci_regression marker matches any current maintainer text containing rebase, so a separate rebase comment can suppress an unresolved CI regression. Match the specific approved CI-request wording or track prior requests by item key instead of searching all feedback with a broad regex.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At .claude/skills/codetrial-triage/hygiene.sh, line 137:

<comment>The `ci_regression` marker matches any current maintainer text containing `rebase`, so a separate rebase comment can suppress an unresolved CI regression. Match the specific approved CI-request wording or track prior requests by item key instead of searching all feedback with a broad regex.</comment>

<file context>
@@ -0,0 +1,147 @@
+      item("ci_regression";
+        $pr.mergeable == "MERGEABLE" and ($ci.stale_base != true)
+        and any($failing[]; .on_main == false and .gates != ["indent"]);
+        "regression|rebase";
+        "Before asking someone to review, you MUST resolve any regressions flagged by GitHub Actions."),
+      item("screenshot";
</file context>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Kept as designed. A rebase request that stands at this head is the answer to the CI failure too: after a rebase the head moves, and the CI item is asked again then if the failure remains. On a conflicting or not yet computed PR the item is also held by the mergeable guard; the marker covers a stale_base rebase request on a mergeable PR, which is the case the guard cannot see.

"Before asking someone to review, you MUST resolve any regressions flagged by GitHub Actions."),
item("screenshot";
any($pr.files[]; .path | test("^web/.*\\.(html|css)$"))
and ($pr.body // "" | test("<img|!\\[|user-attachments/|\\.(png|jpe?g|gif|webp|mp4|mov)\\b"; "i") | not);
"screenshot";
"This changes what the web pages show, so the pull request description must include screenshots of the result (before and after where something changed), with any personal data removed.")
] end) as $items
| {pr: $pr.number, head: $pr.headRefOid, skip: $skip, items: $items,
body: ([$items[].text] | join("\n\n"))}
'
Loading