From 5577bb894d579befd9689798e116d0403d9b2ea3 Mon Sep 17 00:00:00 2001
From: Luke Curley
Date: Sun, 18 Oct 2026 22:10:09 -0700
Subject: [PATCH 01/10] chore(quest): claim quest/m0/kix/prompts
Co-Authored-By: Claude Opus 5.5
From d1d3094c5b2ea0167a01b5678a8a4a1c9ac9faa4 Mon Sep 17 00:00:00 2001
From: Luke Curley
Date: Sun, 18 Oct 2026 22:32:53 -0700
Subject: [PATCH 02/10] feat(worker): make the decision inbox the signed-in
home
Add /inbox: one active decision thread across a maintainer's enrolled projects, coalesced per job target, with agent questions, account-limit choices, failed jobs, new pull requests, new issues, and ready quests. Define the agent prompt contract (choices, recommendation, free text, permission vs decision) and validate prompts and answers. Job routes accept inbox form posts, bind decisions to the revision on screen, and refuse stale or repeated answers. Per-maintainer read state marks changed items unread and keeps skips until an item changes. Sign-in now lands on the inbox.
Co-Authored-By: Claude Opus 5.5
---
cloudflare/README.md | 33 +-
cloudflare/migrations/0008_inbox.sql | 13 +
cloudflare/src/inbox/model.ts | 247 +++++++++++++++
cloudflare/src/inbox/page.tsx | 458 +++++++++++++++++++++++++++
cloudflare/src/index.tsx | 83 ++++-
cloudflare/src/jobs/prompt.ts | 97 ++++++
cloudflare/src/jobs/protocol.ts | 5 +-
cloudflare/src/jobs/routes.ts | 77 ++++-
cloudflare/src/layout.tsx | 1 +
cloudflare/src/site.css | 136 ++++++++
cloudflare/test/github.test.ts | 27 +-
cloudflare/test/inbox.test.ts | 279 ++++++++++++++++
cloudflare/test/jobs.test.ts | 174 ++++------
cloudflare/test/logins.test.ts | 4 +-
cloudflare/test/project-fixtures.ts | 141 +++++++++
cloudflare/test/worker.test.ts | 4 +-
quest/m0/kix/README.md | 1 -
quest/m0/kix/activity.md | 1 -
quest/m0/kix/mobile.md | 4 -
quest/m0/kix/planning.md | 1 -
quest/m0/kix/prompts.md | 45 ---
quest/m0/theme/README.md | 1 -
quest/m0/theme/landing.md | 1 -
23 files changed, 1619 insertions(+), 214 deletions(-)
create mode 100644 cloudflare/migrations/0008_inbox.sql
create mode 100644 cloudflare/src/inbox/model.ts
create mode 100644 cloudflare/src/inbox/page.tsx
create mode 100644 cloudflare/src/jobs/prompt.ts
create mode 100644 cloudflare/test/inbox.test.ts
create mode 100644 cloudflare/test/project-fixtures.ts
delete mode 100644 quest/m0/kix/prompts.md
diff --git a/cloudflare/README.md b/cloudflare/README.md
index 6b58b444..1af94056 100644
--- a/cloudflare/README.md
+++ b/cloudflare/README.md
@@ -111,6 +111,35 @@ An account limit pauses the job with an account decision; kix never switches
accounts on its own. An answer requeues the job and resumes its session on the
runner that holds it.
+A `question` carries one round of independent questions
+(`src/jobs/prompt.ts`): `{ kind, questions: [{ header?, question, options:
+[{ label, description?, recommended? }], multiple? }] }`, where `kind` is
+`decision` (the default) or `permission` for a tool permission request. The
+service refuses a prompt it cannot render. An agent question's answer is
+`{ answers: [{ choices, text? }] }`, one per question, naming offered labels,
+the maintainer's own words, or both; an account question's answer is
+`{ runner, account }`. A job request may name the `revision` the maintainer
+saw, and is refused once observations moved past it.
+
+### Decision inbox
+
+After signing in, a maintainer lands on `/inbox` (`src/inbox/`): one active
+decision thread across the projects they enrolled, then what is up next, what
+is in progress, and their projects. Each item is one job target (`issues/1`,
+`pulls/2`, a quest path), so an issue, its planning job, and that job's
+questions stay one item, and webhook updates and reconnects never add another
+prompt. Agents waiting on an answer come first, then failed jobs, new pull
+requests, new issues, and ready quests. An item offers only actions that
+would work: none on a paused project, and only a refresh while observations
+are incomplete. Every action is a plain form post to the job routes, which
+recheck authority and return to the inbox; prompts render as escaped text and
+never preselect the recommendation.
+
+Each maintainer's read state lives in `inbox_items`, keyed by a version of what
+the item showed (a question, a GitHub change, a quest's text). A newer version
+is unread again, and skipping an item lasts until it changes. A finished or
+stopped job sets its item aside the same way.
+
`cloudflare/` is a Bun workspace of the repository root, so the Worker
imports the CLI's core as `quest/core` (`../src/core`), the same parser, checks,
and readiness the CLI runs.
@@ -169,7 +198,9 @@ replay, and ownership. Job tests race dispatch passes, reassign a disconnected
runner's job and fence its late writes, and cover routing, questions, account
limits, and cancellation. They drive real WebSocket connections through the hub,
including a replaced connection and the alarm that reassigns work, and check the
-maintainer-only routes. No OAuth credentials or
+maintainer-only routes. Inbox tests answer prompts through the forms, reject
+stale revisions and repeated answers, coalesce duplicate webhooks, and hide a
+project whose maintainer lost authority. No OAuth credentials or
Cloudflare account are required for checks.
After changing bindings, regenerate types:
diff --git a/cloudflare/migrations/0008_inbox.sql b/cloudflare/migrations/0008_inbox.sql
new file mode 100644
index 00000000..4431b451
--- /dev/null
+++ b/cloudflare/migrations/0008_inbox.sql
@@ -0,0 +1,13 @@
+-- Each maintainer's own view of a project's decision items, keyed by the job
+-- target an item coalesces on (`issues/1`, `pulls/2`, a quest path, ...).
+-- A version names what the item showed: a question, a GitHub change, a quest's
+-- text. A newer version is unread again; a skip lasts until the item changes.
+-- Read state is navigation only, never an answer or an approval.
+CREATE TABLE inbox_items (
+ user TEXT NOT NULL REFERENCES user(id) ON DELETE CASCADE,
+ repository INTEGER NOT NULL REFERENCES github_projects(id) ON DELETE CASCADE,
+ item TEXT NOT NULL,
+ seen TEXT,
+ skipped TEXT,
+ PRIMARY KEY(user, repository, item)
+);
diff --git a/cloudflare/src/inbox/model.ts b/cloudflare/src/inbox/model.ts
new file mode 100644
index 00000000..0c5a989c
--- /dev/null
+++ b/cloudflare/src/inbox/model.ts
@@ -0,0 +1,247 @@
+// The decision inbox: everything on a maintainer's projects that waits on them,
+// one item per job target. An issue, its planning job, and that job's questions
+// are one item, so updates, reconnects, and duplicate webhooks coalesce instead
+// of adding prompts. Agent text and GitHub data stay untrusted display data.
+
+import { parse } from "quest/core";
+import { GitHubError, type Enrollment, GitHubService } from "../github/service";
+import type { Activity, ProjectState } from "../github/state";
+import { type Prompt, parsePrompt } from "../jobs/prompt";
+import { hub } from "../jobs/routes";
+import * as store from "../jobs/store";
+
+export type JobView = NonNullable>>;
+export type QuestionView = JobView["questions"][number];
+/** A maintainer's own read state for one item. */
+export type Read = { repository: number; item: string; seen: string | null; skipped: string | null };
+
+/**
+ * What the item asks of the maintainer: answer an agent, pick an account for
+ * a limited job, retry a failure, or start work on an issue, PR, or ready
+ * quest. `null` is work in progress, which only informs.
+ */
+export type Need = "answer" | "account" | "retry" | "start";
+export type Kind = "issue" | "pull" | "quest" | "plan" | "devshell";
+
+export type Item = {
+ project: Enrollment;
+ /** The job target the item coalesces on, such as `issues/1` or `quest/a.md`. */
+ target: string;
+ kind: Kind;
+ title: string;
+ href: string;
+ activity: Activity | null;
+ /** The target's latest job. */
+ job: JobView | null;
+ /** The job's open question, and its prompt when an agent asked it. */
+ question: QuestionView | null;
+ prompt: Prompt | null;
+ need: Need | null;
+ /** What the item currently shows; a newer version is unread again and undoes a skip. */
+ version: string;
+ unread: boolean;
+ /** Skipped by the maintainer, or finished by its job, until the item changes. */
+ skipped: boolean;
+ at: number;
+};
+
+/** One project's place in the inbox, or why it is unavailable. */
+export type ProjectView = { project: Enrollment; state: ProjectState | null; error: string | null };
+
+// FNV-1a: a short, stable version for a quest's text.
+function digest(text: string) {
+ let hash = 0x811c9dc5;
+ for (let i = 0; i < text.length; i++) hash = Math.imul(hash ^ text.charCodeAt(i), 0x01000193);
+ return (hash >>> 0).toString(16);
+}
+
+function kindOf(target: string): Kind {
+ if (target.startsWith("issues/")) return "issue";
+ if (target.startsWith("pulls/")) return "pull";
+ if (target.startsWith("plans/")) return "plan";
+ return target === "devshell" ? "devshell" : "quest";
+}
+
+/** A quest's page on the project's board: its path without `.md`, or its epic's directory. */
+export function questHref(project: Enrollment, path: string) {
+ return `/projects/github/${project.owner}/${project.name}/board/${path.replace(/(\/README)?\.md$/, "")}`;
+}
+
+type Entity = Pick;
+
+/** The open issues, open pull requests, and ready quests a maintainer can act on. */
+function entities(project: Enrollment, state: ProjectState | null) {
+ const found = new Map();
+ if (!state) return found;
+ const web = `https://github.com/${project.owner}/${project.name}`;
+ for (const activity of state.activity) {
+ // A webhook's pointer has no data until a refresh reads it, but it is already news.
+ if (activity.missing || (activity.data && activity.data.state !== "OPEN")) continue;
+ const pull = activity.kind === "pull";
+ found.set(`${pull ? "pulls" : "issues"}/${activity.number}`, {
+ kind: activity.kind,
+ title: activity.data?.title ?? `${pull ? "Pull request" : "Issue"} #${activity.number}`,
+ href: `${web}/${pull ? "pull" : "issues"}/${activity.number}`,
+ activity,
+ version: String(activity.changedAt),
+ at: activity.changedAt,
+ });
+ }
+ for (const path of state.available) {
+ const content = state.project.documents.find((d) => d.path === path)?.content ?? "";
+ found.set(path, {
+ kind: "quest",
+ title: parse(path, content).title?.text ?? path,
+ href: questHref(project, path),
+ activity: null,
+ version: digest(content),
+ at: 0,
+ });
+ }
+ return found;
+}
+
+function fallback(project: Enrollment, target: string): Entity {
+ const web = `https://github.com/${project.owner}/${project.name}`;
+ const kind = kindOf(target);
+ const number = target.split("/")[1];
+ const titles: Record = {
+ issue: [`Issue #${number}`, `${web}/issues/${number}`],
+ pull: [`Pull request #${number}`, `${web}/pull/${number}`],
+ plan: ["New plan", web],
+ devshell: ["Add a flake devShell", web],
+ quest: [target, questHref(project, target)],
+ };
+ const [title, href] = titles[kind];
+ return { kind, title, href, activity: null, version: "", at: 0 };
+}
+
+/** Build one project's items from its observations, its latest jobs, and the maintainer's read state. */
+export function decisions(view: ProjectView, jobs: JobView[], reads: Read[]): Item[] {
+ const { project, state } = view;
+ const latest = new Map();
+ // Jobs arrive newest first.
+ for (const job of jobs) if (!latest.has(job.target)) latest.set(job.target, job);
+ const found = entities(project, state);
+ const items: Item[] = [];
+ for (const target of new Set([...found.keys(), ...latest.keys()])) {
+ const entity = found.get(target);
+ const job = latest.get(target) ?? null;
+ // GitHub showed a change after the job ended, such as a new push or comment.
+ const changed = !!entity && !!job && entity.at > job.updatedAt;
+ let need: Need | null = null;
+ let version = entity?.version ?? "";
+ let question: QuestionView | null = null;
+ let ended = false;
+ if (job && (job.state === "queued" || job.state === "running")) version = `job:${job.id}`;
+ else if (job?.state === "waiting") {
+ question = job.questions.find((q) => q.attempt === job.attempt && q.answer === null) ?? null;
+ if (question) need = question.kind === "account" ? "account" : "answer";
+ version = `question:${question?.id}`;
+ } else if (job?.state === "failed" && !changed) {
+ need = "retry";
+ version = `job:${job.id}`;
+ } else if (entity) {
+ // Starting new work on a paused project would be refused, so it is not offered.
+ if (project.state !== "enabled") continue;
+ need = "start";
+ ended = !!job && !changed;
+ } else continue;
+ const read = reads.find((r) => r.repository === project.id && r.item === target);
+ let prompt: Prompt | null = null;
+ if (question?.kind === "agent")
+ try {
+ prompt = parsePrompt(question.prompt);
+ } catch {}
+ items.push({
+ ...(entity ?? fallback(project, target)),
+ project,
+ target,
+ job,
+ question,
+ prompt,
+ need,
+ version,
+ unread: need !== null && read?.seen !== version,
+ skipped: need !== null && (ended || read?.skipped === version),
+ at: question?.askedAt ?? (need === "retry" ? job!.updatedAt : (entity?.at ?? job?.updatedAt ?? 0)),
+ });
+ }
+ return items;
+}
+
+// Agents blocked on an answer come first, then failures, then new PRs,
+// issues, and ready quests: the loop of reviewing, planning, and spawning.
+const order = (item: Item) =>
+ item.need === "answer" || item.need === "account"
+ ? 0
+ : item.need === "retry"
+ ? 1
+ : ({ pull: 2, issue: 3, quest: 4, plan: 5, devshell: 5 } as const)[item.kind];
+
+/** Sort the items: decisions in priority order, then work in progress, then what was set aside. */
+export function arrange(items: Item[]) {
+ const sorted = [...items].sort((a, b) => order(a) - order(b) || (order(a) === 0 ? a.at - b.at : b.at - a.at));
+ return {
+ decisions: sorted.filter((item) => item.need !== null && !item.skipped),
+ working: sorted.filter((item) => item.need === null),
+ skipped: sorted.filter((item) => item.skipped),
+ };
+}
+
+export type Runners = Awaited>;
+
+/**
+ * Load the inbox across the projects a maintainer enrolled. Every project
+ * rechecks current GitHub authority; one that fails is reported, not hidden.
+ */
+export async function load(env: Env, user: string) {
+ const github = new GitHubService(env);
+ const projects: ProjectView[] = await Promise.all(
+ (await github.projects(user)).map(async (project) => {
+ try {
+ return { project, state: await github.projectState(project, user), error: null };
+ } catch (error) {
+ if (!(error instanceof GitHubError)) throw error;
+ return { project, state: null, error: error.message };
+ }
+ }),
+ );
+ const usable = projects.filter((view) => !view.error);
+ const [reads, jobs] = await Promise.all([
+ env.DB.prepare("SELECT repository,item,seen,skipped FROM inbox_items WHERE user=?").bind(user).all(),
+ Promise.all(usable.map((view) => store.listJobs(env.DB, view.project.id))),
+ ]);
+ const items = usable.flatMap((view, i) => decisions(view, jobs[i]!, reads.results));
+ // Jobs run on their requester's runners, so presence comes from each requester's hub.
+ const actors = [...new Set(items.flatMap((item) => (item.job ? [item.job.actor] : [])))];
+ const runners = new Map(
+ await Promise.all(
+ actors.map(
+ async (actor) =>
+ [actor, await store.listRunners(env.DB, actor, new Set(await hub(env, actor).online()))] as const,
+ ),
+ ),
+ );
+ return { projects, items, runners };
+}
+
+/** Record that the maintainer saw an item as it is now. */
+export function markSeen(db: D1Database, user: string, item: Item) {
+ return db
+ .prepare(
+ "INSERT INTO inbox_items(user,repository,item,seen) VALUES(?,?,?,?) ON CONFLICT(user,repository,item) DO UPDATE SET seen=excluded.seen",
+ )
+ .bind(user, item.project.id, item.target, item.version)
+ .run();
+}
+
+/** Set an item aside until it changes. */
+export function skip(db: D1Database, user: string, repository: number, item: string, version: string) {
+ return db
+ .prepare(
+ "INSERT INTO inbox_items(user,repository,item,skipped) VALUES(?,?,?,?) ON CONFLICT(user,repository,item) DO UPDATE SET skipped=excluded.skipped",
+ )
+ .bind(user, repository, item, version)
+ .run();
+}
diff --git a/cloudflare/src/inbox/page.tsx b/cloudflare/src/inbox/page.tsx
new file mode 100644
index 00000000..25a87935
--- /dev/null
+++ b/cloudflare/src/inbox/page.tsx
@@ -0,0 +1,458 @@
+import type { Pull } from "../github/state";
+import { type Prompt, parsePrompt } from "../jobs/prompt";
+import { inboxURL } from "../jobs/routes";
+import { Marker } from "../layout";
+import { projectURL } from "../projects";
+import type { Item, ProjectView, QuestionView, Runners } from "./model";
+
+// The decision inbox, phone first: one active decision thread, then what is
+// up next, what is in progress, and the projects as secondary views. Plain
+// forms post every action, so it works by keyboard and without JavaScript.
+
+export type InboxProps = {
+ user: string;
+ projects: ProjectView[];
+ active: Item | null;
+ /** The requested item is gone, such as a question another maintainer answered. */
+ missing: boolean;
+ decisions: Item[];
+ working: Item[];
+ skipped: Item[];
+ runners: Map;
+};
+
+const kinds = { pull: "Pull request", issue: "Issue", quest: "Ready quest", plan: "Plan", devshell: "Bootstrap" };
+
+function needLabel(item: Item) {
+ if (item.need === "answer")
+ return item.prompt?.kind === "permission" ? "Tool permission request" : "Waiting for your decision";
+ if (item.need === "account") return "Account limited";
+ if (item.need === "retry") return "Failed";
+ if (item.need === "start")
+ return item.skipped && item.job ? `${kinds[item.kind]} · job ${item.job.state}` : kinds[item.kind];
+ return item.job?.state === "queued" ? "Queued" : "Working";
+}
+
+/** Plainly what the item's job is doing, with the next useful action where there is one. */
+function progress(item: Item, runners: Runners, user: string) {
+ const job = item.job;
+ if (!job || item.need === "start") return null;
+ const whose = job.actor === user ? "your" : "the requester's";
+ if (job.state === "queued")
+ return runners.some((r) => r.online)
+ ? "Queued for the next free runner."
+ : `Waiting for a runner: none of ${whose} runners are online. Run quest runner on one of them.`;
+ if (job.state === "running") {
+ if (job.cancel) return "Stopping as requested.";
+ const runner = runners.find((r) => r.id === job.runner);
+ if (job.lease !== null || !runner?.online)
+ return "Runner offline. The job moves to another runner if it stays away for two minutes.";
+ return `Working on ${runner.name}.`;
+ }
+ if (item.need === "account")
+ return "The job's account hit its limit. Choose an account to continue; kix never switches accounts on its own.";
+ if (item.need === "retry") return "The job failed. Its summary says what happened.";
+ return null;
+}
+
+function Context(props: { item: Item }) {
+ const data = props.item.activity?.data;
+ if (!data) return null;
+ const pull = props.item.kind === "pull" ? (data as Pull) : null;
+ return (
+
+ ,
+ status,
+ );
+}
+
// The server API bypasses Better Auth's HTTP middleware. Guard form routes
// before calling it; the /api/auth/* handler enforces its own Origin/CSRF checks.
for (const path of ["/sign-in", "/sign-out", "/link", "/passkeys/delete"]) {
@@ -359,7 +429,7 @@ app.get("/sign-in", (c) => {
{ title: "Sign in", description: "Sign in to Quest with GitHub, Google, or a passkey." },
,
);
@@ -367,7 +437,7 @@ app.get("/sign-in", (c) => {
app.post("/sign-in", async (c) => {
// Return to the page that signed in, if it names a path on this site.
const body = await c.req.parseBody();
- const callbackURL = typeof body.next === "string" && local(body.next) ? body.next : "/";
+ const callbackURL = typeof body.next === "string" && local(body.next) ? body.next : "/inbox";
const chosen = body.provider === undefined ? "github" : provider(c, body.provider);
if (!chosen) return c.json({ error: "Sign-in method unavailable" }, 400);
return oauth(
@@ -431,8 +501,11 @@ app.post("/passkeys/delete", async (c) => {
return redirect(response, "/account");
});
app.onError((error, c) => {
- if (error instanceof GitHubError) return c.json({ error: error.message }, 403);
- if (error instanceof JobError) return c.json({ error: error.message }, 409);
+ const status = error instanceof GitHubError ? 403 : error instanceof JobError ? 409 : null;
+ // A refused inbox form explains itself and leads back, rather than showing JSON.
+ if (status && fromForm(c)) return refused(c, error.message, error instanceof BootstrapRequired, status);
+ if (error instanceof BootstrapRequired) return c.json({ error: error.message, bootstrap: true }, 409);
+ if (status) return c.json({ error: error.message }, status);
console.error({ event: "request_failed", path: c.req.path, name: error.name });
return c.json({ error: "Request failed" }, 500);
});
diff --git a/cloudflare/src/jobs/prompt.ts b/cloudflare/src/jobs/prompt.ts
new file mode 100644
index 00000000..a631fc3b
--- /dev/null
+++ b/cloudflare/src/jobs/prompt.ts
@@ -0,0 +1,97 @@
+// The prompt contract between agents and the decision inbox. An agent asks one
+// round of independent questions; dependent questions wait for a later round.
+// The UI renders the round and returns only what the maintainer chose or wrote.
+// Prompts and answers are untrusted data, so both are validated here.
+import { JobError } from "./store";
+
+export type Option = { label: string; description?: string; recommended?: boolean };
+export type Ask = { header?: string; question: string; options: Option[]; multiple?: boolean };
+/**
+ * A product decision for the maintainer, or a tool permission request from the
+ * agent's harness. Ordinary progress is a checkpoint summary, never a prompt.
+ */
+export type Prompt = { kind: "decision" | "permission"; questions: Ask[] };
+/** One reply per question, in order: chosen option labels, free text, or both. */
+export type Answer = { answers: { choices: string[]; text?: string }[] };
+
+const limits = { questions: 10, options: 10, header: 60, question: 2000, label: 200, description: 2000, text: 4000 };
+
+function record(value: unknown): Record | null {
+ return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : null;
+}
+function string(value: unknown, max: number, optional = false): string | undefined {
+ if (value === undefined && optional) return undefined;
+ if (typeof value !== "string" || !value.trim() || value.length > max) throw new JobError("Invalid prompt");
+ return value;
+}
+
+/** Validate a runner's prompt. */
+export function parsePrompt(value: unknown): Prompt {
+ const body = record(value);
+ const kind = body?.kind ?? "decision";
+ if (!body || (kind !== "decision" && kind !== "permission")) throw new JobError("Invalid prompt");
+ if (!Array.isArray(body.questions) || !body.questions.length || body.questions.length > limits.questions)
+ throw new JobError("A prompt asks 1 to 10 questions");
+ const questions = body.questions.map((raw): Ask => {
+ const ask = record(raw);
+ if (!ask || !Array.isArray(ask.options) || ask.options.length > limits.options)
+ throw new JobError("Each question offers at most 10 options");
+ if (ask.multiple !== undefined && typeof ask.multiple !== "boolean") throw new JobError("Invalid prompt");
+ const options = ask.options.map((raw): Option => {
+ const option = record(raw);
+ if (!option || (option.recommended !== undefined && typeof option.recommended !== "boolean"))
+ throw new JobError("Invalid prompt");
+ return {
+ label: string(option.label, limits.label)!,
+ ...(option.description !== undefined && {
+ description: string(option.description, limits.description),
+ }),
+ ...(option.recommended && { recommended: true }),
+ };
+ });
+ if (new Set(options.map((o) => o.label)).size !== options.length)
+ throw new JobError("Option labels must be unique");
+ return {
+ ...(ask.header !== undefined && { header: string(ask.header, limits.header) }),
+ question: string(ask.question, limits.question)!,
+ options,
+ ...(ask.multiple && { multiple: true }),
+ };
+ });
+ return { kind, questions };
+}
+
+/** Validate a maintainer's answer against the question it answers. */
+export function parseAnswer(prompt: Prompt, value: unknown): Answer {
+ const answers = record(value)?.answers;
+ if (!Array.isArray(answers) || answers.length !== prompt.questions.length)
+ throw new JobError("Answer every question");
+ return {
+ answers: prompt.questions.map((ask, i) => {
+ const reply = record(answers[i]);
+ const choices = reply?.choices ?? [];
+ const text = typeof reply?.text === "string" ? reply.text.trim() : reply?.text;
+ if (
+ !Array.isArray(choices) ||
+ choices.some((c) => !ask.options.some((o) => o.label === c)) ||
+ new Set(choices).size !== choices.length ||
+ (!ask.multiple && choices.length > 1) ||
+ (text !== undefined && (typeof text !== "string" || text.length > limits.text))
+ )
+ throw new JobError("Choose from the offered options");
+ if (!choices.length && !text) throw new JobError("Answer every question");
+ return { choices: choices as string[], ...(text && { text }) };
+ }),
+ };
+}
+
+/**
+ * Read an answer from the inbox form: `q` holds the chosen labels and
+ * `q-text` the maintainer's own words.
+ */
+export function formAnswer(prompt: Prompt, form: Record): Answer {
+ const values = (name: string) => [form[name] ?? []].flat().filter((v): v is string => typeof v === "string");
+ return parseAnswer(prompt, {
+ answers: prompt.questions.map((_, i) => ({ choices: values(`q${i}`), text: values(`q${i}-text`)[0] })),
+ });
+}
diff --git a/cloudflare/src/jobs/protocol.ts b/cloudflare/src/jobs/protocol.ts
index 300bb489..89d7ee07 100644
--- a/cloudflare/src/jobs/protocol.ts
+++ b/cloudflare/src/jobs/protocol.ts
@@ -1,4 +1,5 @@
// Runner messages over the live connection. Every field is untrusted input.
+import { type Prompt, parsePrompt } from "./prompt";
import { type AccountReport, type Attempt, JobError, type Outcome } from "./store";
export function text(value: unknown, max: number): string | null {
@@ -29,7 +30,7 @@ function count(value: unknown): number {
export type RunnerMessage = { id?: string | number } & (
| { type: "status"; accounts: AccountReport[]; attempts: Attempt[]; slots: number }
| ({ type: "checkpoint"; summary: string; session: string | null } & Attempt)
- | ({ type: "question"; summary: string; session: string; prompt: unknown } & Attempt)
+ | ({ type: "question"; summary: string; session: string; prompt: Prompt } & Attempt)
| ({ type: "output"; seq: number; text: string } & Attempt)
| ({ type: "result"; outcome: Outcome; summary: string; result: unknown } & Attempt)
);
@@ -119,7 +120,7 @@ function fields(body: Record, ref: { id?: string | number }): R
...target(body),
summary,
session,
- prompt: data(body.prompt, 64 * 1024),
+ prompt: parsePrompt(data(body.prompt, 64 * 1024)),
};
}
case "output":
diff --git a/cloudflare/src/jobs/routes.ts b/cloudflare/src/jobs/routes.ts
index f891d557..5a492d26 100644
--- a/cloudflare/src/jobs/routes.ts
+++ b/cloudflare/src/jobs/routes.ts
@@ -3,6 +3,7 @@ import { createAuth } from "../auth";
import { GitHubError, GitHubService, type Stopped } from "../github/service";
import type { ProjectState, Pull } from "../github/state";
import { RUNNER_HEADER } from "./hub";
+import { formAnswer, parseAnswer, parsePrompt } from "./prompt";
import { data, id, text } from "./protocol";
import * as store from "./store";
import { JobError } from "./store";
@@ -18,8 +19,42 @@ async function json(c: Context): Promise> {
throw new JobError("JSON object required");
}
+/** Whether a request came from an HTML form, such as the decision inbox, rather than the JSON API. */
+export function fromForm(c: Context) {
+ return /^(application\/x-www-form-urlencoded|multipart\/form-data)\b/.test(c.req.header("Content-Type") ?? "");
+}
+type Form = Record;
+/** A JSON body, or a form's non-empty text fields. */
+async function input(c: Context): Promise<{ body: Record; form: Form | null }> {
+ if (!fromForm(c)) return { body: await json(c), form: null };
+ const form = await c.req.parseBody({ all: true });
+ const body = Object.fromEntries(Object.entries(form).filter(([, v]) => typeof v === "string" && v !== ""));
+ return { body, form };
+}
+/** Answer the JSON API, or return a form to the decision it acted on. */
+function reply(
+ c: Context,
+ form: Form | null,
+ item: { repository: number; target: string },
+ body: unknown,
+ status: 200 | 201 = 200,
+) {
+ if (form) return c.redirect(inboxURL(item.repository, item.target), 303);
+ return body === null ? c.body(null, 204) : c.json(body, status);
+}
+export function inboxURL(repository: number, target: string) {
+ return `/inbox?${new URLSearchParams({ project: String(repository), item: target })}`;
+}
+
+/** The default branch has no flake for the job's devShell; a bootstrap job can add one. */
+export class BootstrapRequired extends JobError {
+ constructor() {
+ super("A flake devShell is required; start a bootstrap job to add one");
+ }
+}
+
/** The owner's hub, which holds their runners' connections and routes their jobs. */
-function hub(env: Env, owner: string) {
+export function hub(env: Env, owner: string) {
return env.RUNNERS.get(env.RUNNERS.idFromName(owner));
}
@@ -177,7 +212,7 @@ function resolve(workflow: unknown, target: unknown, state: ProjectState): Plan
*/
jobs.post(base, async (c) => {
const { user, repo, github } = await maintained(c);
- const body = await json(c);
+ const { body, form } = await input(c);
const pinRunner = body.runner === undefined ? null : id(body.runner);
const pinAccount = body.account === undefined ? null : id(body.account);
if ((body.runner !== undefined && !pinRunner) || (body.account !== undefined && !pinAccount))
@@ -185,15 +220,15 @@ jobs.post(base, async (c) => {
const { job, enabled, flake } = await github.jobSource(repo, user, (state) =>
resolve(body.workflow, body.target, state),
);
+ // A decision binds the revision the maintainer saw, such as a pull request's head.
+ if (body.revision !== undefined && body.revision !== job.revision)
+ throw new JobError("This changed on GitHub since you looked; review it again");
if (!enabled) throw new JobError("Contributions are paused");
if (job.workflow === "bootstrap") {
if (flake) throw new JobError("The default branch already has a flake");
} else if (!flake) {
if (job.workflow === "iterate") throw new JobError("The pull request's head has no flake devShell");
- return c.json(
- { error: "A flake devShell is required; start a bootstrap job to add one", bootstrap: true },
- 409,
- );
+ throw new BootstrapRequired();
}
const created = await store.createJob(
c.env.DB,
@@ -201,25 +236,41 @@ jobs.post(base, async (c) => {
Date.now(),
);
await hub(c.env, user).dispatch(user);
- return c.json(created, created.created ? 201 : 200);
+ return reply(c, form, { repository: repo.id, target: job.target }, created, created.created ? 201 : 200);
});
jobs.post(`${base}/:job/answer`, async (c) => {
const { user, job } = await owned(c);
- const body = await json(c);
- const question = id(body.question);
- if (!question || body.answer === undefined) throw new JobError("Question and answer required");
- await store.answer(c.env.DB, job.id, question, user, data(body.answer, 64 * 1024), Date.now());
+ const { body, form } = await input(c);
+ const question = job.questions.find((q) => q.id === id(body.question));
+ const answer = question && (form ? formReply(question, form) : body.answer);
+ if (!question || answer === undefined) throw new JobError("Question and answer required");
+ await store.answer(
+ c.env.DB,
+ job.id,
+ question.id,
+ user,
+ // An agent's answer must fit its prompt; the store checks an account choice against the owner's accounts.
+ question.kind === "agent" ? parseAnswer(parsePrompt(question.prompt), answer) : data(answer, 1024),
+ Date.now(),
+ );
// Jobs run on their requester's runners, whoever answers.
await hub(c.env, job.actor).dispatch(job.actor);
- return c.body(null, 204);
+ return reply(c, form, job, null);
});
+/** An inbox form's answer: the chosen options, or the chosen `runner:account`. */
+function formReply(question: store.Question, form: Form) {
+ if (question.kind === "agent") return formAnswer(parsePrompt(question.prompt), form);
+ const [runner, account] = typeof form.account === "string" ? form.account.split(":") : [];
+ return runner && account ? { runner, account } : undefined;
+}
+
jobs.post(`${base}/:job/cancel`, async (c) => {
const { user, job } = await owned(c);
await store.cancel(c.env.DB, job.id, user, Date.now());
await hub(c.env, job.actor).dispatch(job.actor);
- return c.body(null, 204);
+ return reply(c, fromForm(c) ? {} : null, job, null);
});
// Raw output stays behind a per-request maintainer check; summaries are the default view.
diff --git a/cloudflare/src/layout.tsx b/cloudflare/src/layout.tsx
index 4da78ca8..2f25a760 100644
--- a/cloudflare/src/layout.tsx
+++ b/cloudflare/src/layout.tsx
@@ -53,6 +53,7 @@ export function Layout(props: Page & { children: Child }) {
Quest
+ {props.user && Decisions}
QuestsProjectsDocs
diff --git a/cloudflare/src/site.css b/cloudflare/src/site.css
index ea544f70..81d8eb68 100644
--- a/cloudflare/src/site.css
+++ b/cloudflare/src/site.css
@@ -1205,3 +1205,139 @@ a.entry-title:hover {
.project-select button {
justify-self: start;
}
+
+/* The decision inbox: one active decision thread, phone first. */
+.inbox {
+ max-width: 46rem;
+}
+.inbox > section {
+ margin-block: 1.75rem;
+}
+.decision {
+ display: grid;
+ gap: 0.75rem;
+ margin-block: 1rem 1.5rem;
+}
+.decision h2 {
+ margin: 0;
+ font-size: 1.5rem;
+ overflow-wrap: anywhere;
+}
+.decision h3 {
+ margin: 0 0 0.25rem;
+ font-size: 1rem;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+}
+.decision p {
+ margin: 0;
+}
+/* Agent text keeps its line breaks but stays escaped text. */
+.decision .summary p,
+.history p {
+ white-space: pre-wrap;
+ overflow-wrap: anywhere;
+}
+.decision-meta {
+ color: var(--ql-muted);
+}
+.decision-form {
+ display: grid;
+ gap: 1rem;
+}
+.decision-form button {
+ justify-self: start;
+}
+.ask {
+ display: grid;
+ gap: 0.5rem;
+ margin: 0;
+ padding: 0;
+ border: 0;
+}
+.ask legend {
+ padding: 0;
+ margin-bottom: 0.5rem;
+ font-weight: 600;
+ white-space: pre-wrap;
+ overflow-wrap: anywhere;
+}
+/* Each choice is a full-width tap target. */
+.choice {
+ display: grid;
+ grid-template-columns: 1.25rem 1fr;
+ gap: 0.6rem;
+ align-items: start;
+ padding: 0.7rem 0.8rem;
+ background: var(--ql-bg);
+ border: 1px solid var(--ql-rule);
+ border-radius: 4px;
+ cursor: pointer;
+}
+.choice:has(input:checked) {
+ border-color: var(--ql-accent);
+}
+.choice input {
+ margin: 0.2rem 0 0;
+ accent-color: var(--ql-accent);
+}
+.choice > span {
+ display: grid;
+ gap: 0.2rem;
+ overflow-wrap: anywhere;
+}
+.choice-why {
+ color: var(--ql-muted);
+ font-size: 0.92rem;
+ white-space: pre-wrap;
+}
+.tag {
+ justify-self: start;
+ padding: 0.05rem 0.4rem;
+ font: 600 0.75rem/1.4 var(--ql-font-display);
+ letter-spacing: 0.1em;
+ text-transform: uppercase;
+ color: var(--ql-bg);
+ background: var(--ql-accent);
+ border-radius: 2px;
+}
+.ask .free {
+ font-size: 0.92rem;
+ color: var(--ql-muted);
+}
+.ask textarea {
+ min-width: 0;
+ padding: 0.6rem;
+ color: var(--ql-text);
+ background: var(--ql-bg);
+ border: 1px solid var(--ql-rule);
+ border-radius: 4px;
+ font: inherit;
+ resize: vertical;
+}
+.decision-actions,
+.project-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+ align-items: center;
+}
+.decision-actions form,
+.project-actions form {
+ margin: 0;
+}
+.decision-actions > p {
+ flex-basis: 100%;
+}
+.history summary,
+.skipped summary {
+ cursor: pointer;
+}
+.history ol {
+ display: grid;
+ gap: 0.5rem;
+ padding-left: 1.25rem;
+}
+.entry-title[aria-current] {
+ color: var(--ql-accent);
+}
diff --git a/cloudflare/test/github.test.ts b/cloudflare/test/github.test.ts
index f3f696dd..163429f0 100644
--- a/cloudflare/test/github.test.ts
+++ b/cloudflare/test/github.test.ts
@@ -5,37 +5,12 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import app from "../src/index";
import { GitHubService, type GitHubRepository } from "../src/github/service";
import { deliveryRetention } from "../src/github/webhook";
+import { delivery } from "./project-fixtures";
const testEnv = { ...env, GITHUB_APP_PRIVATE_KEY: privateKey };
const repo: GitHubRepository = { owner: "upstream", name: "project", id: 10 };
const fork: GitHubRepository = { owner: "quest-bot-forks", name: "project", id: 20 };
-async function delivery(
- event: string,
- payload: unknown,
- id = crypto.randomUUID(),
- changes: Record = {},
-) {
- const body = typeof payload === "string" ? payload : JSON.stringify(payload);
- const key = await crypto.subtle.importKey(
- "raw",
- new TextEncoder().encode(env.GITHUB_WEBHOOK_SECRET),
- { name: "HMAC", hash: "SHA-256" },
- false,
- ["sign"],
- );
- const digest = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(body));
- const signature = "sha256=" + Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, "0")).join("");
- return app.request(
- "http://localhost:8787/webhooks/github",
- {
- method: "POST",
- headers: { "X-GitHub-Event": event, "X-GitHub-Delivery": id, "X-Hub-Signature-256": signature, ...changes },
- body,
- },
- env,
- );
-}
function installation(action: string) {
return { action, installation: { id: 5, app_id: 123 } };
}
diff --git a/cloudflare/test/inbox.test.ts b/cloudflare/test/inbox.test.ts
new file mode 100644
index 00000000..f9eac6c5
--- /dev/null
+++ b/cloudflare/test/inbox.test.ts
@@ -0,0 +1,279 @@
+import { env } from "cloudflare:workers";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import app from "../src/index";
+import * as store from "../src/jobs/store";
+import { privateKey } from "./github-key";
+import { origin } from "./login-fixtures";
+import { commit, delivery, head, maintainerOf, project } from "./project-fixtures";
+
+const testEnv = { ...env, GITHUB_APP_PRIVATE_KEY: privateKey };
+const db = env.DB;
+const jobs = "/projects/github/upstream/project/jobs";
+
+let user = "";
+let cookie = "";
+let github: Awaited>["github"];
+
+beforeEach(async () => {
+ await db.batch([
+ db.prepare("DELETE FROM jobs"),
+ db.prepare("DELETE FROM runners"),
+ db.prepare("DELETE FROM github_projects"),
+ db.prepare("DELETE FROM github_installations"),
+ ]);
+ ({ user, cookie, github } = await maintainerOf());
+});
+afterEach(() => vi.restoreAllMocks());
+
+async function inbox(query: Record = {}) {
+ const response = await app.request(
+ `${origin}/inbox?${new URLSearchParams(query)}`,
+ { headers: { Cookie: cookie } },
+ testEnv,
+ );
+ expect(response.status).toBe(200);
+ return response.text();
+}
+/** The active decision thread's markup. */
+function thread(html: string) {
+ return html.match(//)?.[0] ?? "";
+}
+function submit(path: string, fields: [string, string][]) {
+ return app.request(
+ `${origin}${path}`,
+ { method: "POST", headers: { Cookie: cookie, Origin: origin }, body: new URLSearchParams(fields) },
+ testEnv,
+ );
+}
+async function running(workflow: store.Workflow = "start", target = "quest/first.md") {
+ const machine = await store.createRunner(db, user, "laptop", "linux", false, 0);
+ const runner = { ...machine, owner: user, name: "laptop", platform: "linux" as const, vm: 0 };
+ await store.report(
+ db,
+ runner,
+ [
+ { id: "main", provider: "anthropic", kind: "subscription", label: "Main plan", limited: false },
+ { id: "spare", provider: "anthropic", kind: "subscription", label: "Spare plan", limited: false },
+ ],
+ [],
+ 0,
+ );
+ const branch = target.replace(/\.md$/, "");
+ const { id } = await store.createJob(
+ db,
+ { repository: 10, actor: user, workflow, target, revision: commit, branch, trusted: true },
+ 1,
+ );
+ await store.dispatch(db, user, new Map([[runner.id, 1]]), 2);
+ return { id, runner };
+}
+
+describe("decision inbox", () => {
+ it("sends visitors to sign in, and maintainers there after signing in", async () => {
+ const visitor = await app.request(`${origin}/inbox`, {}, testEnv);
+ expect(visitor.status).toBe(303);
+ expect(visitor.headers.get("Location")).toBe("/sign-in?next=/inbox");
+ const signIn = await (await app.request(`${origin}/sign-in`, {}, testEnv)).text();
+ expect(signIn).toContain('name="next" value="/inbox"');
+ });
+
+ it("presents each new pull request, issue, and ready quest as one decision", async () => {
+ const html = await inbox();
+ // The pull request is the active decision, bound to the head on screen.
+ const active = thread(html);
+ expect(active).toContain("Fix");
+ expect(active).toContain("Checks: SUCCESS");
+ expect(active).toContain(`name="revision" value="${head}"`);
+ expect(active).toContain("Iterate on this PR");
+ expect(active).toContain("Review on GitHub");
+ expect(html.match(/
Up next<\/h2>[\s\S]*?<\/ol>/)![0]).toMatch(/Idea[\s\S]*New[\s\S]*First[\s\S]*New/);
+ expect(await db.prepare("SELECT item,seen FROM inbox_items WHERE user=?").bind(user).all()).toMatchObject({
+ results: [{ item: "pulls/2", seen: "0" }],
+ });
+ const issue = thread(await inbox({ project: "10", item: "issues/1" }));
+ expect(issue).toContain("By reporter");
+ expect(issue).toContain("Plan this issue");
+ expect(thread(await inbox({ project: "10", item: "quest/first.md" }))).toContain("Start this quest");
+ expect(await inbox({ project: "10", item: "issues/99" })).toContain("no longer open");
+ });
+
+ it("coalesces webhook updates into the same item and marks it unread again", async () => {
+ await inbox();
+ const event = {
+ action: "synchronize",
+ installation: { id: 5 },
+ repository: { id: 10 },
+ pull_request: { id: 21, number: 2 },
+ };
+ const id = crypto.randomUUID();
+ expect((await delivery("pull_request", event, id)).status).toBe(202);
+ expect(await (await delivery("pull_request", event, id)).json()).toEqual({ status: "duplicate" });
+ expect((await delivery("pull_request", event)).status).toBe(202);
+ const opened = { ...event, action: "opened", issue: { id: 13, number: 3 }, pull_request: undefined };
+ expect((await delivery("issues", opened)).status).toBe(202);
+ const html = await inbox({ project: "10", item: "issues/1" });
+ expect(html.match(/>FixNew/);
+ // A webhook only points at the new issue; refreshing reads it, and no work starts before.
+ expect(html).toContain("Issue #3");
+ expect(thread(html)).toContain("GitHub reported changes");
+ expect(thread(html)).not.toContain("Plan this issue");
+ });
+
+ it("starts work bound to the revision on screen and shows its progress plainly", async () => {
+ const stale = await submit(jobs, [
+ ["workflow", "iterate"],
+ ["target", "pulls/2"],
+ ["revision", "b".repeat(40)],
+ ]);
+ expect(stale.status).toBe(409);
+ expect(await stale.text()).toContain("This changed on GitHub since you looked");
+ const started = await submit(jobs, [
+ ["workflow", "iterate"],
+ ["target", "pulls/2"],
+ ["revision", head],
+ ]);
+ expect(started.status).toBe(303);
+ expect(started.headers.get("Location")).toBe("/inbox?project=10&item=pulls%2F2");
+ const html = await inbox({ project: "10", item: "pulls/2" });
+ expect(html.match(/
In progress<\/h2>[\s\S]*?<\/ol>/)![0]).toContain("Fix");
+ expect(thread(html)).toContain("none of your runners are online");
+ expect(thread(html)).toContain("Stop this job");
+ const [job] = await store.listJobs(db, 10);
+ const stopped = await submit(`${jobs}/${job!.id}/cancel`, []);
+ expect(stopped.status).toBe(303);
+ expect((await store.readJob(db, job!.id))!.state).toBe("cancelled");
+ // A stopped job sets its item aside until the pull request changes.
+ expect(await inbox()).toMatch(/Skipped and finished \(1\)[\s\S]*Fix/);
+ });
+
+ it("offers a bootstrap job when the default branch has no flake", async () => {
+ github.flake = false;
+ const refused = await submit(jobs, [
+ ["workflow", "start"],
+ ["target", "quest/first.md"],
+ ["revision", commit],
+ ]);
+ expect(refused.status).toBe(409);
+ const html = await refused.text();
+ expect(html).toContain("start a bootstrap job");
+ expect(html).toContain('name="workflow" value="bootstrap"');
+ expect((await submit(jobs, [["workflow", "bootstrap"]])).status).toBe(303);
+ });
+
+ it("renders an agent's questions, records the answers once, and keeps them in the thread", async () => {
+ const { id, runner } = await running();
+ const prompt = {
+ questions: [
+ {
+ header: "Scope",
+ question: "Ship now?",
+ options: [{ label: "Yes", description: "Smallest change", recommended: true }, { label: "No" }],
+ },
+ { question: "Which parts?", options: [{ label: "API" }, { label: "UI" }], multiple: true },
+ ],
+ };
+ const { question } = await store.ask(
+ db,
+ runner,
+ id,
+ 1,
+ { summary: "Planned the change.", session: "s", prompt },
+ 3,
+ );
+ const active = thread(await inbox());
+ expect(active).toContain("Waiting for your decision");
+ expect(active).toContain("Planned the change.");
+ // Agent text is escaped, and the recommendation is marked but never chosen for the maintainer.
+ expect(active).toContain("Ship <b>now</b>?");
+ expect(active).toContain('Recommended');
+ expect(active).not.toContain("checked");
+ expect(active).toContain('type="checkbox" name="q1" value="UI"');
+ const answer: [string, string][] = [
+ ["question", question],
+ ["q0-text", "Only after the release"],
+ ["q1", "API"],
+ ["q1", "UI"],
+ ];
+ expect(
+ (
+ await submit(`${jobs}/${id}/answer`, [
+ ["question", question],
+ ["q1", "Z"],
+ ])
+ ).status,
+ ).toBe(409);
+ const answered = await submit(`${jobs}/${id}/answer`, answer);
+ expect(answered.status).toBe(303);
+ const job = (await store.readJob(db, id))!;
+ expect(job.state).toBe("queued");
+ expect(job.questions[0]!.answer).toEqual({
+ answers: [{ choices: [], text: "Only after the release" }, { choices: ["API", "UI"] }],
+ });
+ const again = await submit(`${jobs}/${id}/answer`, answer);
+ expect(again.status).toBe(409);
+ expect(await again.text()).toContain("Question is no longer open");
+ const after = thread(await inbox({ project: "10", item: "quest/first.md" }));
+ expect(after).toContain("Earlier answers (1)");
+ expect(after).toContain("API · UI");
+ });
+
+ it("asks which account continues a limited job instead of switching", async () => {
+ const { id, runner } = await running();
+ await store.finish(db, runner, id, 1, { outcome: "limited", summary: "Hit the plan limit.", result: null }, 3);
+ const active = thread(await inbox());
+ expect(active).toContain("Account limited");
+ expect(active).toContain(`value="${runner.id}:spare"`);
+ expect(active).toContain("limited; choosing it again retries");
+ const [question] = (await store.readJob(db, id))!.questions;
+ expect(
+ (
+ await submit(`${jobs}/${id}/answer`, [
+ ["question", question!.id],
+ ["account", `${runner.id}:spare`],
+ ])
+ ).status,
+ ).toBe(303);
+ expect(await store.readJob(db, id)).toMatchObject({ state: "queued", pinAccount: "spare" });
+ });
+
+ it("skips an item until it changes", async () => {
+ const skipped = await submit("/projects/github/upstream/project/skip", [
+ ["item", "issues/1"],
+ ["version", "0"],
+ ]);
+ expect(skipped.status).toBe(303);
+ let html = await inbox();
+ expect(html.match(/
Up next<\/h2>[\s\S]*?<\/ol>/)![0]).not.toContain("Idea");
+ expect(html).toMatch(/Skipped and finished \(1\)[\s\S]*Idea/);
+ await db.prepare("UPDATE github_project_activity SET changedAt=5 WHERE kind='issue' AND id='11'").run();
+ html = await inbox();
+ expect(html).not.toContain("Skipped and finished");
+ });
+
+ it("reports a project without current authority and hides its items", async () => {
+ github.role = "write";
+ const html = await inbox();
+ expect(html).toContain("Unavailable: Current GitHub maintainer authority required");
+ expect(html).not.toContain("Idea");
+ expect(html).toContain("Nothing needs a decision right now.");
+ });
+
+ it("keeps paused projects' questions but offers no new work", async () => {
+ const { id, runner } = await running();
+ await project(10, user, "paused");
+ await store.ask(
+ db,
+ runner,
+ id,
+ 1,
+ { summary: "Paused?", session: "s", prompt: { questions: [{ question: "Go on?", options: [] }] } },
+ 3,
+ );
+ const html = await inbox();
+ expect(thread(html)).toContain("Go on?");
+ expect(html).toContain("Contributions paused");
+ expect(html).not.toContain("Idea");
+ expect(html).not.toContain("Start a planning interview");
+ });
+});
diff --git a/cloudflare/test/jobs.test.ts b/cloudflare/test/jobs.test.ts
index fba85c24..d8dff289 100644
--- a/cloudflare/test/jobs.test.ts
+++ b/cloudflare/test/jobs.test.ts
@@ -5,36 +5,20 @@ import app from "../src/index";
import * as store from "../src/jobs/store";
import { GRACE } from "../src/jobs/store";
import { parse } from "../src/jobs/protocol";
+import { formAnswer, parseAnswer, parsePrompt } from "../src/jobs/prompt";
import { refName, resume } from "../src/jobs/routes";
import { privateKey } from "./github-key";
-import { identity, oauth, origin } from "./login-fixtures";
+import { origin } from "./login-fixtures";
+import { commit, head, maintainerOf, project } from "./project-fixtures";
const testEnv = { ...env, GITHUB_APP_PRIVATE_KEY: privateKey };
const db = env.DB;
-const commit = "a".repeat(40);
-const head = "d".repeat(40);
-const documents = [
- {
- path: "quest/README.md",
- content: "# Project\n\n## Goal\n\nBuild it.\n\n## Required\n\n- [First](/quest/first.md)\n",
- },
- { path: "quest/first.md", content: "# [S] First\n\n## Goal\n\nBuild first.\n" },
-];
-
async function user(id: string) {
await db
.prepare("INSERT OR IGNORE INTO user(id,name,email,emailVerified,createdAt,updatedAt) VALUES(?,?,?,1,0,0)")
.bind(id, id, `${id}@example.test`)
.run();
}
-async function project(id: number, owner: string, state: "enabled" | "paused" = "enabled") {
- await db
- .prepare(
- "INSERT INTO github_projects(id,owner,name,installation,state,enrolledBy,updatedAt) VALUES(?,'upstream','project',5,?,?,0) ON CONFLICT(id) DO UPDATE SET state=excluded.state",
- )
- .bind(id, state, owner)
- .run();
-}
async function runner(owner: string, name: string, platform: store.Platform = "linux", vm = false) {
const { id, token } = await store.createRunner(db, owner, name, platform, vm, 0);
return { id, token, owner, name, platform, vm: vm ? 1 : 0 } as store.Runner & { token: string };
@@ -597,6 +581,48 @@ describe("runner messages", () => {
// Fewer than 1 Mi code units, but three bytes each.
expect(() => parse(output("界".repeat(400_000)))).toThrow("at most 1 MiB");
});
+
+ it("accepts only prompts the inbox can render", () => {
+ const question = (prompt: unknown) =>
+ parse(JSON.stringify({ type: "question", job: "j", attempt: 1, summary: "Why", session: "s", prompt }));
+ const ask = { question: "Which?", options: [{ label: "A", recommended: true }, { label: "B" }] };
+ expect(question({ questions: [ask] })).toMatchObject({ prompt: { kind: "decision", questions: [ask] } });
+ expect(question({ kind: "permission", questions: [{ question: "Run it?", options: [] }] })).toMatchObject({
+ prompt: { kind: "permission" },
+ });
+ expect(() => question({ q: 1 })).toThrow("1 to 10 questions");
+ expect(() => question({ kind: "chat", questions: [ask] })).toThrow("Invalid prompt");
+ expect(() => question({ questions: [{ ...ask, options: [{ label: "A" }, { label: "A" }] }] })).toThrow(
+ "unique",
+ );
+ expect(() => question({ questions: [{ question: " ", options: [] }] })).toThrow("Invalid prompt");
+ });
+
+ it("checks answers against their prompt", () => {
+ const prompt = parsePrompt({
+ questions: [
+ { question: "One?", options: [{ label: "A" }, { label: "B" }] },
+ { question: "Many?", options: [{ label: "X" }, { label: "Y" }], multiple: true },
+ { question: "Say", options: [] },
+ ],
+ });
+ const answer = (...answers: unknown[]) => parseAnswer(prompt, { answers });
+ expect(answer({ choices: ["A"] }, { choices: ["X", "Y"] }, { choices: [], text: " Hi " })).toEqual({
+ answers: [{ choices: ["A"] }, { choices: ["X", "Y"] }, { choices: [], text: "Hi" }],
+ });
+ // Free text can stand in for, or add to, a choice.
+ expect(answer({ text: "C" }, { choices: ["X"], text: "why" }, { text: "Hi" }).answers[0]).toEqual({
+ choices: [],
+ text: "C",
+ });
+ expect(() => answer({ choices: ["A", "B"] }, { choices: ["X"] }, { text: "Hi" })).toThrow("offered options");
+ expect(() => answer({ choices: ["Z"] }, { choices: ["X"] }, { text: "Hi" })).toThrow("offered options");
+ expect(() => answer({ choices: ["A"] }, { choices: [] }, { text: "Hi" })).toThrow("every question");
+ expect(() => answer({ choices: ["A"] })).toThrow("every question");
+ expect(formAnswer(prompt, { q0: "B", q1: ["X", "Y"], "q1-text": "", "q2-text": "Hello" })).toEqual({
+ answers: [{ choices: ["B"] }, { choices: ["X", "Y"] }, { choices: [], text: "Hello" }],
+ });
+ });
});
describe("branch names", () => {
@@ -620,88 +646,10 @@ describe("job routes", () => {
const base = `${origin}/projects/github/upstream/project/jobs`;
let maintainer = "";
let cookie = "";
- let flake = true;
- let pullFlake = true;
- let role = "maintain";
+ let github: Awaited>["github"];
beforeEach(async () => {
- flake = true;
- pullFlake = true;
- role = "maintain";
- const who = identity("github");
- cookie = (await oauth(who)).session!;
- maintainer = (await db
- .prepare("SELECT userId FROM account WHERE providerId='github' AND accountId=?")
- .bind(who.id)
- .first<{ userId: string }>())!.userId;
- await project(10, maintainer);
- const snapshot = { commit, date: "2026-10-16", documents, completed: [] };
- const scan = { id: "scan", issues: false, pulls: false, refs: false, claims: [], complete: true };
- const pull = {
- databaseId: "21",
- number: 2,
- title: "Fix",
- state: "OPEN",
- updatedAt: "2026-10-16T00:00:00Z",
- author: null,
- comments: { totalCount: 0 },
- headRefName: "contributor/fix",
- headRefOid: head,
- };
- const issue = {
- databaseId: "11",
- number: 1,
- title: "Idea",
- state: "OPEN",
- updatedAt: "2026-10-16T00:00:00Z",
- author: null,
- comments: { totalCount: 0 },
- };
- await db.batch([
- db
- .prepare(
- "INSERT INTO github_project_state(repository,epoch,snapshot,scan,observedAt) VALUES(10,'e',?,?,0)",
- )
- .bind(JSON.stringify(snapshot), JSON.stringify(scan)),
- db
- .prepare(
- "INSERT INTO github_project_activity(repository,kind,id,number,firstSeen,changedAt,data,seenIn) VALUES(10,'pull','21',2,0,0,?,'scan'),(10,'issue','11',1,0,0,?,'scan')",
- )
- .bind(JSON.stringify(pull), JSON.stringify(issue)),
- ]);
- vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => {
- const url = new URL(String(input));
- const path = url.pathname;
- if (path.endsWith("/installation"))
- return Response.json({
- id: 5,
- app_id: 123,
- suspended_at: null,
- permissions: { metadata: "read", contents: "read" },
- });
- if (path.endsWith("/access_tokens")) return Response.json({ token: "installation-token" });
- if (path === "/installation/token") return new Response(null, { status: 204 });
- if (path === "/repos/upstream/project")
- return Response.json({
- id: 10,
- name: "project",
- owner: { login: "upstream" },
- private: false,
- visibility: "public",
- default_branch: "trunk",
- archived: false,
- disabled: false,
- });
- if (path.startsWith("/user/")) return Response.json({ id: Number(who.id), login: "maintainer" });
- if (path.endsWith("/permission"))
- return Response.json({ user: { id: Number(who.id) }, permission: "write", role_name: role });
- const ref = url.searchParams.get("ref");
- if (path === "/repos/upstream/project/contents/flake.nix" && (ref === commit || ref === head))
- return (ref === commit ? flake : pullFlake)
- ? Response.json({ type: "file" })
- : new Response(null, { status: 404 });
- throw new Error(`Unexpected GitHub request ${path}`);
- });
+ ({ user: maintainer, cookie, github } = await maintainerOf());
});
function post(path: string, body: unknown, headers: Record = { Cookie: cookie, Origin: origin }) {
@@ -764,7 +712,7 @@ describe("job routes", () => {
});
it("offers a devShell bootstrap job instead of starting without a flake", async () => {
- flake = false;
+ github.flake = false;
const refused = await post("", { workflow: "start", target: "quest/first.md" });
expect(refused.status).toBe(409);
expect(await refused.json()).toMatchObject({ bootstrap: true });
@@ -774,7 +722,7 @@ describe("job routes", () => {
});
it("refuses to iterate on a pull request head without a flake", async () => {
- pullFlake = false;
+ github.pullFlake = false;
const refused = await post("", { workflow: "iterate", target: "pulls/2" });
expect(refused.status).toBe(409);
expect(await refused.json()).toEqual({ error: "The pull request's head has no flake devShell" });
@@ -785,9 +733,9 @@ describe("job routes", () => {
403,
);
expect((await post("", { workflow: "plan" }, { Origin: origin })).status).toBe(403);
- role = "write";
+ github.role = "write";
expect((await post("", { workflow: "plan" })).status).toBe(403);
- role = "maintain";
+ github.role = "maintain";
await project(10, maintainer, "paused");
expect((await post("", { workflow: "plan" })).status).toBe(409);
});
@@ -806,7 +754,11 @@ describe("job routes", () => {
machine,
id,
1,
- { summary: "Which?", session: "s", prompt: { q: 1 } },
+ {
+ summary: "Which?",
+ session: "s",
+ prompt: { questions: [{ question: "Which?", options: [{ label: "A" }] }] },
+ },
Date.now(),
);
const output = (query = "") =>
@@ -820,12 +772,16 @@ describe("job routes", () => {
next: null,
});
expect((await output("?after=x")).status).toBe(409);
- role = "write";
+ github.role = "write";
expect((await output()).status).toBe(403);
- expect((await post(`/${id}/answer`, { question, answer: "A" })).status).toBe(403);
- role = "maintain";
- expect((await post(`/${id}/answer`, { question, answer: "A" })).status).toBe(204);
- expect((await post(`/${id}/answer`, { question, answer: "B" })).status).toBe(409);
+ const a = { answers: [{ choices: ["A"] }] };
+ expect((await post(`/${id}/answer`, { question, answer: a })).status).toBe(403);
+ github.role = "maintain";
+ // An answer must fit the prompt it answers.
+ expect((await post(`/${id}/answer`, { question, answer: "A" })).status).toBe(409);
+ expect((await post(`/${id}/answer`, { question, answer: { answers: [{ choices: ["Z"] }] } })).status).toBe(409);
+ expect((await post(`/${id}/answer`, { question, answer: a })).status).toBe(204);
+ expect((await post(`/${id}/answer`, { question, answer: a })).status).toBe(409);
expect((await post(`/${id}/cancel`, {})).status).toBe(204);
expect(await state(id)).toMatchObject({ state: "cancelled" });
expect((await post(`/missing/cancel`, {})).status).toBe(409);
diff --git a/cloudflare/test/logins.test.ts b/cloudflare/test/logins.test.ts
index bc36f01a..521098a2 100644
--- a/cloudflare/test/logins.test.ts
+++ b/cloudflare/test/logins.test.ts
@@ -42,7 +42,7 @@ describe("sign-in choices", () => {
const html = await (await SELF.fetch(`${origin}/docs`)).text();
expect(html).toContain('href="/sign-in?next=%2Fdocs"');
const unsafe = await (await SELF.fetch(`${origin}/sign-in?next=//evil.example`)).text();
- expect(unsafe).toContain('data-next="/"');
+ expect(unsafe).toContain('data-next="/inbox"');
});
it("omits Google until both of its credentials are configured", async () => {
const unconfigured = { ...env, GOOGLE_CLIENT_SECRET: undefined };
@@ -73,7 +73,7 @@ describe("Google sign-in", () => {
it("creates its own account, without GitHub authority", async () => {
const who = identity("google");
const { response, session } = await oauth(who);
- expect(response.headers.get("location")).toBe("/");
+ expect(response.headers.get("location")).toBe("/inbox");
expect(await linked(session!)).toEqual([`google:${who.id}`]);
const page = await (await SELF.fetch(`${origin}/account`, { headers: { Cookie: session! } })).text();
expect(page).toContain("Link GitHub to act on repositories");
diff --git a/cloudflare/test/project-fixtures.ts b/cloudflare/test/project-fixtures.ts
new file mode 100644
index 00000000..62082f28
--- /dev/null
+++ b/cloudflare/test/project-fixtures.ts
@@ -0,0 +1,141 @@
+// A signed-in maintainer of an enrolled project with complete observations,
+// and a mocked GitHub that answers the requests authority checks make.
+import { env } from "cloudflare:workers";
+import { vi } from "vitest";
+import app from "../src/index";
+import { identity, oauth } from "./login-fixtures";
+
+const db = env.DB;
+export const commit = "a".repeat(40);
+export const head = "d".repeat(40);
+export const documents = [
+ {
+ path: "quest/README.md",
+ content: "# Project\n\n## Goal\n\nBuild it.\n\n## Required\n\n- [First](/quest/first.md)\n",
+ },
+ { path: "quest/first.md", content: "# [S] First\n\n## Goal\n\nBuild first.\n" },
+];
+
+export async function project(id: number, owner: string, state: "enabled" | "paused" = "enabled") {
+ await db
+ .prepare(
+ "INSERT INTO github_projects(id,owner,name,installation,state,enrolledBy,updatedAt) VALUES(?,'upstream','project',5,?,?,0) ON CONFLICT(id) DO UPDATE SET state=excluded.state",
+ )
+ .bind(id, state, owner)
+ .run();
+}
+
+export const pull = {
+ databaseId: "21",
+ number: 2,
+ title: "Fix",
+ state: "OPEN",
+ updatedAt: "2026-10-16T00:00:00Z",
+ author: null,
+ comments: { totalCount: 0 },
+ isDraft: false,
+ reviewDecision: null,
+ commits: { nodes: [{ commit: { oid: head, statusCheckRollup: { state: "SUCCESS" } } }] },
+ headRefName: "contributor/fix",
+ headRefOid: head,
+};
+export const issue = {
+ databaseId: "11",
+ number: 1,
+ title: "Idea",
+ state: "OPEN",
+ updatedAt: "2026-10-16T00:00:00Z",
+ author: { login: "reporter" },
+ comments: { totalCount: 0 },
+};
+
+/**
+ * Sign in a GitHub user, enroll project 10 for them with an open issue #1 and
+ * pull request #2, and mock GitHub. `github` changes what the mock reports.
+ */
+export async function maintainerOf() {
+ const github = { flake: true, pullFlake: true, role: "maintain" };
+ const who = identity("github");
+ const cookie = (await oauth(who)).session!;
+ const user = (await db
+ .prepare("SELECT userId FROM account WHERE providerId='github' AND accountId=?")
+ .bind(who.id)
+ .first<{ userId: string }>())!.userId;
+ await project(10, user);
+ const snapshot = { commit, date: "2026-10-16", documents, completed: [] };
+ const scan = { id: "scan", issues: false, pulls: false, refs: false, claims: [], complete: true };
+ await db.batch([
+ db
+ .prepare(
+ "INSERT INTO github_project_state(repository,epoch,snapshot,scan,observedAt) VALUES(10,'e',?,?,0) ON CONFLICT(repository) DO UPDATE SET snapshot=excluded.snapshot,scan=excluded.scan,invalidated=0",
+ )
+ .bind(JSON.stringify(snapshot), JSON.stringify(scan)),
+ db
+ .prepare(
+ "INSERT INTO github_project_activity(repository,kind,id,number,firstSeen,changedAt,data,seenIn) VALUES(10,'pull','21',2,0,0,?,'scan'),(10,'issue','11',1,0,0,?,'scan')",
+ )
+ .bind(JSON.stringify(pull), JSON.stringify(issue)),
+ ]);
+ vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => {
+ const url = new URL(String(input));
+ const path = url.pathname;
+ if (path.endsWith("/installation"))
+ return Response.json({
+ id: 5,
+ app_id: 123,
+ suspended_at: null,
+ permissions: { metadata: "read", contents: "read" },
+ });
+ if (path.endsWith("/access_tokens")) return Response.json({ token: "installation-token" });
+ if (path === "/installation/token") return new Response(null, { status: 204 });
+ if (path === "/repos/upstream/project")
+ return Response.json({
+ id: 10,
+ name: "project",
+ owner: { login: "upstream" },
+ private: false,
+ visibility: "public",
+ default_branch: "trunk",
+ archived: false,
+ disabled: false,
+ });
+ if (path.startsWith("/user/")) return Response.json({ id: Number(who.id), login: "maintainer" });
+ if (path.endsWith("/permission"))
+ return Response.json({ user: { id: Number(who.id) }, permission: "write", role_name: github.role });
+ const ref = url.searchParams.get("ref");
+ if (path === "/repos/upstream/project/contents/flake.nix" && (ref === commit || ref === head))
+ return (ref === commit ? github.flake : github.pullFlake)
+ ? Response.json({ type: "file" })
+ : new Response(null, { status: 404 });
+ throw new Error(`Unexpected GitHub request ${path}`);
+ });
+ return { user, cookie, github };
+}
+
+/** Deliver a signed GitHub webhook. */
+export async function delivery(
+ event: string,
+ payload: unknown,
+ id = crypto.randomUUID(),
+ changes: Record = {},
+) {
+ const body = typeof payload === "string" ? payload : JSON.stringify(payload);
+ const key = await crypto.subtle.importKey(
+ "raw",
+ new TextEncoder().encode(env.GITHUB_WEBHOOK_SECRET),
+ { name: "HMAC", hash: "SHA-256" },
+ false,
+ ["sign"],
+ );
+ const digest = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(body));
+ const signature = "sha256=" + Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, "0")).join("");
+ return app.request(
+ "http://localhost:8787/webhooks/github",
+ {
+ method: "POST",
+ headers: { "X-GitHub-Event": event, "X-GitHub-Delivery": id, "X-Hub-Signature-256": signature, ...changes },
+ body,
+ },
+ env,
+ );
+}
diff --git a/cloudflare/test/worker.test.ts b/cloudflare/test/worker.test.ts
index a9623bdf..f4c11e94 100644
--- a/cloudflare/test/worker.test.ts
+++ b/cloudflare/test/worker.test.ts
@@ -118,7 +118,7 @@ describe("Worker", () => {
"/\n/x",
"/%2F/evil.example",
"/%5cevil.example",
- ].map((next) => [next, "/"]),
+ ].map((next) => [next, "/inbox"]),
])("returns to a local page after signing in: %j -> %j", async (next, expected) => {
// Better Auth stores each sign-in's callback with its OAuth state.
await env.DB.prepare("DELETE FROM verification").run();
@@ -203,7 +203,7 @@ describe("Worker", () => {
redirect: "manual",
});
expect(callback.status).toBe(302);
- expect(callback.headers.get("location")).toBe("/");
+ expect(callback.headers.get("location")).toBe("/inbox");
expect(outbound).toHaveBeenCalled();
const sessionCookie = callback.headers
.getSetCookie()
diff --git a/quest/m0/kix/README.md b/quest/m0/kix/README.md
index dc424340..83d3b53a 100644
--- a/quest/m0/kix/README.md
+++ b/quest/m0/kix/README.md
@@ -94,7 +94,6 @@ operator-paid-hosting decisions:
- [Runner](/quest/m0/kix/runner.md) - `quest runner` on Linux and macOS with Nix devShells, accounts, and the Claude Code adapter
- [Job sandbox](/quest/m0/kix/sandbox.md) - OS-sandboxed commands, with untrusted PR devShells built in a VM on Linux runners
- [opencode adapter](/quest/m0/kix/opencode.md) - OpenCode Go, ChatGPT plans, and API-key providers through opencode
-- [Decision inbox](/quest/m0/kix/prompts.md) - a simple prompt UI with new-issue and new-PR notifications
- [Output retention](/quest/m0/kix/output.md) - raw agent output in R2, kept 30 days and capped at 64 MiB per attempt
- [Agent activity](/quest/m0/kix/activity.md) - agent cards and map highlights built from checkpoint summaries
- [Mobile web app](/quest/m0/kix/mobile.md) - an installable PWA with Web Push for decisions
diff --git a/quest/m0/kix/activity.md b/quest/m0/kix/activity.md
index f779e025..c850b1fb 100644
--- a/quest/m0/kix/activity.md
+++ b/quest/m0/kix/activity.md
@@ -32,5 +32,4 @@ Decided 2026-10-15 in the decisions-not-implementations interview:
## Required
- [Runner](/quest/m0/kix/runner.md) - the checkpoint tool, summaries, and retained raw output
-- [Decision inbox](/quest/m0/kix/prompts.md) - the signed-in shell the cards sit beside
- [Output retention](/quest/m0/kix/output.md) - the bounded raw output behind the disclosure
diff --git a/quest/m0/kix/mobile.md b/quest/m0/kix/mobile.md
index ab4a12c0..42e65145 100644
--- a/quest/m0/kix/mobile.md
+++ b/quest/m0/kix/mobile.md
@@ -39,7 +39,3 @@ Decided 2026-10-15 in the decisions-not-implementations interview:
[Web Push on iOS](https://webkit.org/blog/13878/web-push-for-web-apps-on-ios-and-ipados/),
[Push API](https://developer.mozilla.org/en-US/docs/Web/API/Push_API),
and [RFC 8291](https://www.rfc-editor.org/rfc/rfc8291).
-
-## Required
-
-- [Decision inbox](/quest/m0/kix/prompts.md) - the decision items that notifications open
diff --git a/quest/m0/kix/planning.md b/quest/m0/kix/planning.md
index 4538e319..e49b745c 100644
--- a/quest/m0/kix/planning.md
+++ b/quest/m0/kix/planning.md
@@ -32,5 +32,4 @@ interview runs on one of their runners; they never see a terminal.
## Required
- [Runner](/quest/m0/kix/runner.md) - planning sessions, questions, and resumption
-- [Decision inbox](/quest/m0/kix/prompts.md) - the interactive rounds and notifications
- [Bot publication](/quest/m0/kix/publication.md) - draft planning PRs and recorded decisions
diff --git a/quest/m0/kix/prompts.md b/quest/m0/kix/prompts.md
deleted file mode 100644
index 99f20bbc..00000000
--- a/quest/m0/kix/prompts.md
+++ /dev/null
@@ -1,45 +0,0 @@
-# [L] Make interactive decisions the main UI
-
-## Goal
-
-After signing in, a maintainer sees a simple decision inbox and can work
-through contextual prompts with recommendations, choices, and free-text
-answers. Every new GitHub issue and PR produces a notification. Refreshing
-or reconnecting preserves the current question and submitted answers.
-
-## Plan
-
-- The user emphasized simplicity on 2026-10-13: the maintainer primarily
- does quest-plan for new issues and quest-spawn for ready work, then handles
- the PRs that arrive. Optimize that loop before secondary dashboards.
-- Present one active decision thread, showing the issue/quest/PR, relevant
- context, recommendation, choices, and free text. A planning round may
- contain independent questions; dependent questions wait for the answers.
-- The model generates contextual questions; the UI renders them and returns
- the maintainer's selected answers. Distinguish product decisions from
- tool permission requests and from ordinary informational progress.
-- New issue: offer planning or an appropriate skip/defer action. Ready
- quest: offer starting/prioritizing it. New PR: offer the next review,
- iteration, or permitted GitHub merge decision. Capability and authorization
- determine available actions; do not display nonworking promises.
-- Use in-app unread notifications as the baseline; Web Push belongs to the
- [mobile web app](/quest/m0/kix/mobile.md). Coalesce updates into the same
- decision item; a reconnect or duplicate webhook must not create another
- prompt. Design the inbox phone-first.
-- Decisions are bound to a project, current maintainer, session, and
- question/revision. Reject stale or duplicate submissions safely. Agent
- recommendations and other users' comments are never approval.
-- Surface waiting-for-answer, active, account-limited, runner-offline, and
- failed states plainly, with the next useful action. Keep runner and account
- setup and repository browsing available as secondary views.
-- Decided 2026-10-15: prompts and agent-written summaries are the whole
- default UI. There is no log view; raw agent output sits behind a collapsed
- per-job disclosure for current maintainers
- ([agent activity](/quest/m0/kix/activity.md)).
-- Reuse the established visual identity and existing components where they
- help. Maps, status grids, and configuration screens are not the primary
- signed-in experience. Preserve keyboard and accessible form behavior.
-- Build on the job routes under `/projects/github///jobs`:
- create, list, answer, cancel, and raw output, each rechecking maintainer
- authority. A job waiting on an `account` question takes a
- `{ runner, account }` answer; a missing `flake.nix` returns `bootstrap: true`.
diff --git a/quest/m0/theme/README.md b/quest/m0/theme/README.md
index 74da6c9f..275c6c7d 100644
--- a/quest/m0/theme/README.md
+++ b/quest/m0/theme/README.md
@@ -116,5 +116,4 @@ Decided while planning on 2026-10-05, amended 2026-10-14 where noted:
## Related
-- [Decision inbox](/quest/m0/kix/prompts.md) - the primary hosted workflow
- [Launch material](/quest/m0/launch.md) - posts, quickstart, demo, and comparison written in the same voice
diff --git a/quest/m0/theme/landing.md b/quest/m0/theme/landing.md
index 7fc2074d..8adc4099 100644
--- a/quest/m0/theme/landing.md
+++ b/quest/m0/theme/landing.md
@@ -19,4 +19,3 @@ their decision inbox. The standalone Quest setup remains easy to find.
## Required
- [Deploy kix.quest](/quest/m0/kix/deploy.md) - the live site and setup link
-- [Decision inbox](/quest/m0/kix/prompts.md) - returning maintainer destination
From d62bf7e71156ddc135d523234f863b6b66ec67e3 Mon Sep 17 00:00:00 2001
From: Luke Curley
Date: Sun, 18 Oct 2026 22:56:09 -0700
Subject: [PATCH 03/10] fix(worker): keep the decision thread in one column on
wide screens
The PR meta line reused .entry-meta, whose grid-column: 2 split the thread
into two columns once there was room. Also show GitHub's check and review
enums as plain words.
Co-Authored-By: Claude Opus 5.5
---
cloudflare/src/inbox/page.tsx | 7 +++++--
cloudflare/src/site.css | 2 ++
cloudflare/test/inbox.test.ts | 2 +-
3 files changed, 8 insertions(+), 3 deletions(-)
diff --git a/cloudflare/src/inbox/page.tsx b/cloudflare/src/inbox/page.tsx
index 25a87935..8a395322 100644
--- a/cloudflare/src/inbox/page.tsx
+++ b/cloudflare/src/inbox/page.tsx
@@ -55,6 +55,9 @@ function progress(item: Item, runners: Runners, user: string) {
return null;
}
+// GitHub's enums, such as CHANGES_REQUESTED, read as plain words.
+const words = (value: string) => value.toLowerCase().replaceAll("_", " ");
+
function Context(props: { item: Item }) {
const data = props.item.activity?.data;
if (!data) return null;
@@ -68,8 +71,8 @@ function Context(props: { item: Item }) {
{pull && (
<>
{pull.isDraft && Draft}
- Checks: {pull.commits.nodes.at(-1)?.commit.statusCheckRollup?.state ?? "none"}
- Review: {pull.reviewDecision ?? "none yet"}
+ Checks: {words(pull.commits.nodes.at(-1)?.commit.statusCheckRollup?.state ?? "none")}
+ Review: {pull.reviewDecision ? words(pull.reviewDecision) : "none yet"}
>
)}
diff --git a/cloudflare/src/site.css b/cloudflare/src/site.css
index 81d8eb68..c3bb5f20 100644
--- a/cloudflare/src/site.css
+++ b/cloudflare/src/site.css
@@ -1238,7 +1238,9 @@ a.entry-title:hover {
white-space: pre-wrap;
overflow-wrap: anywhere;
}
+/* Entry meta sits in a log row's second column; in the thread it spans the one column. */
.decision-meta {
+ grid-column: auto;
color: var(--ql-muted);
}
.decision-form {
diff --git a/cloudflare/test/inbox.test.ts b/cloudflare/test/inbox.test.ts
index f9eac6c5..ecf387eb 100644
--- a/cloudflare/test/inbox.test.ts
+++ b/cloudflare/test/inbox.test.ts
@@ -82,7 +82,7 @@ describe("decision inbox", () => {
// The pull request is the active decision, bound to the head on screen.
const active = thread(html);
expect(active).toContain("Fix");
- expect(active).toContain("Checks: SUCCESS");
+ expect(active).toContain("Checks: success");
expect(active).toContain(`name="revision" value="${head}"`);
expect(active).toContain("Iterate on this PR");
expect(active).toContain("Review on GitHub");
From f778d7d3192cfc6b0cb73a53b0109666f2b0df3e Mon Sep 17 00:00:00 2001
From: Luke Curley
Date: Mon, 19 Oct 2026 11:01:03 -0700
Subject: [PATCH 04/10] chore(quest): drop the deleted inbox quest from
paper-trail
#150 added paper-trail.md requiring /quest/m0/kix/prompts.md, which
this PR deletes. That was its only blocker, so the Required section
goes away.
Co-Authored-By: Claude Opus 5.5
---
quest/m0/kix/paper-trail.md | 4 ----
1 file changed, 4 deletions(-)
diff --git a/quest/m0/kix/paper-trail.md b/quest/m0/kix/paper-trail.md
index 09a1672e..4c43e049 100644
--- a/quest/m0/kix/paper-trail.md
+++ b/quest/m0/kix/paper-trail.md
@@ -38,7 +38,3 @@ paper trail from the agent's quest files and PR body:
account question must not appear. Cover hostile prompts with Markdown,
`@user` and `@org/team` mentions, bare URLs, issue and cross-repository
references, and closing keywords.
-
-## Required
-
-- [Decision inbox](/quest/m0/kix/prompts.md) - the recorded prompt and answer format this renders
From 57d059cc742306ce0f919d3bc7151ba832f38c3a Mon Sep 17 00:00:00 2001
From: Luke Curley
Date: Mon, 19 Oct 2026 11:29:16 -0700
Subject: [PATCH 05/10] fix(worker): answer publication decisions and keep
older jobs in the inbox
Codex review findings on #144:
- A publication decision from bot publication (#145) fell through to
"could not be read" with no way to answer. The inbox now lists the
refused paths or why the branch is blocked and takes a text reply,
which the answer route accepts from the form.
- A failed PR job whose pull request is no longer observed crashed the
inbox: the retry action dereferenced missing activity before the
refresh reason was checked. The action is built only when unblocked.
- The inbox read only the 50 newest jobs, so an older waiting job
vanished. It now also reads every queued, running, and waiting job.
Co-Authored-By: Claude Opus 5.5
---
cloudflare/README.md | 5 ++-
cloudflare/src/inbox/model.ts | 2 +-
cloudflare/src/inbox/page.tsx | 53 ++++++++++++++++++++++++--
cloudflare/src/jobs/routes.ts | 3 +-
cloudflare/src/jobs/store.ts | 14 +++++++
cloudflare/test/inbox.test.ts | 71 +++++++++++++++++++++++++++++++++++
6 files changed, 141 insertions(+), 7 deletions(-)
diff --git a/cloudflare/README.md b/cloudflare/README.md
index 73195e20..557bf735 100644
--- a/cloudflare/README.md
+++ b/cloudflare/README.md
@@ -172,7 +172,10 @@ requests, new issues, and ready quests. An item offers only actions that
would work: none on a paused project, and only a refresh while observations
are incomplete. Every action is a plain form post to the job routes, which
recheck authority and return to the inbox; prompts render as escaped text and
-never preselect the recommendation.
+never preselect the recommendation. A `publication` decision lists the refused
+paths or why the branch is blocked, and takes the maintainer's reply as text.
+The inbox reads every queued, running, and waiting job, so an older decision
+is never cut off by the job history's limit.
Each maintainer's read state lives in `inbox_items`, keyed by a version of what
the item showed (a question, a GitHub change, a quest's text). A newer version
diff --git a/cloudflare/src/inbox/model.ts b/cloudflare/src/inbox/model.ts
index 0c5a989c..3780e741 100644
--- a/cloudflare/src/inbox/model.ts
+++ b/cloudflare/src/inbox/model.ts
@@ -210,7 +210,7 @@ export async function load(env: Env, user: string) {
const usable = projects.filter((view) => !view.error);
const [reads, jobs] = await Promise.all([
env.DB.prepare("SELECT repository,item,seen,skipped FROM inbox_items WHERE user=?").bind(user).all(),
- Promise.all(usable.map((view) => store.listJobs(env.DB, view.project.id))),
+ Promise.all(usable.map((view) => store.listInboxJobs(env.DB, view.project.id))),
]);
const items = usable.flatMap((view, i) => decisions(view, jobs[i]!, reads.results));
// Jobs run on their requester's runners, so presence comes from each requester's hub.
diff --git a/cloudflare/src/inbox/page.tsx b/cloudflare/src/inbox/page.tsx
index 8a395322..214f95cf 100644
--- a/cloudflare/src/inbox/page.tsx
+++ b/cloudflare/src/inbox/page.tsx
@@ -24,8 +24,10 @@ export type InboxProps = {
const kinds = { pull: "Pull request", issue: "Issue", quest: "Ready quest", plan: "Plan", devshell: "Bootstrap" };
function needLabel(item: Item) {
- if (item.need === "answer")
+ if (item.need === "answer") {
+ if (item.question?.kind === "publication") return "Publishing needs your decision";
return item.prompt?.kind === "permission" ? "Tool permission request" : "Waiting for your decision";
+ }
if (item.need === "account") return "Account limited";
if (item.need === "retry") return "Failed";
if (item.need === "start")
@@ -99,6 +101,7 @@ function History(props: { item: Item }) {
if (q.kind === "account") {
return
Chose an account to continue on.
;
}
+ if (q.kind === "publication") return
{typeof q.answer === "string" ? q.answer : ""}
;
const prompt = parsed(q);
const replies = (q.answer as { answers?: { choices: string[]; text?: string }[] }).answers ?? [];
return (
@@ -167,6 +170,44 @@ function AgentForm(props: { item: Item; prompt: Prompt }) {
);
}
+/** A publication decision's prompt. kix wrote it, but its paths came from the runner. */
+function publication(question: QuestionView) {
+ const prompt = question.prompt as { refused?: unknown; blocked?: unknown } | null;
+ return {
+ refused: Array.isArray(prompt?.refused) ? (prompt.refused as { path: string; reason: string }[]) : [],
+ blocked: typeof prompt?.blocked === "string" ? prompt.blocked : null,
+ };
+}
+
+/** Work kix would not publish as is; the maintainer's reply resumes the agent's session. */
+function PublicationForm(props: { item: Item }) {
+ const { item } = props;
+ const { refused, blocked } = publication(item.question!);
+ return (
+
+ );
+}
+
function AccountForm(props: { item: Item; runners: Runners }) {
const { item } = props;
const job = item.job!;
@@ -249,8 +290,10 @@ function Refresh(props: { project: Item["project"] }) {
function Actions(props: { item: Item; view: ProjectView | undefined }) {
const { item, view } = props;
const base = projectURL(item.project.owner, item.project.name);
- const reason = item.need === "start" || item.need === "retry" ? blocked(item, view) : null;
- const job = item.need === "start" || item.need === "retry" ? work(item, view!) : null;
+ const starts = item.need === "start" || item.need === "retry";
+ const reason = starts ? blocked(item, view) : null;
+ // Only an unblocked item has the observations its work is bound to.
+ const job = starts && !reason ? work(item, view!) : null;
const active = item.job && ["queued", "running", "waiting"].includes(item.job.state) && !item.job.cancel;
return (
diff --git a/cloudflare/src/jobs/routes.ts b/cloudflare/src/jobs/routes.ts
index ba15ac60..fdb7f364 100644
--- a/cloudflare/src/jobs/routes.ts
+++ b/cloudflare/src/jobs/routes.ts
@@ -273,9 +273,10 @@ jobs.post(`${base}/:job/answer`, async (c) => {
return reply(c, form, job, null);
});
-/** An inbox form's answer: the chosen options, or the chosen `runner:account`. */
+/** An inbox form's answer: the chosen options, a publication reply's text, or the chosen `runner:account`. */
function formReply(question: store.Question, form: Form) {
if (question.kind === "agent") return formAnswer(parsePrompt(question.prompt), form);
+ if (question.kind === "publication") return typeof form.text === "string" ? form.text : undefined;
const [runner, account] = typeof form.account === "string" ? form.account.split(":") : [];
return runner && account ? { runner, account } : undefined;
}
diff --git a/cloudflare/src/jobs/store.ts b/cloudflare/src/jobs/store.ts
index a8f9da9f..ff34889b 100644
--- a/cloudflare/src/jobs/store.ts
+++ b/cloudflare/src/jobs/store.ts
@@ -628,6 +628,20 @@ export async function listJobs(db: D1Database, repository: number) {
return (await Promise.all(rows.results.map((row) => readJob(db, row.id)))).filter((job) => job !== null);
}
+/**
+ * The inbox's jobs, newest first: the 50 newest plus every queued, running, or
+ * waiting job, so an older job awaiting a decision is never cut off.
+ */
+export async function listInboxJobs(db: D1Database, repository: number) {
+ const rows = await db
+ .prepare(
+ "SELECT id FROM jobs WHERE repository=?1 AND (state IN ('queued','running','waiting') OR id IN (SELECT id FROM jobs WHERE repository=?1 ORDER BY createdAt DESC,id LIMIT 50)) ORDER BY createdAt DESC,id",
+ )
+ .bind(repository)
+ .all<{ id: string }>();
+ return (await Promise.all(rows.results.map((row) => readJob(db, row.id)))).filter((job) => job !== null);
+}
+
/**
* Answer the job's open question once, then requeue it. An account decision
* pins the chosen account and starts a new session from the job's summaries.
diff --git a/cloudflare/test/inbox.test.ts b/cloudflare/test/inbox.test.ts
index 21928e89..0608e397 100644
--- a/cloudflare/test/inbox.test.ts
+++ b/cloudflare/test/inbox.test.ts
@@ -237,6 +237,77 @@ describe("decision inbox", () => {
expect(await store.readJob(db, id)).toMatchObject({ state: "queued", pinAccount: "spare" });
});
+ it("asks how to continue when publishing needs a decision", async () => {
+ const { id, runner } = await running();
+ const prompt = {
+ type: "publication",
+ refused: [{ path: "/index.ts", reason: "Planning publishes only quest files" }],
+ };
+ const { question } = await store.ask(
+ db,
+ runner,
+ id,
+ 1,
+ { kind: "publication", summary: "Publishing needs a decision", session: "s", prompt },
+ 3,
+ );
+ const active = thread(await inbox());
+ expect(active).toContain("Publishing needs your decision");
+ // Runner-supplied paths are escaped.
+ expect(active).toContain("<src>/index.ts: Planning publishes only quest files");
+ const answered = await submit(`${jobs}/${id}/answer`, [
+ ["question", question],
+ ["text", "Drop the source change"],
+ ]);
+ expect(answered.status).toBe(303);
+ const job = (await store.readJob(db, id))!;
+ expect(job.state).toBe("queued");
+ expect(job.questions[0]!.answer).toBe("Drop the source change");
+ expect(thread(await inbox({ project: "10", item: "quest/first.md" }))).toContain("Drop the source change");
+ });
+
+ it("offers a refresh for a failed job whose pull request is no longer observed", async () => {
+ const { id, runner } = await running("iterate", "pulls/2");
+ await store.finish(db, runner, id, 1, { outcome: "failed", summary: "Broke.", result: null }, 3);
+ await db.prepare("DELETE FROM github_project_activity WHERE kind='pull'").run();
+ const active = thread(await inbox({ project: "10", item: "pulls/2" }));
+ expect(active).toContain("Failed");
+ expect(active).toContain("Refresh to read this from GitHub.");
+ expect(active).not.toContain("Try again");
+ });
+
+ it("keeps an older job's question beyond the newest fifty jobs", async () => {
+ const { id, runner } = await running();
+ await store.ask(
+ db,
+ runner,
+ id,
+ 1,
+ {
+ summary: "Asked early.",
+ session: "s",
+ prompt: { questions: [{ question: "Still there?", options: [] }] },
+ },
+ 3,
+ );
+ for (let i = 0; i < 50; i++)
+ await store.createJob(
+ db,
+ {
+ repository: 10,
+ actor: user,
+ workflow: "start",
+ target: `quest/later-${i}.md`,
+ revision: commit,
+ branch: `quest/later-${i}`,
+ trusted: true,
+ },
+ 10 + i,
+ );
+ await db.prepare("UPDATE jobs SET state='completed' WHERE id<>?").bind(id).run();
+ expect(thread(await inbox())).toContain("Still there?");
+ });
+
it("skips an item until it changes", async () => {
const skipped = await submit("/projects/github/upstream/project/skip", [
["item", "issues/1"],
From 0d464abf6c9b80f3c39046fb906d1efa8de92bf6 Mon Sep 17 00:00:00 2001
From: Luke Curley
Date: Mon, 19 Oct 2026 11:41:11 -0700
Subject: [PATCH 06/10] fix(worker): retry failures only where the retry can
work
Codex review findings on #144, round 2:
- Retrying a failed planning interview started an unrelated one under a
new plans/. The retry now keeps the target, which the jobs route
accepts, so the new job replaces the failure.
- A failed quest job offered "Try again" after the quest stopped being
ready, which the route then refused. The inbox explains instead.
- An older failure beyond the 50-job history showed as fresh work or
vanished. The inbox also reads each target's latest failure.
Co-Authored-By: Claude Opus 5.5
---
cloudflare/README.md | 5 ++--
cloudflare/src/inbox/page.tsx | 7 +++--
cloudflare/src/jobs/routes.ts | 6 +++--
cloudflare/src/jobs/store.ts | 11 +++++---
cloudflare/test/inbox.test.ts | 49 +++++++++++++++++++++++++++++++++--
5 files changed, 67 insertions(+), 11 deletions(-)
diff --git a/cloudflare/README.md b/cloudflare/README.md
index 557bf735..6c671887 100644
--- a/cloudflare/README.md
+++ b/cloudflare/README.md
@@ -174,8 +174,9 @@ are incomplete. Every action is a plain form post to the job routes, which
recheck authority and return to the inbox; prompts render as escaped text and
never preselect the recommendation. A `publication` decision lists the refused
paths or why the branch is blocked, and takes the maintainer's reply as text.
-The inbox reads every queued, running, and waiting job, so an older decision
-is never cut off by the job history's limit.
+The inbox reads every queued, running, and waiting job and each target's
+latest failure, so an older decision is never cut off by the job history's
+limit. Retrying a failed planning interview keeps its `plans/` target.
Each maintainer's read state lives in `inbox_items`, keyed by a version of what
the item showed (a question, a GitHub change, a quest's text). A newer version
diff --git a/cloudflare/src/inbox/page.tsx b/cloudflare/src/inbox/page.tsx
index 214f95cf..c8d48dd4 100644
--- a/cloudflare/src/inbox/page.tsx
+++ b/cloudflare/src/inbox/page.tsx
@@ -256,7 +256,9 @@ function AccountForm(props: { item: Item; runners: Runners }) {
function blocked(item: Item, view: ProjectView | undefined) {
if (item.project.state !== "enabled") return "Contributions are paused for this project.";
if (!view?.state?.complete) return "GitHub reported changes. Refresh to start new work from current state.";
- if (item.kind !== "quest" && item.kind !== "plan" && item.kind !== "devshell" && !item.activity?.data)
+ if (item.kind === "quest" && !view.state.available.includes(item.target))
+ return "This quest is no longer ready to start, such as when it is claimed or removed.";
+ if ((item.kind === "issue" || item.kind === "pull") && !item.activity?.data)
return "Refresh to read this from GitHub.";
return null;
}
@@ -273,7 +275,8 @@ function work(item: Item, view: ProjectView) {
};
if (item.kind === "issue")
return { workflow: "plan", target: item.target, revision: commit, label: "Plan this issue" };
- if (item.kind === "plan") return { workflow: "plan", revision: commit, label: "Start a new plan" };
+ // Only a failed interview is an item, so this retries it on the same target.
+ if (item.kind === "plan") return { workflow: "plan", target: item.target, revision: commit, label: "Try again" };
if (item.kind === "devshell") return { workflow: "bootstrap", revision: commit, label: "Add a devShell" };
return { workflow: "start", target: item.target, revision: commit, label: "Start this quest" };
}
diff --git a/cloudflare/src/jobs/routes.ts b/cloudflare/src/jobs/routes.ts
index fdb7f364..58183b50 100644
--- a/cloudflare/src/jobs/routes.ts
+++ b/cloudflare/src/jobs/routes.ts
@@ -186,8 +186,10 @@ function resolve(workflow: unknown, target: unknown, state: ProjectState): Plan
return { workflow, target, revision: commit, branch, trusted: true };
}
if (workflow === "plan") {
- if (target === undefined) {
- const plan = crypto.randomUUID();
+ // A new interview gets its own target; retrying one keeps it, replacing the failure.
+ const retry = typeof target === "string" ? target.match(/^plans\/([0-9a-f-]{36})$/)?.[1] : undefined;
+ if (target === undefined || retry) {
+ const plan = retry ?? crypto.randomUUID();
return {
workflow,
target: `plans/${plan}`,
diff --git a/cloudflare/src/jobs/store.ts b/cloudflare/src/jobs/store.ts
index ff34889b..b156a2fb 100644
--- a/cloudflare/src/jobs/store.ts
+++ b/cloudflare/src/jobs/store.ts
@@ -629,13 +629,18 @@ export async function listJobs(db: D1Database, repository: number) {
}
/**
- * The inbox's jobs, newest first: the 50 newest plus every queued, running, or
- * waiting job, so an older job awaiting a decision is never cut off.
+ * The inbox's jobs, newest first: the 50 newest plus every job awaiting a
+ * decision (queued, running, waiting, or its target's latest failure), so an
+ * older decision is never cut off.
*/
export async function listInboxJobs(db: D1Database, repository: number) {
const rows = await db
.prepare(
- "SELECT id FROM jobs WHERE repository=?1 AND (state IN ('queued','running','waiting') OR id IN (SELECT id FROM jobs WHERE repository=?1 ORDER BY createdAt DESC,id LIMIT 50)) ORDER BY createdAt DESC,id",
+ `SELECT id FROM jobs j WHERE repository=?1 AND (
+ state IN ('queued','running','waiting')
+ OR (state='failed' AND NOT EXISTS(SELECT 1 FROM jobs n WHERE n.repository=?1 AND n.target=j.target AND (n.createdAt>j.createdAt OR (n.createdAt=j.createdAt AND n.id();
diff --git a/cloudflare/test/inbox.test.ts b/cloudflare/test/inbox.test.ts
index 0608e397..3b93a647 100644
--- a/cloudflare/test/inbox.test.ts
+++ b/cloudflare/test/inbox.test.ts
@@ -276,8 +276,21 @@ describe("decision inbox", () => {
expect(active).not.toContain("Try again");
});
- it("keeps an older job's question beyond the newest fifty jobs", async () => {
+ it("keeps older questions and failures beyond the newest fifty jobs", async () => {
const { id, runner } = await running();
+ const failed = await store.createJob(
+ db,
+ {
+ repository: 10,
+ actor: user,
+ workflow: "plan",
+ target: "issues/1",
+ revision: commit,
+ branch: "plan/issue-1",
+ trusted: true,
+ },
+ 2,
+ );
await store.ask(
db,
runner,
@@ -304,8 +317,40 @@ describe("decision inbox", () => {
},
10 + i,
);
- await db.prepare("UPDATE jobs SET state='completed' WHERE id<>?").bind(id).run();
+ await db.batch([
+ db.prepare("UPDATE jobs SET state='completed' WHERE id NOT IN (?,?)").bind(id, failed.id),
+ db.prepare("UPDATE jobs SET state='failed' WHERE id=?").bind(failed.id),
+ ]);
expect(thread(await inbox())).toContain("Still there?");
+ // The failure stays a failure, not fresh work on the issue.
+ const issue = thread(await inbox({ project: "10", item: "issues/1" }));
+ expect(issue).toContain("Failed");
+ expect(issue).toContain("Try again");
+ });
+
+ it("retries a failed planning interview on its own target", async () => {
+ const target = `plans/${crypto.randomUUID()}`;
+ const { id, runner } = await running("plan", target);
+ await store.finish(db, runner, id, 1, { outcome: "failed", summary: "Broke.", result: null }, 3);
+ const active = thread(await inbox({ project: "10", item: target }));
+ expect(active).toContain(`name="target" value="${target}"`);
+ const retried = await submit(jobs, [
+ ["workflow", "plan"],
+ ["target", target],
+ ["revision", commit],
+ ]);
+ expect(retried.status).toBe(303);
+ const [latest] = await store.listJobs(db, 10);
+ expect(latest).toMatchObject({ target, state: "queued" });
+ expect(latest!.id).not.toBe(id);
+ });
+
+ it("offers no retry for a failed quest that is no longer ready", async () => {
+ const { id, runner } = await running("start", "quest/gone.md");
+ await store.finish(db, runner, id, 1, { outcome: "failed", summary: "Broke.", result: null }, 3);
+ const active = thread(await inbox({ project: "10", item: "quest/gone.md" }));
+ expect(active).toContain("This quest is no longer ready to start");
+ expect(active).not.toContain("Try again");
});
it("skips an item until it changes", async () => {
From 07c269c7a23792c4306f21f454f2bf07a5b236b4 Mon Sep 17 00:00:00 2001
From: Luke Curley
Date: Mon, 19 Oct 2026 11:52:32 -0700
Subject: [PATCH 07/10] fix(worker): read each target's latest job and encode
quest links
Codex review findings on #144, round 3:
- A finished job beyond the 50-job window let its still-open issue, PR,
or quest show as fresh work. The inbox now reads each target's latest
job however old: every one awaiting a decision, plus any other whose
target is still observed. An index on (repository, target, createdAt)
backs the lookup, replacing the 50-job window.
- Quest links interpolated raw paths, so a `#` or `?` in a filename
broke the board link. They now reuse the board's path encoding.
Co-Authored-By: Claude Opus 5.5
---
cloudflare/README.md | 7 ++++---
cloudflare/migrations/0010_inbox.sql | 3 +++
cloudflare/src/board/model.ts | 2 +-
cloudflare/src/inbox/model.ts | 25 +++++++++++++++----------
cloudflare/src/jobs/store.ts | 19 +++++++++----------
cloudflare/test/inbox.test.ts | 27 +++++++++++++++++++++++++--
6 files changed, 57 insertions(+), 26 deletions(-)
diff --git a/cloudflare/README.md b/cloudflare/README.md
index 6c671887..75a65771 100644
--- a/cloudflare/README.md
+++ b/cloudflare/README.md
@@ -174,9 +174,10 @@ are incomplete. Every action is a plain form post to the job routes, which
recheck authority and return to the inbox; prompts render as escaped text and
never preselect the recommendation. A `publication` decision lists the refused
paths or why the branch is blocked, and takes the maintainer's reply as text.
-The inbox reads every queued, running, and waiting job and each target's
-latest failure, so an older decision is never cut off by the job history's
-limit. Retrying a failed planning interview keeps its `plans/` target.
+The inbox reads each target's latest job however old it is: every one that
+awaits a decision, and any other whose issue, pull request, or quest is still
+observed, so finished work stays set aside. Retrying a failed planning
+interview keeps its `plans/` target.
Each maintainer's read state lives in `inbox_items`, keyed by a version of what
the item showed (a question, a GitHub change, a quest's text). A newer version
diff --git a/cloudflare/migrations/0010_inbox.sql b/cloudflare/migrations/0010_inbox.sql
index 4431b451..943bcd1e 100644
--- a/cloudflare/migrations/0010_inbox.sql
+++ b/cloudflare/migrations/0010_inbox.sql
@@ -11,3 +11,6 @@ CREATE TABLE inbox_items (
skipped TEXT,
PRIMARY KEY(user, repository, item)
);
+
+-- The inbox reads each target's latest job.
+CREATE INDEX jobs_target ON jobs(repository, target, createdAt);
diff --git a/cloudflare/src/board/model.ts b/cloudflare/src/board/model.ts
index 6119b22f..b066fdee 100644
--- a/cloudflare/src/board/model.ts
+++ b/cloudflare/src/board/model.ts
@@ -88,7 +88,7 @@ export function blobHref(project: Project, path: string, fragment = ""): string
return project.web ? `${project.web}/blob/${project.commit}/${encodePath(path)}${fragment}` : null;
}
-function encodePath(path: string): string {
+export function encodePath(path: string): string {
return path.split("/").map(encodeURIComponent).join("/");
}
diff --git a/cloudflare/src/inbox/model.ts b/cloudflare/src/inbox/model.ts
index 3780e741..3bb9baa8 100644
--- a/cloudflare/src/inbox/model.ts
+++ b/cloudflare/src/inbox/model.ts
@@ -4,11 +4,13 @@
// of adding prompts. Agent text and GitHub data stay untrusted display data.
import { parse } from "quest/core";
+import { encodePath } from "../board/model";
import { GitHubError, type Enrollment, GitHubService } from "../github/service";
import type { Activity, ProjectState } from "../github/state";
import { type Prompt, parsePrompt } from "../jobs/prompt";
import { hub } from "../jobs/routes";
import * as store from "../jobs/store";
+import { projectURL } from "../projects";
export type JobView = NonNullable>>;
export type QuestionView = JobView["questions"][number];
@@ -64,13 +66,13 @@ function kindOf(target: string): Kind {
/** A quest's page on the project's board: its path without `.md`, or its epic's directory. */
export function questHref(project: Enrollment, path: string) {
- return `/projects/github/${project.owner}/${project.name}/board/${path.replace(/(\/README)?\.md$/, "")}`;
+ return `${projectURL(project.owner, project.name)}/board/${encodePath(path.replace(/(\/README)?\.md$/, ""))}`;
}
-type Entity = Pick;
+export type Entity = Pick;
-/** The open issues, open pull requests, and ready quests a maintainer can act on. */
-function entities(project: Enrollment, state: ProjectState | null) {
+/** The open issues, open pull requests, and ready quests a maintainer can act on, by job target. */
+export function entities(project: Enrollment, state: ProjectState | null) {
const found = new Map();
if (!state) return found;
const web = `https://github.com/${project.owner}/${project.name}`;
@@ -116,13 +118,15 @@ function fallback(project: Enrollment, target: string): Entity {
return { kind, title, href, activity: null, version: "", at: 0 };
}
-/** Build one project's items from its observations, its latest jobs, and the maintainer's read state. */
-export function decisions(view: ProjectView, jobs: JobView[], reads: Read[]): Item[] {
- const { project, state } = view;
+/**
+ * Build one project's items from its observed `found` entities, its latest
+ * jobs, and the maintainer's read state.
+ */
+export function decisions(view: ProjectView, found: Map, jobs: JobView[], reads: Read[]): Item[] {
+ const { project } = view;
const latest = new Map();
// Jobs arrive newest first.
for (const job of jobs) if (!latest.has(job.target)) latest.set(job.target, job);
- const found = entities(project, state);
const items: Item[] = [];
for (const target of new Set([...found.keys(), ...latest.keys()])) {
const entity = found.get(target);
@@ -208,11 +212,12 @@ export async function load(env: Env, user: string) {
}),
);
const usable = projects.filter((view) => !view.error);
+ const found = usable.map((view) => entities(view.project, view.state));
const [reads, jobs] = await Promise.all([
env.DB.prepare("SELECT repository,item,seen,skipped FROM inbox_items WHERE user=?").bind(user).all(),
- Promise.all(usable.map((view) => store.listInboxJobs(env.DB, view.project.id))),
+ Promise.all(usable.map((view, i) => store.listInboxJobs(env.DB, view.project.id, [...found[i]!.keys()]))),
]);
- const items = usable.flatMap((view, i) => decisions(view, jobs[i]!, reads.results));
+ const items = usable.flatMap((view, i) => decisions(view, found[i]!, jobs[i]!, reads.results));
// Jobs run on their requester's runners, so presence comes from each requester's hub.
const actors = [...new Set(items.flatMap((item) => (item.job ? [item.job.actor] : [])))];
const runners = new Map(
diff --git a/cloudflare/src/jobs/store.ts b/cloudflare/src/jobs/store.ts
index b156a2fb..1b852b07 100644
--- a/cloudflare/src/jobs/store.ts
+++ b/cloudflare/src/jobs/store.ts
@@ -629,20 +629,19 @@ export async function listJobs(db: D1Database, repository: number) {
}
/**
- * The inbox's jobs, newest first: the 50 newest plus every job awaiting a
- * decision (queued, running, waiting, or its target's latest failure), so an
- * older decision is never cut off.
+ * The inbox's jobs, newest first: each target's latest job, however old, when
+ * it awaits a decision (queued, running, waiting, or failed) or its target is
+ * one of the `observed` issues, pull requests, or ready quests.
*/
-export async function listInboxJobs(db: D1Database, repository: number) {
+export async function listInboxJobs(db: D1Database, repository: number, observed: string[]) {
const rows = await db
.prepare(
- `SELECT id FROM jobs j WHERE repository=?1 AND (
- state IN ('queued','running','waiting')
- OR (state='failed' AND NOT EXISTS(SELECT 1 FROM jobs n WHERE n.repository=?1 AND n.target=j.target AND (n.createdAt>j.createdAt OR (n.createdAt=j.createdAt AND n.idj.createdAt OR (n.createdAt=j.createdAt AND n.id();
return (await Promise.all(rows.results.map((row) => readJob(db, row.id)))).filter((job) => job !== null);
}
diff --git a/cloudflare/test/inbox.test.ts b/cloudflare/test/inbox.test.ts
index 3b93a647..7ac137bd 100644
--- a/cloudflare/test/inbox.test.ts
+++ b/cloudflare/test/inbox.test.ts
@@ -1,6 +1,7 @@
import { env } from "cloudflare:workers";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { app } from "../src/index";
+import { questHref } from "../src/inbox/model";
import * as store from "../src/jobs/store";
import { privateKey } from "./github-key";
import { origin } from "./login-fixtures";
@@ -276,8 +277,21 @@ describe("decision inbox", () => {
expect(active).not.toContain("Try again");
});
- it("keeps older questions and failures beyond the newest fifty jobs", async () => {
+ it("keeps each target's latest job however many newer jobs there are", async () => {
const { id, runner } = await running();
+ await store.createJob(
+ db,
+ {
+ repository: 10,
+ actor: user,
+ workflow: "iterate",
+ target: "pulls/2",
+ revision: head,
+ branch: "contributor/fix",
+ trusted: false,
+ },
+ 2,
+ );
const failed = await store.createJob(
db,
{
@@ -321,7 +335,10 @@ describe("decision inbox", () => {
db.prepare("UPDATE jobs SET state='completed' WHERE id NOT IN (?,?)").bind(id, failed.id),
db.prepare("UPDATE jobs SET state='failed' WHERE id=?").bind(failed.id),
]);
- expect(thread(await inbox())).toContain("Still there?");
+ const html = await inbox();
+ expect(thread(html)).toContain("Still there?");
+ // The pull request's finished job still sets it aside rather than offering it as new work.
+ expect(html).toMatch(/Skipped and finished \(1\)[\s\S]*Fix/);
// The failure stays a failure, not fresh work on the issue.
const issue = thread(await inbox({ project: "10", item: "issues/1" }));
expect(issue).toContain("Failed");
@@ -353,6 +370,12 @@ describe("decision inbox", () => {
expect(active).not.toContain("Try again");
});
+ it("encodes quest paths in board links", () => {
+ const enrolled = { owner: "upstream", name: "project" } as Parameters[0];
+ expect(questHref(enrolled, "quest/a#b?.md")).toBe("/projects/github/upstream/project/board/quest/a%23b%3F");
+ expect(questHref(enrolled, "quest/epic/README.md")).toBe("/projects/github/upstream/project/board/quest/epic");
+ });
+
it("skips an item until it changes", async () => {
const skipped = await submit("/projects/github/upstream/project/skip", [
["item", "issues/1"],
From 44f4c8760228691645dd7fb84e5863903b66d456 Mon Sep 17 00:00:00 2001
From: Luke Curley
Date: Mon, 19 Oct 2026 12:05:22 -0700
Subject: [PATCH 08/10] fix(worker): limit publication replies by characters,
not JSON length
The inbox's reply box allows 1,000 characters, but the answer route
checked the JSON-serialized length against 1024, so quotes, backslashes,
and line breaks could get a valid reply refused as too large. The route
now checks the text itself against the same 1,000-character limit the
form uses, after normalizing the CRLF line breaks forms submit.
Co-Authored-By: Claude Opus 5.5
---
cloudflare/src/inbox/page.tsx | 4 ++--
cloudflare/src/jobs/routes.ts | 21 ++++++++++++++++++++-
cloudflare/test/inbox.test.ts | 20 ++++++++++++++------
3 files changed, 36 insertions(+), 9 deletions(-)
diff --git a/cloudflare/src/inbox/page.tsx b/cloudflare/src/inbox/page.tsx
index c8d48dd4..982b6256 100644
--- a/cloudflare/src/inbox/page.tsx
+++ b/cloudflare/src/inbox/page.tsx
@@ -1,6 +1,6 @@
import type { Pull } from "../github/state";
import { type Prompt, parsePrompt } from "../jobs/prompt";
-import { inboxURL } from "../jobs/routes";
+import { PUBLICATION_REPLY, inboxURL } from "../jobs/routes";
import { Marker } from "../layout";
import { projectURL } from "../projects";
import type { Item, ProjectView, QuestionView, Runners } from "./model";
@@ -201,7 +201,7 @@ function PublicationForm(props: { item: Item }) {
-
+
diff --git a/cloudflare/src/jobs/routes.ts b/cloudflare/src/jobs/routes.ts
index 58183b50..26fa9414 100644
--- a/cloudflare/src/jobs/routes.ts
+++ b/cloudflare/src/jobs/routes.ts
@@ -267,7 +267,11 @@ jobs.post(`${base}/:job/answer`, async (c) => {
question.id,
user,
// An agent's answer must fit its prompt; the store checks an account choice against the owner's accounts.
- question.kind === "agent" ? parseAnswer(parsePrompt(question.prompt), answer) : data(answer, 1024),
+ question.kind === "agent"
+ ? parseAnswer(parsePrompt(question.prompt), answer)
+ : question.kind === "publication"
+ ? publicationReply(answer)
+ : data(answer, 1024),
Date.now(),
);
// Jobs run on their requester's runners, whoever answers.
@@ -275,6 +279,21 @@ jobs.post(`${base}/:job/answer`, async (c) => {
return reply(c, form, job, null);
});
+/** The longest reply to a publication decision, in characters as the inbox form counts them. */
+export const PUBLICATION_REPLY = 1000;
+
+/**
+ * A publication decision's answer is the maintainer's own words, limited by
+ * length rather than encoding. Forms submit line breaks as CRLF but count them
+ * as one character, so they are normalized first.
+ */
+function publicationReply(answer: unknown) {
+ if (typeof answer !== "string" || !answer.trim()) throw new JobError("Reply in your own words");
+ const text = answer.replaceAll("\r\n", "\n");
+ if (text.length > PUBLICATION_REPLY) throw new JobError(`Reply in at most ${PUBLICATION_REPLY} characters`);
+ return text;
+}
+
/** An inbox form's answer: the chosen options, a publication reply's text, or the chosen `runner:account`. */
function formReply(question: store.Question, form: Form) {
if (question.kind === "agent") return formAnswer(parsePrompt(question.prompt), form);
diff --git a/cloudflare/test/inbox.test.ts b/cloudflare/test/inbox.test.ts
index 7ac137bd..9fd8479a 100644
--- a/cloudflare/test/inbox.test.ts
+++ b/cloudflare/test/inbox.test.ts
@@ -256,14 +256,22 @@ describe("decision inbox", () => {
expect(active).toContain("Publishing needs your decision");
// Runner-supplied paths are escaped.
expect(active).toContain("<src>/index.ts: Planning publishes only quest files");
- const answered = await submit(`${jobs}/${id}/answer`, [
- ["question", question],
- ["text", "Drop the source change"],
- ]);
- expect(answered.status).toBe(303);
+ // The form's limit counts characters, not their JSON escapes, and a line break as one.
+ const text = `Drop the source change. ${'"\\\n'.repeat(325)}x`;
+ expect(text).toHaveLength(1000);
+ // Browsers submit a textarea's line breaks as CRLF.
+ const sent = (reply: string) =>
+ submit(`${jobs}/${id}/answer`, [
+ ["question", question],
+ ["text", reply.replaceAll("\n", "\r\n")],
+ ]);
+ const long = await sent(`${text}y`);
+ expect(long.status).toBe(409);
+ expect(await long.text()).toContain("Reply in at most 1000 characters");
+ expect((await sent(text)).status).toBe(303);
const job = (await store.readJob(db, id))!;
expect(job.state).toBe("queued");
- expect(job.questions[0]!.answer).toBe("Drop the source change");
+ expect(job.questions[0]!.answer).toBe(text);
expect(thread(await inbox({ project: "10", item: "quest/first.md" }))).toContain("Drop the source change");
});
From c72cf428c98ad12f658bb4f5e1539e1d4cf5be49 Mon Sep 17 00:00:00 2001
From: Luke Curley
Date: Mon, 19 Oct 2026 12:05:22 -0700
Subject: [PATCH 09/10] docs(quest): plan batched inbox reads for m1
Co-Authored-By: Claude Opus 5.5
---
quest/m1/README.md | 1 +
quest/m1/inbox-reads.md | 20 ++++++++++++++++++++
2 files changed, 21 insertions(+)
create mode 100644 quest/m1/inbox-reads.md
diff --git a/quest/m1/README.md b/quest/m1/README.md
index 91071ea6..7a03ef31 100644
--- a/quest/m1/README.md
+++ b/quest/m1/README.md
@@ -15,3 +15,4 @@ additional login providers. This milestone does not gate the first release.
- [Donor isolation](/quest/m1/donor-isolation.md) - stronger boundaries for untrusted shared jobs
- [ChatGPT hosted runs](/quest/m1/chatgpt-hosted.md) - conditional on provider approval and current terms
- [ChatGPT approval](/quest/m1/chatgpt-waitlist.md) - resolve hosted-use approval before implementation
+- [Batched inbox reads](/quest/m1/inbox-reads.md) - a fixed number of D1 queries per project on each inbox load
diff --git a/quest/m1/inbox-reads.md b/quest/m1/inbox-reads.md
new file mode 100644
index 00000000..e8f01b62
--- /dev/null
+++ b/quest/m1/inbox-reads.md
@@ -0,0 +1,20 @@
+# [S] Read the inbox's jobs in one query per project
+
+## Goal
+
+Loading the decision inbox runs a fixed number of D1 queries per project,
+however many jobs, issues, PRs, and quests it shows. Today
+`listInboxJobs` reads each returned job with its own `readJob` batch.
+
+## Plan
+
+Decided 2026-10-19 as a follow-up of the decision inbox (#144), whose final
+review fix made the inbox return every observed target's latest job:
+
+- m1: m0 pilots track few targets per repository, and D1 calls within the
+ Worker are cheap, so this waits until after launch.
+- Replace the per-job `readJob` batches with one joined query, or one batch
+ of a constant number of queries, per project. Keep the output identical,
+ including questions, answers, and attempt state.
+- Add a test that seeds many jobs and asserts that the number of D1 queries
+ per inbox load does not grow with them.
From 6fe526c8241c29552f844d646ba0062971f559f3 Mon Sep 17 00:00:00 2001
From: Luke Curley
Date: Mon, 19 Oct 2026 12:17:23 -0700
Subject: [PATCH 10/10] docs(quest): plan retries that continue from the
published branch
Co-Authored-By: Claude Opus 5.5
---
quest/m0/kix/README.md | 1 +
quest/m0/kix/retry.md | 22 ++++++++++++++++++++++
2 files changed, 23 insertions(+)
create mode 100644 quest/m0/kix/retry.md
diff --git a/quest/m0/kix/README.md b/quest/m0/kix/README.md
index 41c70728..6b417ddf 100644
--- a/quest/m0/kix/README.md
+++ b/quest/m0/kix/README.md
@@ -100,6 +100,7 @@ operator-paid-hosting decisions:
- [Inbox merge](/quest/m0/kix/merge.md) - PR items merge the reviewed head through GitHub's rules
- [Co-maintainer inbox](/quest/m0/kix/members.md) - every current maintainer sees enrolled projects in their inbox
- [Answer flow](/quest/m0/kix/answer-flow.md) - move to the next decision after an answer, with neutral refusals
+- [Retry from the branch](/quest/m0/kix/retry.md) - a retried job continues from the head its failed attempt published
- [Decision paper trail](/quest/m0/kix/paper-trail.md) - planning PRs carry the recorded questions and answers, rendered by the service
- [Agent activity](/quest/m0/kix/activity.md) - agent cards and map highlights built from checkpoint summaries
- [Mobile web app](/quest/m0/kix/mobile.md) - an installable PWA with Web Push for decisions
diff --git a/quest/m0/kix/retry.md b/quest/m0/kix/retry.md
new file mode 100644
index 00000000..e5c32a3f
--- /dev/null
+++ b/quest/m0/kix/retry.md
@@ -0,0 +1,22 @@
+# [XS] Continue a retried job from its published branch
+
+## Goal
+
+"Try again" on a failed plan, bootstrap, or start job that already
+published to its branch continues from that branch's last published head.
+The retry publishes normally, and nobody deletes the branch by hand.
+
+## Plan
+
+Decided 2026-10-19 as a follow-up of the decision inbox (#144), whose final
+Codex review found that a retry starts with `head` unset, so its first
+publication pauses with "already has the branch … Delete it on GitHub to
+start over" (`cloudflare/src/jobs/publish.ts`):
+
+- The retry records the failed job's last published `head` and continues on
+ the same branch, the way a reassigned job resumes from its checkpoint. The
+ existing fence and head checks still apply.
+- A branch that changed outside kix since that head still pauses on the
+ existing decision; only a branch at the recorded head continues.
+- Test a retry after a published checkpoint, a retry with nothing
+ published, and a branch moved outside kix.