Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
179807c
chore(quest): claim quest/m0/benchmark/records.md
kixelated Nov 14, 2026
9df443b
feat(records): send each session's record to the checkout's Kixel Cod…
kixelated Nov 14, 2026
aa3ae88
quest: records ship; naming Kixel Code's endpoint waits on its store
kixelated Nov 14, 2026
df9a29b
fix(records): throttle background passes and report a refused token a…
kixelated Nov 14, 2026
cafd650
fix(records): settle only the uploaded stop, lock atomically, honor r…
kixelated Nov 14, 2026
71f94b5
fix(records): one queue file per stop; overlapping senders are harmle…
kixelated Nov 14, 2026
bc6c31d
Merge remote-tracking branch 'origin/main' into quest/m0/benchmark/re…
kixelated Nov 14, 2026
ace3883
feat(records): one sender per user as a systemd user service or launc…
kixelated Nov 14, 2026
e510e2e
fix(records): route sub-agents by repository, drop sessions token-max…
kixelated Nov 14, 2026
74d99bf
fix(records): route sub-agents by their own checkout, own pre-window …
kixelated Nov 14, 2026
7e26324
Merge remote-tracking branch 'origin/main' into t3/records-sender-ser…
kixelated Nov 14, 2026
e832658
fix(records): route a deleted folder's sub-agent by its surviving che…
kixelated Nov 14, 2026
317b308
fix(records): a sub-agent whose resolved repositories are none of the…
kixelated Nov 14, 2026
3d177c7
fix(records): back off a failing group, check uploads before posting,…
kixelated Nov 14, 2026
f48be9c
fix(records): write backoff files whole and rename them; an unreadabl…
kixelated Nov 14, 2026
332d305
Merge origin/main into quest/m0/benchmark/records
kixelated Nov 14, 2026
4eddf30
fix(usage): count a multi-PR session once per writer arm, at its equa…
kixelated Nov 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 108 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1592,17 +1592,14 @@ prompt or reply. Since another repository may use the same branch name, the
session must also be this repository's, as the repositories token-max resolved
for it on its own host name, or, once its checkout is deleted, those resolved
there for another session in that exact directory, as a Codex log records
(never a parent's, which a nested clone may not share). A sub-agent or
spawned session that names no PR's branch, as one in a detached checkout,
takes the PR of the session that spawned it, unless its own repository is
known to be another; otherwise it stays
unattributed. A session is classifying when the PR's `## Review` record names
it as the [risk classifier](#risk-tiers), reviewing when the record names it as
a reviewer or Codex marks it a review, its parent's role when both are on the
same PR, so a review's sub-agents count as reviewing, implementing when its
first request since `--since` came before the PR opened, and iterating
otherwise, so a session
that opened a PR before `--since` and kept working on it reads as iterating. Each PR shows its `## Models` size, its risk tier and
(never a parent's, which a nested clone may not share); otherwise it stays
unattributed. A session on no such branch, such as a sub-agent in a detached
checkout, belongs to its parent's PRs unless its own repository is known to be
another, and a coordinator whose sub-agents worked on several PRs belongs to all
of them, its cost counting equally toward each, so a PR's sessions add up to its
whole cost. Each session gets the record
[`quest records sender`](#session-records) would send, and its role comes from
it, so a review's sub-agents count as reviewing. Each PR shows its `## Models` size, its risk tier and
whether a reviewer raised it, and its implementers, and as `unread:` each Implementer line it cannot read; each reviewer the
`quest review` record names, with its latest round and score and its tally of
findings across every round, marked `shadow` for the shadow, where a merged PR whose last round left only low
Expand Down Expand Up @@ -1662,7 +1659,8 @@ Sessions on no PR here, from any
repository or agent token-max reads, total on an `Unattributed` line. `--pr` and
`--quest` narrow the report to one PR or one quest's branch, and `--json`
prints it as one object. Without `token-max` on `PATH`, it says how to install
it and reports the PRs' records alone.
it and reports the PRs' records alone. Each PR's `waits:` line sums its
sessions' waits by kind, a shared session's in its share.

`--ssh HOST`, repeatable, also reads that SSH destination's sessions through
the token-max installed there, matching each through the repositories resolved
Expand All @@ -1675,6 +1673,101 @@ unfinished, is listed under `Lower bounds` with its unreadable lines, and every
cost it reaches reads `or more`. The land fixer's container keeps no session log, so
its fixes show without tokens.

### Session records

When an agent session's turn ends in a checkout that uses Quest, `quest`
sends Kixel Code one record of it, rebuilt each time, so a resend replaces
the last and the store follows the session as it grows. The store keeps the
latest upload, since one sender sends a session's uploads in order. The type is
`SessionRecord`, exported from `quest/core` with `parseRecord` and
`parseUpload`, which refuse a malformed record whole:

```json
{"harness":"claude","session":"7ef44bda-fb9b-436f-9ed8-9013d0f38d46","parent":null,"repository":"acme/app","branch":"quest/m0/login","quest":"quest/m0/login.md","pulls":[42],"role":"implementing","models":[{"model":"claude-opus-5-5","effort":"high","input":230,"cacheWrite":211500,"cacheRead":15800000,"output":88800,"cost":6.64,"complete":true}],"cost":6.64,"complete":true,"start":"2026-11-14T00:06:12.270Z","end":"2026-11-14T00:31:40.120Z","waits":[{"kind":"gate-queue","start":"2026-11-14T00:22:07.386Z","end":"2026-11-14T00:22:07.402Z","until":null}]}
```

- `pulls` are the PRs it worked on, attributed as `quest usage` attributes
them; a session with none yet, such as planning before its PR opens, keeps
its `branch`, and the next record names the PR.
- `role` is classifying when a PR's `## Review` record names it as the
[risk classifier](#risk-tiers), reviewing when it names it as a reviewer or
Codex marks it a review, else the one the first quest skill it ran gives: planning
(`/quest-plan`, `/quest-audit`, `/quest-recurring`, `/quest-import`,
`/quest-grill`, `/quest-delete`, `/quest-export`), implementing
(`/quest-start`), iterating (`/quest-iterate`), reviewing (`/quest-review`),
merging (`/quest-merge`, `/quest-complete`), or coordinating (`/quest-run`,
`/quest-spawn`). Without one, a sub-agent takes its parent's role unless that
is coordinating; a session on its sub-agents' PRs only is coordinating; one
on a `quest-plan/`, `quest-audit/`, `quest-recurring/`, or `quest-import/`
branch is planning; and the rest are implementing when their first request
came before the PR opened, else iterating.
- `waits` holds each wait a tool timed: `gate-queue` and `gate-run` for an
outermost `quest gate`, `pull` for `quest wait` with the first activity it
printed as `until` (null on timeout), `merge-queue` for a `quest wait` that
ended on `merged` or `removed_from_merge_queue`, and `person` from a blocking
question, such as `AskUserQuestion` or `request_user_input`, to its answer.
`quest gate` and `quest wait` log theirs under
`$XDG_STATE_HOME/quest/waits` (`~/.local/state`), one file per day kept 90
days, whenever `CLAUDE_CODE_SESSION_ID` or `CODEX_THREAD_ID` names the
session; a Claude sub-agent's commands carry its parent's ID, so a wait goes
to the one sub-agent of it running then in the wait's directory. Waits may
overlap, as parallel gates do. `null` means they could not be read, as for a
session on another host in `quest usage --ssh`, or one whose transcript has
lines token-max could not read either. Like token costs, waits count only
those that ended since the window's start.

The record holds no prompt, reply, or transcript text: skills and questions
are read from the transcript on this host, and only their names and times
leave it.

A checkout names its endpoint in a committed `.quest.json`:

```json
{ "records": "https://kixel.example/records" }
```

and each user pairs a token with it in `$XDG_CONFIG_HOME/quest/records.json`
(`~/.config`), so a checkout naming another endpoint, as a hostile branch
might, is never sent the token or a record:

```json
{ "https://kixel.example/records": "token" }
```

The endpoint must be HTTPS, or HTTP to `localhost`. A checkout naming none, or
an endpoint the user holds no token for, sends nothing, and the hook says so
once as a system message.

The `Stop` hook [`quest setup`](#configure-your-agent) installs for Claude Code
and Codex only queues the session under `$XDG_STATE_HOME/quest/records`. One
sender per user, `quest records sender`, sends the queue each minute at low
priority, so a busy host's stops never spend its GitHub quota. Its service
manager runs exactly one, so passes never overlap and a session's uploads reach
the store in order:

```console
$ quest records install
installed and started /home/me/.config/systemd/user/quest-records.service
```

`quest records install` writes a systemd user service on Linux or a launchd
agent on macOS running `quest records sender` with the current `PATH`, and
(re)starts it; rerun it after upgrading `quest`. `quest records uninstall`
removes it. While no sender runs, stops still queue, and the next stop says so
once per outage.

Each pass runs token-max on this host from the earliest queued session's first
instant, reads the PRs updated since then, and posts `{"records": [...]}` with
`Authorization: Bearer <token>`, one upload per endpoint and repository, each
queued session with its sub-agents, which stop without the hook naming them.
Each upload is checked with `parseUpload` before it is posted. A 2xx drops the
sessions from the queue, unless the hook queued one again meanwhile. A 401 or
403 drops them and sends that endpoint nothing more until the token changes.
Any other failure keeps them queued, and the next stop says so once. A failing
endpoint and repository back off, waiting a minute and doubling up to six
hours, and sit out the passes meanwhile, so they never widen the others'
window. token-max missing is retried each pass.

### Typed messages

`quest messages` prints the messages a person typed to agents on this host
Expand Down Expand Up @@ -2048,7 +2141,9 @@ background commands when it ends, and a session that ends its turn on one
leaves its result unread. A command is the agent's when its transcript holds
the ID Claude Code gave it on backgrounding it, so a sibling's gate does not
hold an agent. Claude Code lists a command only while it runs, so the
refusal ends with the gate. Codex runs no such hook.
refusal ends with the gate. Codex runs no such hook. Once the turn can end,
Claude Code's and Codex's `Stop` hooks queue the session's
[record](#session-records).

`quest` must be on the `PATH` the agent starts with, as for the worktree `--body`, a `body=` field,
`git commit -m`, `--body-file -` with a here-document, or a body file. A file
Expand Down
5 changes: 0 additions & 5 deletions quest/m0/benchmark/breakage.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,11 +45,6 @@ Agent's choices, for the implementer to revisit:
- Keep names of customers and private repositories out of anything this
public repository's records or PRs show.

## Required

- [Send each session's record to Kixel Code](/quest/m0/benchmark/records.md) -
the record type and the sending path breakage records reuse

## Related

- [Mistake audit](/quest/m0/mistake-audit.md) - classifies each escape as
Expand Down
31 changes: 31 additions & 0 deletions quest/m0/benchmark/records-endpoint.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# [XS] Name Kixel Code's record endpoint in each repository

## Goal

Every repository whose sessions Kixel Code should count commits a
`.quest.json` naming the record endpoint Kixel Code serves, so `quest`'s
stop hook sends each session's record there, and the maintainer's hosts
hold a token paired with it in `~/.config/quest/records.json`. One real
session on a moq-dev/quest PR then shows in Kixel Code's store, its
record equal to what `quest usage --json` prints for it.

## Plan

`quest` sends the records since
[moq-dev/quest#718](https://github.com/moq-dev/quest/pull/718): it posts
`{"records": [...]}`, each a `SessionRecord` from `quest/core`, with the
user's token as a bearer token, and takes a 401 or 403 as no access.

- Commit `.quest.json` here first, then in each repository the benchmark
covers, with the endpoint's final URL.
- Put the token on each host, and start its sender with `quest records
install`, through kixel-ai/dotfiles, which owns the
maintainer's host configuration, not by hand.

## Waiting

- kixel-ai/kixel.quest: its record store serves an endpoint that stores
`quest`'s upload with `parseUpload`, keeps each session's latest upload,
refuses a token it did not issue
with 401 or 403, and it says how a person gets a token; check that its
`quest/m0/records.md` is gone from its tree
53 changes: 0 additions & 53 deletions quest/m0/benchmark/records.md

This file was deleted.

5 changes: 0 additions & 5 deletions quest/m0/benchmark/time-split.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,6 @@ Agent's choices, for the implementer to revisit:
the categories and the one-line note leave the host; a note names no
customer and quotes no message.

## Required

- [Send each session's record to Kixel Code](/quest/m0/benchmark/records.md) -
the record the split is added to, and the exact waits it excludes

## Related

- [Mistake audit](/quest/m0/mistake-audit.md) - groups what went wrong
Expand Down
19 changes: 16 additions & 3 deletions src/cli/claims.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,9 +72,7 @@ export async function upstream(root: string, remote: Remote, deps: ClaimDependen
: (await repositoryInfo(client, pushed)).repository;
// The README where this checkout's tree sits, which may be below the top level.
const prefix = git(root, ["rev-parse", "--show-prefix"]);
let up = self;
while (up.parent !== null && (await has(client, up.parent, `${prefix}quest/README.md`)))
up = await repositoryInfo(client, up.parent);
const up = await base(client, self, prefix);
return {
// Reach an ancestor the way the remote is reached, so the same credentials work.
git: up === self ? remote.name : /^https:/.test(source.url) ? up.https : up.ssh,
Expand All @@ -85,6 +83,21 @@ export async function upstream(root: string, remote: Remote, deps: ClaimDependen
};
}

type Info = Awaited<ReturnType<typeof repositoryInfo>>;

/** `self`, or the nearest fork ancestor whose default branch keeps the quest tree at `prefix`. */
async function base(client: GitHub, self: Info, prefix: string): Promise<Info> {
let up = self;
while (up.parent !== null && (await has(client, up.parent, `${prefix}quest/README.md`)))
up = await repositoryInfo(client, up.parent);
return up;
}

/** The repository a checkout of `repository`, whose tree sits at `prefix`, opens its PRs on, as `upstream` finds it. */
export async function baseRepository(client: GitHub, repository: Repository, prefix: string): Promise<Repository> {
return (await base(client, await repositoryInfo(client, repository), prefix)).repository;
}

/** A repository as GitHub names it, its default branch and clone URLs, and its parent when it is a fork. */
async function repositoryInfo(
client: GitHub,
Expand Down
29 changes: 28 additions & 1 deletion src/cli/gate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import type { Readable } from "node:stream";
import { setTimeout as sleep } from "node:timers/promises";
import { type Cancellation, cancelled, follow, forwards, GROUP, interrupts, received, requested } from "./interrupt";
import { stubbed } from "./job";
import { envSessions, logWait } from "./waits";
import { questScope, scopeGroup, startTime, systemdScopes } from "./supervise";

/**
Expand Down Expand Up @@ -921,9 +922,11 @@ interface Observed {
export async function gate(command: string[], options: GateOptions = {}): Promise<number> {
const outermost = process.env[ADMITTED] !== "1";
const record = outermost ? recordFile(process.env) : undefined;
// An outermost gate in an agent session logs its waits for the session's record.
const waits = outermost && envSessions().length > 0;
const started = Date.now();
// Read now: the command may remove its own directory.
const directory = record ? process.cwd() : "";
const directory = record || waits ? process.cwd() : "";
const cancel = interrupts();
const observed: Observed = {};
try {
Expand All @@ -936,6 +939,30 @@ export async function gate(command: string[], options: GateOptions = {}): Promis
// The status the CLI exits with for a gate's own failure.
status = 1;
}
if (waits) {
const ended = Date.now();
const iso = (ms: number) => new Date(ms).toISOString();
try {
// A run cancelled before admission waited in the queue until it ended.
const admitted = observed.admitted ?? ended;
logWait(
{ kind: "gate-queue", start: iso(started), end: iso(admitted), until: null },
{ cwd: directory },
);
if (observed.admitted !== undefined)
logWait(
{ kind: "gate-run", start: iso(admitted), end: iso(ended), until: null },
{ cwd: directory },
);
} catch (error) {
// A cancelled run exits as it would have, logged or not.
if (cancel.signal.aborted) process.stderr.write(`quest gate: ${message(error)}\n`);
else {
failure = failure === undefined ? error : new Error(`${message(failure)}; ${message(error)}`);
status = 1;
}
}
}
if (!record) {
if (failure !== undefined) throw failure;
return status;
Expand Down
Loading
Loading