From 4aa4dc70618df5b867c79f1e3c746b53f4b06c9a Mon Sep 17 00:00:00 2001
From: Luke Curley
Date: Wed, 21 Oct 2026 11:57:51 -0700
Subject: [PATCH 1/2] chore(quest): claim quest/m0/kix/answer-flow
Co-Authored-By: Claude Opus 5.5
From 444d3187aa4fa7dcfd052dab5208626d83e858c0 Mon Sep 17 00:00:00 2001
From: Luke Curley
Date: Wed, 21 Oct 2026 12:03:04 -0700
Subject: [PATCH 2/2] feat(kix): move to the next decision after an inbox
answer
Every inbox form (answer, start, stop, skip, merge) now redirects through one
nextURL helper to the next active decision, or the empty state, with a short
notice such as "Answer recorded." The item acted on stays reachable from its
list with its earlier answers. A refused action is titled "Couldn't do that",
says why, and links back to the item it was posted from.
Co-Authored-By: Claude Opus 5.5
---
cloudflare/README.md | 7 +++++--
cloudflare/src/inbox/page.tsx | 17 ++++++++++++++++-
cloudflare/src/index.tsx | 28 ++++++++++++++++++++++------
cloudflare/src/jobs/routes.ts | 27 +++++++++++++++------------
cloudflare/test/inbox.test.ts | 32 +++++++++++++++++++++++++-------
quest/m0/kix/README.md | 1 -
quest/m0/kix/answer-flow.md | 20 --------------------
7 files changed, 83 insertions(+), 49 deletions(-)
delete mode 100644 quest/m0/kix/answer-flow.md
diff --git a/cloudflare/README.md b/cloudflare/README.md
index 5fb87f75..802d4182 100644
--- a/cloudflare/README.md
+++ b/cloudflare/README.md
@@ -173,8 +173,11 @@ 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 availability
is pending. 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
+recheck authority and move on to the next active decision (or the empty
+state) with a short notice such as "Answer recorded"; the item acted on stays
+reachable from its list with its earlier answers. A refused action says why
+and links back to its item. 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 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
diff --git a/cloudflare/src/inbox/page.tsx b/cloudflare/src/inbox/page.tsx
index 7b9a16ff..06c675e0 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 { PUBLICATION_REPLY, inboxURL } from "../jobs/routes";
+import { type Done, PUBLICATION_REPLY, inboxURL } from "../jobs/routes";
import { Marker } from "../layout";
import { projectURL } from "../projects";
import type { Item, ProjectView, QuestionView, Runners } from "./model";
@@ -15,12 +15,22 @@ export type InboxProps = {
active: Item | null;
/** The requested item is gone, such as a question another maintainer answered. */
missing: boolean;
+ /** What the previous form recorded, from the redirect's query. */
+ done: string | undefined;
decisions: Item[];
working: Item[];
skipped: Item[];
runners: Map;
};
+const recorded: Record = {
+ answer: "Answer recorded.",
+ start: "Job requested.",
+ stop: "Stop requested.",
+ skip: "Skipped until it changes.",
+ merge: "Pull request merged.",
+};
+
const kinds = { pull: "Pull request", issue: "Issue", quest: "Ready quest", plan: "Plan", devshell: "Bootstrap" };
function needLabel(item: Item) {
@@ -477,6 +487,11 @@ export function InboxPage(props: InboxProps) {
? `${props.decisions.length} waiting on you${unread ? `, ${unread} new` : ""}.`
: "Nothing needs a decision right now."}
+ {props.done && Object.hasOwn(recorded, props.done) && (
+
+ {recorded[props.done as Done]}
+
+ )}
{props.missing && (
That decision is no longer open. It may have been answered or changed on GitHub.
diff --git a/cloudflare/src/index.tsx b/cloudflare/src/index.tsx
index 22078cfd..2cbbcfb5 100644
--- a/cloudflare/src/index.tsx
+++ b/cloudflare/src/index.tsx
@@ -15,7 +15,7 @@ import { githubWebhooks } from "./github/webhook";
import * as inbox from "./inbox/model";
import { InboxPage } from "./inbox/page";
import { purge, retain } from "./jobs/output";
-import { BootstrapRequired, fromForm, jobs, resume, stop } from "./jobs/routes";
+import { BootstrapRequired, fromForm, inboxURL, jobs, nextURL, resume, stop } from "./jobs/routes";
import { JobError, active } from "./jobs/store";
import { Layout, type Page, type User } from "./layout";
@@ -230,7 +230,7 @@ app.post("/projects/github/:owner/:repository/skip", async (c) => {
if (typeof item !== "string" || typeof version !== "string" || item.length > 300 || version.length > 100)
return c.json({ error: "Item and version required" }, 400);
await inbox.skip(c.env.DB, session.user.id, repo.id, item, version);
- return c.redirect("/inbox", 303);
+ return c.redirect(nextURL("skip"), 303);
});
// A merge is the maintainer's own click on the head they saw; GitHub's rules decide whether it lands.
app.post("/projects/github/:owner/:repository/pulls/:number/merge", async (c) => {
@@ -250,7 +250,7 @@ app.post("/projects/github/:owner/:repository/pulls/:number/merge", async (c) =>
// The merged item is finished for this maintainer until GitHub's next observation drops it.
if (typeof version === "string" && version.length <= 100)
await inbox.skip(c.env.DB, session.user.id, repo.id, target, version);
- return c.redirect("/inbox", 303);
+ return c.redirect(nextURL("merge"), 303);
});
// The signed-in home: one decision at a time across the maintainer's projects.
@@ -273,6 +273,7 @@ app.get("/inbox", async (c) => {
projects={projects}
active={active}
missing={!!target && !chosen}
+ done={c.req.query("done")}
decisions={decisions}
working={working}
skipped={skipped}
@@ -377,12 +378,27 @@ app.notFound((c) =>
),
);
+/** The inbox item a refused form was posted from, or the inbox itself. */
+function back(c: Context<{ Bindings: Env }>) {
+ try {
+ const from = new URL(c.req.header("Referer") ?? "");
+ const project = from.searchParams.get("project");
+ const item = from.searchParams.get("item");
+ const here = from.origin === new URL(c.env.AUTH_URL).origin && from.pathname === "/inbox";
+ if (here && /^\d{1,15}$/.test(project ?? "") && item)
+ return { href: inboxURL(Number(project), item), label: "Return to the item" };
+ } catch {}
+ return { href: "/inbox", label: "Return to your decisions" };
+}
+
+// Any action can be refused, not only an answer, so the page names none.
function refused(c: Context<{ Bindings: Env }>, message: string, bootstrap: boolean, status: 403 | 409) {
+ const to = back(c);
return page(
c,
- { title: "Not recorded", description: "The decision was not recorded." },
+ { title: "Couldn't do that", description: "The request was refused." },
,
status,
diff --git a/cloudflare/src/jobs/routes.ts b/cloudflare/src/jobs/routes.ts
index 29842a7e..3df49478 100644
--- a/cloudflare/src/jobs/routes.ts
+++ b/cloudflare/src/jobs/routes.ts
@@ -33,20 +33,23 @@ async function input(c: Context): Promise<{ body: Record; form:
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);
+/** Answer the JSON API, or move a form on to the next decision. */
+function reply(c: Context, form: Form | null, done: Done, body: unknown, status: 200 | 201 = 200) {
+ if (form) return c.redirect(nextURL(done), 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 })}`;
}
+/** What an inbox form just recorded, which the next decision's page notes. */
+export type Done = "answer" | "start" | "stop" | "skip" | "merge";
+/**
+ * The inbox's next active decision, or its empty state, after a form recorded
+ * one. The item acted on stays reachable from its list, with its earlier answers.
+ */
+export function nextURL(done: Done) {
+ return `/inbox?${new URLSearchParams({ done })}`;
+}
/** The default branch has no flake for the job's devShell; a bootstrap job can add one. */
export class BootstrapRequired extends JobError {
@@ -252,7 +255,7 @@ jobs.post(base, async (c) => {
Date.now(),
);
await hub(c.env, user).dispatch(user);
- return reply(c, form, { repository: repo.id, target: job.target }, created, created.created ? 201 : 200);
+ return reply(c, form, "start", created, created.created ? 201 : 200);
});
jobs.post(`${base}/:job/answer`, async (c) => {
@@ -276,7 +279,7 @@ jobs.post(`${base}/:job/answer`, async (c) => {
);
// Jobs run on their requester's runners, whoever answers.
await hub(c.env, job.actor).dispatch(job.actor);
- return reply(c, form, job, null);
+ return reply(c, form, "answer", null);
});
/** The longest reply to a publication decision, in characters as the inbox form counts them. */
@@ -306,7 +309,7 @@ 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 reply(c, fromForm(c) ? {} : null, job, null);
+ return reply(c, fromForm(c) ? {} : null, "stop", null);
});
// Raw output stays behind a per-request maintainer check; summaries are the default view.
diff --git a/cloudflare/test/inbox.test.ts b/cloudflare/test/inbox.test.ts
index 021e3a04..5474615b 100644
--- a/cloudflare/test/inbox.test.ts
+++ b/cloudflare/test/inbox.test.ts
@@ -39,10 +39,10 @@ async function inbox(query: Record = {}) {
function thread(html: string) {
return html.match(//)?.[0] ?? "";
}
-function submit(path: string, fields: [string, string][]) {
+function submit(path: string, fields: [string, string][], headers: Record = {}) {
return app.request(
`${origin}${path}`,
- { method: "POST", headers: { Cookie: cookie, Origin: origin }, body: new URLSearchParams(fields) },
+ { method: "POST", headers: { Cookie: cookie, Origin: origin, ...headers }, body: new URLSearchParams(fields) },
testEnv,
);
}
@@ -135,7 +135,7 @@ describe("decision inbox", () => {
["revision", head],
]);
expect(started.status).toBe(303);
- expect(started.headers.get("Location")).toBe("/inbox?project=10&item=pulls%2F2");
+ expect(started.headers.get("Location")).toBe("/inbox?done=start");
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");
@@ -143,6 +143,7 @@ describe("decision inbox", () => {
const [job] = await store.listJobs(db, 10);
const stopped = await submit(`${jobs}/${job!.id}/cancel`, []);
expect(stopped.status).toBe(303);
+ expect(stopped.headers.get("Location")).toBe("/inbox?done=stop");
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/);
@@ -158,11 +159,18 @@ describe("decision inbox", () => {
expect(moved.status).toBe(403);
expect(await moved.text()).toContain("head moved since you looked; review it again");
github.refuse = "At least 1 approving review is required by reviewers with write access.";
- const refused = await submit(merge, [["head", head]]);
+ const refused = await submit(merge, [["head", head]], {
+ Referer: `${origin}/inbox?project=10&item=pulls%2F2`,
+ });
expect(refused.status).toBe(403);
- expect(await refused.text()).toContain(
+ // Any refused action reads neutrally, says why, and leads back to its item.
+ const explained = await refused.text();
+ expect(explained).toContain("Couldn't do that
");
+ expect(explained).not.toContain("Not recorded");
+ expect(explained).toContain(
"GitHub refused the merge: At least 1 approving review is required by reviewers with write access.",
);
+ expect(explained).toContain('Return to the item');
// Each click is one request bound to its head; a refusal is never retried.
expect(github.merged).toHaveLength(2);
github.refuse = null;
@@ -172,7 +180,7 @@ describe("decision inbox", () => {
["version", "0"],
]);
expect(merged.status).toBe(303);
- expect(merged.headers.get("Location")).toBe("/inbox");
+ expect(merged.headers.get("Location")).toBe("/inbox?done=merge");
expect(github.merged.at(-1)).toEqual({ sha: head, merge_method: "squash" });
// The merged item is finished, and no new work starts before GitHub is read again.
const html = await inbox();
@@ -268,6 +276,12 @@ describe("decision inbox", () => {
).toBe(409);
const answered = await submit(`${jobs}/${id}/answer`, answer);
expect(answered.status).toBe(303);
+ // The inbox moves on to the next decision and notes the answer there.
+ expect(answered.headers.get("Location")).toBe("/inbox?done=answer");
+ const next = await inbox({ done: "answer" });
+ expect(next).toContain('
Answer recorded.
');
+ expect(thread(next)).toContain("Fix");
+ expect(next.match(/In progress<\/h2>[\s\S]*?<\/ol>/)![0]).toContain("First");
const job = (await store.readJob(db, id))!;
expect(job.state).toBe("queued");
expect(job.questions[0]!.answer).toEqual({
@@ -452,6 +466,7 @@ describe("decision inbox", () => {
["version", "0"],
]);
expect(skipped.status).toBe(303);
+ expect(skipped.headers.get("Location")).toBe("/inbox?done=skip");
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/);
@@ -462,10 +477,13 @@ describe("decision inbox", () => {
it("reports a project without current authority and hides its items", async () => {
github.role = "write";
- const html = await inbox();
+ const html = await inbox({ done: "answer" });
expect(html).toContain("Unavailable: Current GitHub maintainer authority required");
expect(html).not.toContain("Idea");
+ // With no decision left, the notice shows on the empty state.
+ expect(html).toContain("Answer recorded.");
expect(html).toContain("Nothing needs a decision right now.");
+ expect(await inbox({ done: "constructor" })).not.toContain('
');
});
it("keeps paused projects' questions but offers no new work", async () => {
diff --git a/quest/m0/kix/README.md b/quest/m0/kix/README.md
index 6fc6af67..2020c36b 100644
--- a/quest/m0/kix/README.md
+++ b/quest/m0/kix/README.md
@@ -104,7 +104,6 @@ operator-paid-hosting decisions:
- [Webhook reconcile](/quest/m0/kix/reconcile.md) - new issues and PRs arrive in the inbox ready to act on, without a manual refresh
- [Merge jobs](/quest/m0/kix/merge-job.md) - a merge pick fixes conflicts, CI, and reviews on a runner, then lands the PR
- [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
diff --git a/quest/m0/kix/answer-flow.md b/quest/m0/kix/answer-flow.md
deleted file mode 100644
index 8497c1d6..00000000
--- a/quest/m0/kix/answer-flow.md
+++ /dev/null
@@ -1,20 +0,0 @@
-# [XS] Move to the next decision after an answer
-
-## Goal
-
-After a maintainer answers a decision in the inbox, the page moves on to the
-next active decision with a short "Answer recorded" notice. When an action is
-refused, the page has a neutral title and says why, whatever the action was.
-
-## Plan
-
-Decided 2026-10-19 as a follow-up of the decision inbox (#144), which stays
-on the item just answered, and titles every refusal "Not recorded" even for
-a refused "Refresh from GitHub":
-
-- Redirect to the next active decision, or to the inbox's empty state, and
- show the notice there. The answered item stays reachable under its
- earlier answers.
-- Retitle the refused-action page neutrally (for example "Couldn't do
- that"), with the specific reason and a link back to the item.
-- Keep the form posts working without JavaScript. Update the inbox tests.