From d43c559051489dc85488a82af360202242a9ae04 Mon Sep 17 00:00:00 2001
From: Luke Curley
Date: Wed, 21 Oct 2026 22:38:52 -0700
Subject: [PATCH 1/3] chore(quest): claim quest/m0/kix/inbox-accuracy.md
Quest-Claim: 3dd62499cca25163
From dd2a391af90ccaba17bbbb9bdba33533f0833f6f Mon Sep 17 00:00:00 2001
From: Luke Curley
Date: Wed, 21 Oct 2026 23:35:43 -0700
Subject: [PATCH 2/3] feat(kix): keep the board and inbox accurate on busy
projects
A draft PR its starter still holds (no `## Decisions needed`) shows as
Claimed on the board and in progress in the inbox; a handed-back draft
or ready PR is turned in. The hand-back rule moves to quest/core so the
CLI and the Worker agree.
GitHub's UNKNOWN merge state keeps a recorded PR's last known state at
the same head, and defers its change time at a new head until a definite
state arrives.
readState reads only open entities and unread pointers through a partial
index, the activity log pages, and a closed entity's row is pruned once
it has been closed past RETENTION (30 days, pending the user's choice).
The seed adds a held draft, a handed-back draft, and an UNKNOWN PR, and
its test opens only the items it checks.
Co-Authored-By: Claude Opus 5.5
---
cloudflare/GITHUB.md | 24 +++-
cloudflare/dev/github.ts | 6 +-
cloudflare/dev/seed.ts | 37 ++++++
cloudflare/migrations/0015_activity_reads.sql | 9 ++
cloudflare/src/board/model.ts | 5 +-
cloudflare/src/board/pages.tsx | 15 ++-
cloudflare/src/board/project.ts | 2 +
cloudflare/src/github/state.ts | 109 ++++++++++++++--
cloudflare/src/inbox/model.ts | 14 ++-
cloudflare/src/inbox/page.tsx | 5 +-
cloudflare/src/index.tsx | 20 ++-
cloudflare/src/projects.tsx | 19 ++-
cloudflare/test/inbox.test.ts | 25 ++++
cloudflare/test/projects.test.ts | 117 +++++++++++++++++-
cloudflare/test/seed.test.ts | 30 ++++-
design/theme.md | 4 +-
quest/m0/kix/README.md | 2 +-
quest/m0/kix/execution.md | 5 +-
quest/m0/kix/inbox-accuracy.md | 51 +++-----
src/cli/claims.ts | 6 +-
src/core/claim.ts | 10 ++
src/core/index.ts | 1 +
22 files changed, 434 insertions(+), 82 deletions(-)
create mode 100644 cloudflare/migrations/0015_activity_reads.sql
create mode 100644 src/core/claim.ts
diff --git a/cloudflare/GITHUB.md b/cloudflare/GITHUB.md
index 2e7deb4a..a5fc12aa 100644
--- a/cloudflare/GITHUB.md
+++ b/cloudflare/GITHUB.md
@@ -391,15 +391,33 @@ pushes/comments/reviews, and reconciliation all update the same item. Webhooks
retain identity pointers only, never issue/PR bodies. Authoritative API reads add
escaped titles, state, comment/review counts, current PR head, that head's check
rollup, aggregate review decision, and up to 20 latest reviewer records with an
-explicit pagination flag. Raw bodies and individual check logs are not cached.
+explicit pagination flag. Raw bodies and individual check logs are not cached:
+a PR's body is kept only as whether it has a `## Decisions needed` section.
Unavailable/deleted entities retain their notification identity and are marked
missing rather than disappearing or presenting old state as current.
+GitHub reports `mergeStateStatus` `UNKNOWN` while it computes mergeability and
+may send no event once it has. A read that sees it on a recorded PR at the same
+head keeps the last known merge state, so nothing looks new; at a new head it
+stores `UNKNOWN` but leaves the item's change time, so it becomes news once a
+definite state arrives. A newly seen PR is news either way.
+
+Closed entities only appear in the activity log, so a read that finds an entity
+closed for longer than `RETENTION` (`cloudflare/src/github/state.ts`) deletes its
+row instead of updating it, unless a webhook named it again since. History
+rereads every entity, so a pass prunes every expired row. The board, inbox, jobs
+and `/data` read only open entities and unread webhook pointers, through a
+partial index, and the activity log pages 50 rows at a time, so neither read
+grows with a repository's history.
+
A PR associates with a quest only when its head branch exactly matches the quest's
path without `.md` (or an epic's `/README.md`). An unrelated human PR remains in
activity without a guessed association. Open associated PRs and observed quest
branch claims exclude that quest from candidate data; branch claims display as
-Claimed on the board. Runner/run associations belong to job coordination.
+Claimed on the board. So does a draft PR its starter still holds, one without a
+`## Decisions needed` section, which the inbox lists as in progress rather than
+as a decision; a ready PR or a handed-back draft is Ready to turn in.
+Runner/run associations belong to job coordination.
Every cached read and refresh proves current maintainer authority, live repository
identity/public visibility, installation access, and enrollment for that
@@ -414,7 +432,7 @@ observation tied to its commit, **never execution permission**: job coordination
must validate current eligibility, fresh revision, locks, and PRs before a start.
Review/check data is similarly descriptive and cannot approve a merge.
-The `/data` endpoint exposes the same authenticated snapshot/activity with
+The `/data` endpoint exposes the same authenticated snapshot and open activity with
`Cache-Control: no-store` for future decision and job consumers. The activity
page is a secondary read view; the durable decision inbox and its unread/answer
state belong to the prompts workflow. Neither incoming activity nor refreshing
diff --git a/cloudflare/dev/github.ts b/cloudflare/dev/github.ts
index de23f825..406d9feb 100644
--- a/cloudflare/dev/github.ts
+++ b/cloudflare/dev/github.ts
@@ -12,8 +12,10 @@ export type Pull = Issue & {
review: "APPROVED" | "CHANGES_REQUESTED" | "REVIEW_REQUIRED" | null;
reviews?: { state: string; author: string }[];
draft?: boolean;
+ /** A `## Decisions needed` section in it hands a draft back to the maintainer. */
+ body?: string;
/** GitHub's mergeStateStatus; only CLEAN merges. Defaults to BLOCKED, or DRAFT for a draft. */
- merge?: "CLEAN" | "BLOCKED" | "DIRTY" | "UNSTABLE";
+ merge?: "CLEAN" | "BLOCKED" | "DIRTY" | "UNSTABLE" | "UNKNOWN";
};
export type Repo = {
id: number;
@@ -49,6 +51,7 @@ function entity(repo: Repo, item: Issue) {
title: item.title,
state: item.closed ? "CLOSED" : "OPEN",
updatedAt,
+ closedAt: item.closed ? updatedAt : null,
author: { login: item.author },
comments: { totalCount: item.comments ?? 0 },
};
@@ -59,6 +62,7 @@ function pullNode(repo: Repo, pull: Pull) {
const reviews = pull.reviews ?? [];
return {
...entity(repo, pull),
+ body: pull.body ?? "",
headRefName: pull.branch,
headRefOid: oid,
isDraft: !!pull.draft,
diff --git a/cloudflare/dev/seed.ts b/cloudflare/dev/seed.ts
index 6de077f8..12d3a5b9 100644
--- a/cloudflare/dev/seed.ts
+++ b/cloudflare/dev/seed.ts
@@ -49,8 +49,10 @@ export function world(): World {
content: quest("Later", "Work after the beta.", [
"- [Offline mode](/quest/m1/offline.md) - keep working without a network",
"- [Themes](/quest/m1/themes.md) - custom colors",
+ "- [Search](/quest/m1/search.md) - find anything",
]),
},
+ { path: "quest/m1/search.md", content: quest("[S] Search across projects", "Find anything.") },
{
path: "quest/m1/offline.md",
content: quest(
@@ -109,6 +111,38 @@ export function world(): World {
checks: null,
review: "REVIEW_REQUIRED",
},
+ // A claim its starter still holds: work in progress, not a decision.
+ {
+ number: 17,
+ title: "Draft the theme picker",
+ author: "contributor",
+ branch: "quest/m1/themes",
+ checks: "PENDING",
+ review: null,
+ draft: true,
+ body: "Started on the picker.",
+ },
+ // A draft handed back with decisions: it waits on the maintainer.
+ {
+ number: 18,
+ title: "Search quest titles",
+ author: "contributor",
+ branch: "quest/m1/search",
+ checks: "SUCCESS",
+ review: null,
+ draft: true,
+ body: "Titles are indexed.\n\n## Decisions needed\n\n1. Index quest bodies too?\n",
+ },
+ // Clean when first read; `seed` rereads it while GitHub reports UNKNOWN.
+ {
+ number: 19,
+ title: "Speed up cold start",
+ author: "contributor",
+ branch: "perf/cold-start",
+ checks: "SUCCESS",
+ review: "APPROVED",
+ merge: "CLEAN",
+ },
],
claims: ["quest/m0/docs"],
};
@@ -213,6 +247,9 @@ export async function seed(options: { fetch: Fetch; db: D1Database; world: World
"pause archive",
);
+ // GitHub recomputes a recorded PR's mergeability, such as after a push to
+ // its base. The read below keeps its last known state rather than marking it new.
+ rocket.pulls.find((pull) => pull.number === 19)!.merge = "UNKNOWN";
// An issue opened since the refresh: its webhook rereads the project in the background.
const pointer = { number: 9, title: "Sync stops after the laptop sleeps", author: "reporter" };
rocket.issues.push(pointer);
diff --git a/cloudflare/migrations/0015_activity_reads.sql b/cloudflare/migrations/0015_activity_reads.sql
new file mode 100644
index 00000000..79eb7851
--- /dev/null
+++ b/cloudflare/migrations/0015_activity_reads.sql
@@ -0,0 +1,9 @@
+-- Activity reads that every board, inbox, and background read makes stay
+-- proportional to open work, not to a repository's history. `readState` reads
+-- only open entities and unread webhook pointers, in this index's order (its
+-- WHERE term matches the index's exactly), and a background read takes the
+-- entities webhooks named. The paged activity log, read only on its own page,
+-- sorts the repository's rows, which retention bounds.
+CREATE INDEX github_project_activity_live ON github_project_activity(repository,firstSeen,kind,id)
+ WHERE (data IS NULL OR json_extract(data,'$.state')='OPEN');
+CREATE INDEX github_project_activity_stale ON github_project_activity(repository,changedAt,id) WHERE stale>0;
diff --git a/cloudflare/src/board/model.ts b/cloudflare/src/board/model.ts
index 94450e01..9a866e3f 100644
--- a/cloudflare/src/board/model.ts
+++ b/cloudflare/src/board/model.ts
@@ -14,7 +14,8 @@ export type Status =
| { kind: "turn-in"; change: Change }
| { kind: "available" }
| { kind: "pending" }
- | { kind: "accepted" }
+ /** Claimed by a quest branch, or by the draft PR its starter still holds. */
+ | { kind: "accepted"; change?: Change }
| { kind: "blocked"; requires: Ref[] };
/** A link to another quest, or a blocker's own words when it names none. */
@@ -135,7 +136,7 @@ export function readBoard(project: Project): Board {
const status = (doc: Doc): Status => {
const change = changes.get(doc.path);
- if (change) return { kind: "turn-in", change };
+ if (change) return change.owned ? { kind: "accepted", change } : { kind: "turn-in", change };
if (project.claims?.includes(doc.path)) return { kind: "accepted" };
if (free.has(doc.path)) return { kind: "available" };
if (open.has(doc.path)) return { kind: "pending" };
diff --git a/cloudflare/src/board/pages.tsx b/cloudflare/src/board/pages.tsx
index 847786cb..c39389dd 100644
--- a/cloudflare/src/board/pages.tsx
+++ b/cloudflare/src/board/pages.tsx
@@ -340,10 +340,17 @@ function Actions(props: { board: Board; quest: Quest; user: User | null; path: s
if (status.kind === "accepted")
return (
-
- A quest branch already claims this work. Review its current GitHub state before starting another
- implementation.
-
+ {status.change ? (
+
+ {status.change.author} is working on this in a draft PR. It
+ waits on you once they mark it ready or hand back decisions.
+
+ ) : (
+
+ A quest branch already claims this work. Review its current GitHub state before starting another
+ implementation.
+
+ )}
);
if (status.kind === "pending")
diff --git a/cloudflare/src/board/project.ts b/cloudflare/src/board/project.ts
index 2ef3e6ee..1d2a8944 100644
--- a/cloudflare/src/board/project.ts
+++ b/cloudflare/src/board/project.ts
@@ -34,6 +34,8 @@ export type Change = {
href: string;
/** The pull request's number, when the change is one observed on GitHub. */
number?: number;
+ /** A draft its author still holds, without a `## Decisions needed` section: a claim, not yet turned in. */
+ owned?: boolean;
};
/** An issue filed from a fork, quarantined under `issues/` until a maintainer promotes it. */
diff --git a/cloudflare/src/github/state.ts b/cloudflare/src/github/state.ts
index a0e0e41b..35181293 100644
--- a/cloudflare/src/github/state.ts
+++ b/cloudflare/src/github/state.ts
@@ -1,4 +1,4 @@
-import { parse, ready } from "quest/core";
+import { handedBack, parse, ready } from "quest/core";
import type { Project, Snapshot } from "../board/project";
import { GitHubError, type GitHubRepository, ProjectChanged } from "./service";
import { positiveId } from "./webhook";
@@ -17,6 +17,8 @@ type Entity = {
title: string;
state: string;
updatedAt: string;
+ /** When it closed or merged; absent in older observations. */
+ closedAt?: string | null;
author: { login: string } | null;
comments: { totalCount: number };
};
@@ -24,6 +26,8 @@ export type Pull = Entity & {
headRefName: string;
headRefOid: string;
isDraft: boolean;
+ /** Its body has a `## Decisions needed` section; the body itself is not kept. Absent in older observations. */
+ handedBack?: boolean;
reviewDecision: string | null;
/** GitHub's merge readiness for the App, such as CLEAN, BLOCKED, or DIRTY; absent in older observations. */
mergeStateStatus?: string;
@@ -66,8 +70,15 @@ export type ProjectState = {
failure: string | null;
};
-const entityFields = "databaseId:fullDatabaseId number title state updatedAt author { login } comments { totalCount }";
-const pullFields = `${entityFields} headRefName headRefOid isDraft reviewDecision mergeStateStatus reviews { totalCount } latestReviews(first:20) { nodes { state author {login} commit {oid} } pageInfo {hasNextPage} } commits(last:1) { nodes { commit { oid statusCheckRollup { state } } } }`;
+/**
+ * How long a closed issue or PR stays in the activity log. Only the log shows
+ * closed entities, so after this its row is pruned when history next reads it.
+ */
+export const RETENTION = 30 * 24 * 60 * 60 * 1000;
+
+const entityFields =
+ "databaseId:fullDatabaseId number title state updatedAt closedAt author { login } comments { totalCount }";
+const pullFields = `${entityFields} body headRefName headRefOid isDraft reviewDecision mergeStateStatus reviews { totalCount } latestReviews(first:20) { nodes { state author {login} commit {oid} } pageInfo {hasNextPage} } commits(last:1) { nodes { commit { oid statusCheckRollup { state } } } }`;
// Cursor variables are declared only for unfinished connections (GraphQL rejects
// unused variables). Fixed page sizes bound subrequests, independent of tree size.
export function activityQuery({ history, occupancy }: Scan, stale: Stale[] = []) {
@@ -142,6 +153,26 @@ function validate(kind: "issue" | "pull", item: Entity) {
}
}
+/** What is kept of an observed entity: a PR's body only as whether it hands its draft back. */
+function observed(kind: "issue" | "pull", item: Entity): Entity {
+ if (kind === "issue") return item;
+ const { body, ...pull } = item as Pull & { body?: unknown };
+ return { ...pull, handedBack: typeof body === "string" && handedBack(body) } as Pull;
+}
+
+/**
+ * A draft its starter still holds: claimed work in progress until its body
+ * hands it back with `## Decisions needed`, as the guide defines.
+ */
+export function owned(pull: Pull) {
+ return pull.isDraft && !pull.handedBack;
+}
+
+/** Closed for longer than `RETENTION`. */
+function expired(item: Entity, now: number) {
+ return item.state !== "OPEN" && typeof item.closedAt === "string" && Date.parse(item.closedAt) < now - RETENTION;
+}
+
/** Entities that webhooks named since they were last read by number, oldest first. */
export async function staleEntities(db: D1Database, repository: number) {
return (
@@ -201,7 +232,7 @@ export async function reconcile(
if (cursor === false) continue;
for (const item of connection!.nodes) {
validate(kind, item);
- entities.set(`${kind}/${item.databaseId}`, { kind, item, stale: 0 });
+ entities.set(`${kind}/${item.databaseId}`, { kind, item: observed(kind, item), stale: 0 });
if (open) updated.occupancy.pulls.push(item.databaseId);
}
}
@@ -213,12 +244,30 @@ export async function reconcile(
if (item) validate(entity.kind, item);
// A number GitHub no longer resolves to this entity, such as a deleted issue.
if (!item || item.databaseId !== entity.id) gone.push(entity);
- else entities.set(`${entity.kind}/${entity.id}`, { kind: entity.kind, item, stale: entity.stale });
+ else
+ entities.set(`${entity.kind}/${entity.id}`, {
+ kind: entity.kind,
+ item: observed(entity.kind, item),
+ stale: entity.stale,
+ });
});
+ // Long-closed entities lose their rows, unless a webhook named one again since it was read.
+ const kept = [...entities.values()].filter((entity) => !expired(entity.item, now));
+ const pruned = [...entities.values()]
+ .filter((entity) => expired(entity.item, now))
+ .map(({ kind, item, stale }) => ({ kind, id: item.databaseId, stale }));
updated.occupancy.claims = [...new Set(updated.occupancy.claims)];
updated.occupancy.pulls = [...new Set(updated.occupancy.pulls)];
const current = "EXISTS(SELECT 1 FROM github_project_state WHERE repository=? AND revision=? AND epoch=?)";
- // json_each keeps the transaction to four statements and a few bindings,
+ // GitHub reports UNKNOWN while it computes mergeability, and may send no
+ // event once it has. A recorded PR at the same head keeps its last known
+ // merge state; at a new head it keeps its changedAt, so the change is news
+ // once a definite state arrives. A newly seen PR is news either way.
+ const prior = "github_project_activity.data";
+ const unknown = `json_extract(excluded.data,'$.mergeStateStatus')='UNKNOWN' AND ${prior} IS NOT NULL`;
+ const keeps = `${unknown} AND json_extract(${prior},'$.headRefOid')=json_extract(excluded.data,'$.headRefOid') AND json_extract(${prior},'$.mergeStateStatus') IS NOT NULL`;
+ const data = `CASE WHEN ${keeps} THEN json_set(excluded.data,'$.mergeStateStatus',json_extract(${prior},'$.mergeStateStatus')) ELSE excluded.data END`;
+ // json_each keeps the transaction to five statements and a few bindings,
// within D1's free query budget and SQLite's bound-parameter limit.
const statements = [
db
@@ -226,9 +275,14 @@ export async function reconcile(
`INSERT INTO github_project_activity(repository,kind,id,number,firstSeen,changedAt,data,seenIn,stale)
SELECT ?,json_extract(value,'$.kind'),json_extract(value,'$.item.databaseId'),json_extract(value,'$.item.number'),?,?,json_extract(value,'$.item'),?,json_extract(value,'$.stale')
FROM json_each(?) WHERE ${current}
- ON CONFLICT(repository,kind,id) DO UPDATE SET number=excluded.number,changedAt=CASE WHEN github_project_activity.data IS NOT excluded.data THEN excluded.changedAt ELSE github_project_activity.changedAt END,data=excluded.data,seenIn=excluded.seenIn,missing=0,stale=CASE WHEN excluded.stale>0 AND github_project_activity.stale=excluded.stale THEN 0 ELSE github_project_activity.stale END`,
+ ON CONFLICT(repository,kind,id) DO UPDATE SET number=excluded.number,changedAt=CASE WHEN ${unknown} AND NOT (${keeps}) THEN github_project_activity.changedAt WHEN ${prior} IS NOT ${data} THEN excluded.changedAt ELSE github_project_activity.changedAt END,data=${data},seenIn=excluded.seenIn,missing=0,stale=CASE WHEN excluded.stale>0 AND github_project_activity.stale=excluded.stale THEN 0 ELSE github_project_activity.stale END`,
)
- .bind(repo.id, now, now, history.id, JSON.stringify([...entities.values()]), repo.id, revision, epoch),
+ .bind(repo.id, now, now, history.id, JSON.stringify(kept), repo.id, revision, epoch),
+ db
+ .prepare(
+ `DELETE FROM github_project_activity WHERE repository=? AND EXISTS(SELECT 1 FROM json_each(?) WHERE json_extract(value,'$.kind')=github_project_activity.kind AND json_extract(value,'$.id')=github_project_activity.id AND json_extract(value,'$.stale')=github_project_activity.stale) AND ${current}`,
+ )
+ .bind(repo.id, JSON.stringify(pruned), repo.id, revision, epoch),
db
.prepare(
`UPDATE github_project_activity SET missing=1,stale=CASE WHEN stale=json_extract(value,'$.stale') THEN 0 ELSE stale END
@@ -267,6 +321,37 @@ export function startable(state: ProjectState, quest: string, retry: number | nu
);
}
+/** Open entities and unread webhook pointers; migration 0015's partial index matches this term exactly. */
+const LIVE = "(data IS NULL OR json_extract(data,'$.state')='OPEN')";
+
+type Row = Omit & { data: string | null };
+const parsed = (rows: Row[]): Activity[] =>
+ rows.map((item) => ({ ...item, data: item.data ? JSON.parse(item.data) : null }));
+
+/** Where `readActivity` resumes: the last shown row's position, newest first. */
+const CURSOR = /^(\d+)-(issue|pull)-([1-9]\d*)$/;
+
+/**
+ * One page of a project's activity log, open and closed alike, newest first,
+ * and the cursor of the next page. An unreadable cursor starts from the top.
+ */
+export async function readActivity(db: D1Database, repository: number, before: string | null = null, limit = 50) {
+ const at = before ? CURSOR.exec(before) : null;
+ const rows = (
+ await db
+ .prepare(
+ `SELECT kind,id,number,firstSeen,changedAt,data,missing FROM github_project_activity
+ WHERE repository=?1 AND (?2 IS NULL OR (firstSeen,kind,id)<(?2,?3,?4))
+ ORDER BY firstSeen DESC,kind DESC,id DESC LIMIT ?5`,
+ )
+ .bind(repository, at ? Number(at[1]) : null, at?.[2] ?? null, at?.[3] ?? null, limit + 1)
+ .all()
+ ).results;
+ const items = parsed(rows.slice(0, limit));
+ const last = items.at(-1);
+ return { items, next: rows.length > limit && last ? `${last.firstSeen}-${last.kind}-${last.id}` : null };
+}
+
export async function readState(db: D1Database, repo: GitHubRepository): Promise {
const records = await db.batch([
db
@@ -274,9 +359,11 @@ export async function readState(db: D1Database, repo: GitHubRepository): Promise
"SELECT snapshot,scan,observedAt,revision,invalidated,failure FROM github_project_state WHERE repository=?",
)
.bind(repo.id),
+ // Only open entities and unread pointers decide the board, the inbox, and
+ // jobs, so this read grows with open work, not history.
db
.prepare(
- "SELECT kind,id,number,firstSeen,changedAt,data,missing FROM github_project_activity WHERE repository=? ORDER BY firstSeen DESC,id DESC",
+ `SELECT kind,id,number,firstSeen,changedAt,data,missing FROM github_project_activity WHERE repository=? AND ${LIVE} ORDER BY firstSeen DESC,kind DESC,id DESC`,
)
.bind(repo.id),
]);
@@ -293,8 +380,7 @@ export async function readState(db: D1Database, repo: GitHubRepository): Promise
if (!row?.snapshot || !row.scan) return null;
const snapshot: Snapshot = JSON.parse(row.snapshot);
const scan: Scan = JSON.parse(row.scan);
- const rows = records[1]!.results as (Omit & { data: string | null })[];
- const activity: Activity[] = rows.map((item) => ({ ...item, data: item.data ? JSON.parse(item.data) : null }));
+ const activity = parsed(records[1]!.results as Row[]);
const paths = new Set(snapshot.documents.map((d) => d.path));
// A PR's activity row can still say OPEN until history rereads it, so only
// PRs in the latest occupancy pass count as open.
@@ -311,6 +397,7 @@ export async function readState(db: D1Database, repo: GitHubRepository): Promise
author: pull.author?.login ?? "GitHub contributor",
href: `https://github.com/${repo.owner}/${repo.name}/pull/${pull.number}`,
number: pull.number,
+ owned: owned(pull),
},
]
: [];
diff --git a/cloudflare/src/inbox/model.ts b/cloudflare/src/inbox/model.ts
index 79c8f26c..028fee69 100644
--- a/cloudflare/src/inbox/model.ts
+++ b/cloudflare/src/inbox/model.ts
@@ -6,7 +6,7 @@
import { parse } from "quest/core";
import { encodePath } from "../board/model";
import { GitHubError, type Enrollment, GitHubService, NotMaintainer } from "../github/service";
-import { type Activity, type ProjectState, readState } from "../github/state";
+import { type Activity, type ProjectState, type Pull, owned, readState } from "../github/state";
import { type Prompt, parsePrompt } from "../jobs/prompt";
import { hub } from "../jobs/routes";
import * as store from "../jobs/store";
@@ -106,6 +106,11 @@ export function entities(project: Enrollment, state: ProjectState | null) {
return found;
}
+/** A PR whose observed draft its starter still holds, so nothing waits on the maintainer. */
+function held(entity: Pick- ) {
+ return entity.kind === "pull" && !!entity.activity?.data && owned(entity.activity.data as Pull);
+}
+
function fallback(project: Enrollment, target: string): Entity {
const web = `https://github.com/${project.owner}/${project.name}`;
const kind = kindOf(target);
@@ -151,8 +156,11 @@ export function decisions(view: ProjectView, found: Map, jobs: J
} 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;
+ // A draft its starter still holds is their work in progress until they hand it back.
+ if (!held(entity)) {
+ need = "start";
+ ended = !!job && !changed;
+ }
} else continue;
const read = reads.find((r) => r.repository === project.id && r.item === target);
let prompt: Prompt | null = null;
diff --git a/cloudflare/src/inbox/page.tsx b/cloudflare/src/inbox/page.tsx
index ee844133..17269849 100644
--- a/cloudflare/src/inbox/page.tsx
+++ b/cloudflare/src/inbox/page.tsx
@@ -44,12 +44,15 @@ function needLabel(item: Item) {
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";
+ if (!item.job) return "Claimed draft";
+ 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 === null)
+ return "Its starter holds this draft. It waits on you once they mark it ready or hand back decisions.";
if (!job || item.need === "start") return null;
const whose = job.actor === user ? "your" : "the requester's";
if (job.state === "queued")
diff --git a/cloudflare/src/index.tsx b/cloudflare/src/index.tsx
index c24adb9d..28f656f9 100644
--- a/cloudflare/src/index.tsx
+++ b/cloudflare/src/index.tsx
@@ -10,6 +10,7 @@ import { type Project, findProject, home } from "./board/project";
import { DocIndex, DocPage, findDoc } from "./docs";
import { Home } from "./home";
import { type Enrollment, GitHubError, GitHubService } from "./github/service";
+import { readActivity } from "./github/state";
import { ProjectsPage, ProjectSetupPage, ProjectStopControls, ProjectStatePage, projectURL } from "./projects";
import { githubWebhooks } from "./github/webhook";
import * as inbox from "./inbox/model";
@@ -200,19 +201,26 @@ async function liveProject(c: Context<{ Bindings: Env }>, refresh = false) {
const github = new GitHubService(c.env);
const repo = await github.enrolledProject(c.req.param("owner")!, c.req.param("repository")!, session.user.id);
if (!repo) throw new GitHubError("Project is not enrolled");
- return github.projectState(repo, session.user.id, refresh);
+ return { repo, state: await github.projectState(repo, session.user.id, refresh) };
}
app.get("/projects/github/:owner/:repository/state", async (c) => {
- const state = await liveProject(c);
+ const { repo, state } = await liveProject(c);
+ // The activity log is the one view of closed entities, so it pages.
+ const log = await readActivity(c.env.DB, repo.id, c.req.query("before") ?? null);
return page(
c,
{ title: "Project activity", description: "Observed GitHub project state." },
- ,
+ ,
);
});
app.get("/projects/github/:owner/:repository/data", async (c) => {
c.header("Cache-Control", "no-store");
- return c.json(await liveProject(c));
+ return c.json((await liveProject(c)).state);
});
app.post("/projects/github/:owner/:repository/refresh", async (c) => {
await liveProject(c, true);
@@ -290,7 +298,7 @@ app.get("/inbox", async (c) => {
);
});
app.get("/projects/github/:owner/:repository/board", async (c) => {
- const state = await liveProject(c);
+ const { state } = await liveProject(c);
if (!state) return c.redirect(`${projectURL(c.req.param("owner"), c.req.param("repository"))}/state`, 303);
return page(
c,
@@ -302,7 +310,7 @@ app.get("/projects/github/:owner/:repository/board", async (c) => {
);
});
app.get("/projects/github/:owner/:repository/board/quest/*", async (c) => {
- const state = await liveProject(c);
+ const { state } = await liveProject(c);
if (!state) return c.notFound();
let segments: string[];
try {
diff --git a/cloudflare/src/projects.tsx b/cloudflare/src/projects.tsx
index a6dce431..03065ef5 100644
--- a/cloudflare/src/projects.tsx
+++ b/cloudflare/src/projects.tsx
@@ -1,4 +1,4 @@
-import type { ProjectState, Pull } from "./github/state";
+import type { Activity, ProjectState, Pull } from "./github/state";
import type { Enrollment, ProjectPreview } from "./github/service";
import { Difficulty, Marker } from "./layout";
import { title } from "./board/model";
@@ -213,7 +213,13 @@ export function ProjectSetupPage(props: { preview: ProjectPreview; enrollment: E
);
}
-export function ProjectStatePage(props: { owner: string; repository: string; state: ProjectState | null }) {
+export function ProjectStatePage(props: {
+ owner: string;
+ repository: string;
+ state: ProjectState | null;
+ /** One page of the activity log, and where the next begins. */
+ log: { items: Activity[]; next: string | null };
+}) {
const base = projectURL(props.owner, props.repository);
const state = props.state;
return (
@@ -262,9 +268,9 @@ export function ProjectStatePage(props: { owner: string; repository: string; sta
No observed branch claims.
)}
Issues and pull requests
- {state.activity.length ? (
+ {props.log.items.length ? (
)}
+ {props.log.next && (
+
+ Older activity
+
+ )}
>
) : (
Refresh to read this enrolled project's quest tree and activity.
diff --git a/cloudflare/test/inbox.test.ts b/cloudflare/test/inbox.test.ts
index 94f2c255..4f8516cc 100644
--- a/cloudflare/test/inbox.test.ts
+++ b/cloudflare/test/inbox.test.ts
@@ -152,6 +152,31 @@ describe("decision inbox", () => {
expect(thread(html)).not.toContain("Plan this issue");
});
+ it("shows a draft its starter holds as work in progress until it hands back decisions", async () => {
+ const draft = (handedBack: boolean) =>
+ db
+ .prepare(
+ "UPDATE github_project_activity SET data=json_set(data,'$.isDraft',json('true'),'$.handedBack',json(?)),changedAt=changedAt+1 WHERE id='21'",
+ )
+ .bind(String(handedBack))
+ .run();
+ await draft(false);
+ const html = await inbox();
+ expect(thread(html)).not.toContain(">Fix<");
+ expect(html.match(/In progress<\/h2>[\s\S]*?<\/ol>/)![0]).toMatch(
+ /Fix<\/a>Claimed draft/,
+ );
+ const held = thread(await inbox({ project: "10", item: "pulls/2" }));
+ expect(held).toContain("Its starter holds this draft");
+ expect(held).not.toContain("Iterate on this PR");
+ expect(held).not.toContain("Skip until it changes");
+ await draft(true);
+ const handed = await inbox();
+ expect(thread(handed)).toContain("Fix");
+ expect(thread(handed)).toContain("Iterate on this PR");
+ expect(handed).not.toContain("In progress
");
+ });
+
it("drops a pull request the latest occupancy pass no longer found open", async () => {
expect(thread(await inbox({ project: "10", item: "pulls/2" }))).toContain("Iterate on this PR");
// History has not reread it, so its row still says open.
diff --git a/cloudflare/test/projects.test.ts b/cloudflare/test/projects.test.ts
index eb43b281..26cf6f78 100644
--- a/cloudflare/test/projects.test.ts
+++ b/cloudflare/test/projects.test.ts
@@ -3,7 +3,7 @@ import { createHash } from "node:crypto";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { app } from "../src/index";
import { readBoard } from "../src/board/model";
-import type { ActivityPage } from "../src/github/state";
+import { type ActivityPage, RETENTION, readActivity } from "../src/github/state";
import { GitHubService } from "../src/github/service";
import { privateKey } from "./github-key";
import { identity, oauth, origin } from "./login-fixtures";
@@ -789,6 +789,81 @@ describe("enrolled project observations", () => {
"d".repeat(40),
);
});
+ it("shows a draft its starter holds as claimed, and a handed-back draft as turned in", async () => {
+ await enrolled();
+ const page = activity();
+ const draft = Object.assign(page.pullRequests!.nodes[0]!, { isDraft: true, body: "Work so far." });
+ const first = await github({ activity: page }).service.projectState(repo, "enrollment-test", true);
+ expect(first!.project.changes).toMatchObject([{ quest: "quest/first.md", number: 2, owned: true }]);
+ expect(readBoard(first!.project).quests.get("quest/first.md")!.status).toMatchObject({
+ kind: "accepted",
+ change: { number: 2 },
+ });
+ // Only whether the body hands the draft back is kept.
+ const data = first!.activity.find((item) => item.id === "200")!.data!;
+ expect(data).toMatchObject({ handedBack: false });
+ expect(data).not.toHaveProperty("body");
+ draft.body = "Work so far.\n\n## Decisions needed\n\n1. Which name?\n";
+ const next = await github({ activity: page }).service.projectState(repo, "enrollment-test", true);
+ expect(next!.project.changes).toMatchObject([{ quest: "quest/first.md", owned: false }]);
+ expect(readBoard(next!.project).quests.get("quest/first.md")!.status.kind).toBe("turn-in");
+ });
+ it("keeps a recorded PR's state while GitHub computes its mergeability", async () => {
+ await enrolled();
+ const page = (status: string, oid = commit) => {
+ const result = activity();
+ const [mine, other] = result.pullRequests!.nodes as (ReturnType & {
+ mergeStateStatus?: string;
+ })[];
+ Object.assign(mine!, { mergeStateStatus: status, headRefOid: oid });
+ mine!.commits.nodes[0]!.commit.oid = oid;
+ other!.mergeStateStatus = "UNKNOWN";
+ return result;
+ };
+ const refresh = async (status: string, oid = commit) => {
+ const state = await github({ activity: page(status, oid) }).service.projectState(
+ repo,
+ "enrollment-test",
+ true,
+ );
+ const rows = Object.fromEntries(
+ state!.activity.map((item) => [item.id, { changedAt: item.changedAt, data: item.data as any }]),
+ );
+ // Forget when each changed, so the next refresh shows whether it changes again.
+ await env.DB.prepare("UPDATE github_project_activity SET changedAt=0 WHERE repository=10").run();
+ return rows;
+ };
+ const seen = await refresh("CLEAN");
+ // A newly seen PR is news even while its state is unknown.
+ expect(seen["201"]).toMatchObject({ data: { mergeStateStatus: "UNKNOWN" } });
+ expect(seen["201"]!.changedAt).toBeGreaterThan(0);
+ // At the same head, the last known state stands and nothing is new.
+ expect(await refresh("UNKNOWN")).toMatchObject({ 200: { changedAt: 0, data: { mergeStateStatus: "CLEAN" } } });
+ // At a new head, the state is unknown, and the change is news only once it is known.
+ const moved = "e".repeat(40);
+ expect(await refresh("UNKNOWN", moved)).toMatchObject({
+ 200: { changedAt: 0, data: { mergeStateStatus: "UNKNOWN", headRefOid: moved } },
+ });
+ expect((await refresh("BLOCKED", moved))["200"]!.changedAt).toBeGreaterThan(0);
+ });
+ it("prunes a closed entity's activity once it has been closed past retention", async () => {
+ await enrolled();
+ await github().service.projectState(repo, "enrollment-test", true);
+ const page = activity();
+ const old = new Date(Date.now() - RETENTION - 60_000).toISOString();
+ Object.assign(page.issues!.nodes[0]!, { state: "CLOSED", closedAt: old });
+ Object.assign(page.pullRequests!.nodes[1]!, { state: "MERGED", closedAt: new Date().toISOString() });
+ page.open = connection([page.pullRequests!.nodes[0]!]);
+ const state = await github({ activity: page }).service.projectState(repo, "enrollment-test", true);
+ // Only open entities are read for the board and inbox; the log keeps recently closed ones.
+ expect(state!.activity.map((item) => item.id)).toEqual(["200"]);
+ const log = await readActivity(env.DB, repo.id);
+ expect(log.items.map((item) => [item.id, item.data?.state])).toEqual([
+ ["201", "MERGED"],
+ ["200", "OPEN"],
+ ]);
+ expect(log.next).toBeNull();
+ });
it("resumes independent cursors and reconciles closed entities and removed claims in later scans", async () => {
await enrolled();
const page = activity();
@@ -1182,6 +1257,20 @@ describe("webhook reconciliation", () => {
expect(await row("100")).toMatchObject({ stale: 0, missing: 1 });
});
+ it("prunes a long-closed issue that a webhook names again", async () => {
+ await enrolled();
+ const closedAt = new Date(Date.now() - RETENTION - 60_000).toISOString();
+ mocked({ entities: { 1: { ...issue(), state: "CLOSED", closedAt } } });
+ await delivery(
+ "issue_comment",
+ { ...event, action: "created", issue: { id: 100, number: 1 } },
+ undefined,
+ {},
+ testEnv,
+ );
+ expect(await row("100")).toBeNull();
+ });
+
it("rereads availability after a webhook that can claim a quest", async () => {
await enrolled();
const quiet = {
@@ -1257,6 +1346,32 @@ describe("project activity routes", () => {
expect(data.headers.get("cache-control")).toBe("no-store");
expect(await data.json()).toMatchObject({ complete: true, project: { commit } });
});
+ it("pages the activity log, newest first", async () => {
+ const cookie = await session();
+ await app.request(`${base}/refresh`, { method: "POST", headers: { Cookie: cookie, Origin: origin } }, testEnv);
+ await env.DB.batch(
+ Array.from({ length: 60 }, (_, i) =>
+ env.DB.prepare(
+ "INSERT INTO github_project_activity(repository,kind,id,number,firstSeen,changedAt,data,seenIn) VALUES(10,'issue',?,?,?,0,?,'old')",
+ ).bind(
+ String(1000 + i),
+ 1000 + i,
+ i,
+ JSON.stringify({ ...issue(1000 + i, 1000 + i), title: `Closed ${i}`, state: "CLOSED" }),
+ ),
+ ),
+ );
+ const first = await (await app.request(`${base}/state`, { headers: { Cookie: cookie } }, testEnv)).text();
+ expect(first).toContain("<script>title</script>");
+ expect(first).toContain("Closed 59");
+ expect(first).not.toContain("Closed 12<");
+ const older = first.match(/href="([^"]+\/state\?before=[^"]+)">Older activity/)![1]!.replaceAll("&", "&");
+ const second = await (await app.request(`${origin}${older}`, { headers: { Cookie: cookie } }, testEnv)).text();
+ expect(second).toContain("Closed 12<");
+ expect(second).toContain("Closed 0<");
+ expect(second).not.toContain("Closed 13<");
+ expect(second).not.toContain("Older activity");
+ });
it("uses the shared board and resolves nested quest routes within the authenticated project", async () => {
const cookie = await session();
await app.request(`${base}/refresh`, { method: "POST", headers: { Cookie: cookie, Origin: origin } }, testEnv);
diff --git a/cloudflare/test/seed.test.ts b/cloudflare/test/seed.test.ts
index 8d4356cc..11c920a8 100644
--- a/cloudflare/test/seed.test.ts
+++ b/cloudflare/test/seed.test.ts
@@ -6,6 +6,7 @@ import { afterEach, expect, it, vi } from "vitest";
import { fakeGitHub } from "../dev/github";
import { seed, world } from "../dev/seed";
import { app } from "../src/index";
+import { inboxURL } from "../src/jobs/routes";
import { privateKey } from "./github-key";
import { origin } from "./login-fixtures";
@@ -64,15 +65,32 @@ it("seeds a representative inbox through the Worker's own routes", async () => {
// Claimed and blocked quests, and closed issues, are not offered.
for (const text of ["Write the getting-started guide", "Report anonymous crash telemetry", "Old crash on startup"])
expect(html).not.toContain(text);
+ // A draft its starter holds is in progress; one handed back with decisions waits on the maintainer.
+ const section = (heading: string) => html.match(new RegExp(`${heading}
[\\s\\S]*?`))?.[0] ?? "";
+ expect(section("In progress")).toMatch(/Draft the theme picker<\/a>Claimed draft/);
+ expect(section("Up next")).toContain("Search quest titles");
- // Every seeded decision opens on its own.
- const items = [...html.matchAll(/href="(\/inbox\?project=[^"]+)"/g)].map((m) => m[1]!.replaceAll("&", "&"));
- expect(items.length).toBeGreaterThan(5);
- for (const href of new Set(items)) expect((await request(href, { headers: { Cookie: cookie } })).status).toBe(200);
+ // The board agrees: a held draft claims its quest, a handed-back one is turned in.
+ const board = await (
+ await request("/projects/github/example/rocket/board", { headers: { Cookie: cookie } })
+ ).text();
+ const row = (title: string) => board.match(new RegExp(`${title}[\\s\\S]*?`))?.[0] ?? "";
+ expect(row("Support custom color themes")).toContain("Claimed");
+ expect(row("Search across projects")).toContain("Ready to turn in");
+
+ // The items this test checks open on their own. Each inbox page rechecks
+ // GitHub authority, so the inbox tests cover the other threads.
+ const open = async (target: string) => {
+ const response = await request(inboxURL(9101, target), { headers: { Cookie: cookie } });
+ expect(response.status).toBe(200);
+ return response.text();
+ };
+ expect(await open("pulls/17")).toContain("Its starter holds this draft");
+ // GitHub's UNKNOWN on a recorded pull request keeps the clean state it last reported.
+ expect(await open("pulls/19")).toContain("/pulls/19/merge");
// The clean pull request merges through the fake GitHub.
- const pull = await request("/inbox?project=9101&item=pulls/12", { headers: { Cookie: cookie } });
- const head = (await pull.text()).match(
+ const head = (await open("pulls/12")).match(
/action="([^"]+\/pulls\/12\/merge)"[\s\S]*?name="head" value="([0-9a-f]{40})"/,
);
expect(head).not.toBeNull();
diff --git a/design/theme.md b/design/theme.md
index 893197f5..e65e22b9 100644
--- a/design/theme.md
+++ b/design/theme.md
@@ -57,7 +57,9 @@ A quest shows its most advanced state: Ready to turn in, then Accepted by, then
Available or Requires. Accepted by comes from a live runner lock: an active
job in kix.quest's database, which moves when its runner goes offline; open changes
carry Ready to turn in. On GitHub, a draft PR from the quest branch, opened
-right away and from a fork too, claims work.
+right away and from a fork too, claims work: the board shows it as Claimed
+by its starter until its body hands it back with a `## Decisions needed`
+section or it is marked ready, and only then as Ready to turn in.
None of these states adds a section to the quest file.
Tokens are display only: a run's spend and donated tokens show with an amber
diff --git a/quest/m0/kix/README.md b/quest/m0/kix/README.md
index 0438fedb..479d091d 100644
--- a/quest/m0/kix/README.md
+++ b/quest/m0/kix/README.md
@@ -99,7 +99,7 @@ operator-paid-hosting decisions:
- [Operator setup](/quest/m0/kix/operator-setup.md) - fork namespace, pilot installations, and a verified test PR
- [Per-project forks](/quest/m0/kix/forks.md) - each project publishes through its own fork, recorded by repository ID
- [Epic branches](/quest/m0/kix/epic-branches.md) - kix starts and links an epic on its `quest//README` branch
-- [Inbox accuracy](/quest/m0/kix/inbox-accuracy.md) - draft claims, unknown merge states, and paged, pruned activity
+- [Activity retention](/quest/m0/kix/inbox-accuracy.md) - the user picks how long closed activity stays
- [Job recovery](/quest/m0/kix/recovery.md) - retries keep their answers, and blocked branches can start over
- [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
diff --git a/quest/m0/kix/execution.md b/quest/m0/kix/execution.md
index e583a57b..fb2928f9 100644
--- a/quest/m0/kix/execution.md
+++ b/quest/m0/kix/execution.md
@@ -27,8 +27,9 @@ time:
- A start job claims through the bot exactly as `quest start` does: a draft
PR. kix treats PRs that local sessions hold as owned, shows that owner on
the card, and never acts on them: a `quest:*` label, or a draft with no
- `## Decisions needed` section. A stale one is offered to the maintainer for
- takeover.
+ `## Decisions needed` section. The inbox already lists such a draft as in
+ progress, with no actions; the label half remains. A stale one is offered
+ to the maintainer for takeover.
- A PR with no `quest:*` label that is ready, or a draft with
`## Decisions needed`, is a decision item offering the
`/quest-complete` actions, one decision per PR: mark ready, merge (the
diff --git a/quest/m0/kix/inbox-accuracy.md b/quest/m0/kix/inbox-accuracy.md
index 345635ce..72d0f55f 100644
--- a/quest/m0/kix/inbox-accuracy.md
+++ b/quest/m0/kix/inbox-accuracy.md
@@ -1,38 +1,25 @@
-# [M] Keep the inbox accurate on busy projects
+# [XS] Choose the activity retention period
## Goal
-The board and decision inbox show each quest and PR in its real state, mark
-items unread only when something a maintainer would act on changed, and stay
-fast on repositories with long histories.
+The Worker prunes a closed issue or PR's activity row after a retention period
+the user chose from measured row counts, so the activity log
+keeps the closed history maintainers use and storage stays bounded.
## Plan
-Decided 2026-10-21 from the follow-ups of
-[kixelated/quest#132](https://github.com/kixelated/quest/pull/132),
-[kixelated/quest#157](https://github.com/kixelated/quest/pull/157), and
-[kixelated/quest#163](https://github.com/kixelated/quest/pull/163):
-
-- **Draft claims.** The board classifies a draft PR without a
- `## Decisions needed` section as claimed work in progress, owned by its
- starter, and a draft with that section as waiting on the maintainer, as the
- guide defines. Today a draft without the section can show as Accepted.
-- **Unknown merge state.** GitHub reports `mergeStateStatus` `UNKNOWN` while it
- computes mergeability. On an already-recorded PR, treat it as not yet known:
- keep the item's previous state and do not mark it unread until a definite
- state arrives. A newly seen PR still surfaces as unread, since GitHub may
- send no further event once the computation finishes.
-- **Retention (user).** Page every activity read (`readState` in
- `cloudflare/src/github/state.ts` reads every row on each `/board`, `/state`,
- `/data`, and inbox view), and prune a closed entity's activity rows once it
- has been closed for a fixed period. Choose the period from measured row
- counts on a busy pilot repository and record it here. Rejected: paging
- without pruning (unbounded storage) and dropping rows on close (the board
- loses closed history).
-- Extend the seeded dev server (`cloudflare/dev/seed.ts`) with a claimed
- draft, a draft that hands back decisions, and an `UNKNOWN` PR, so
- `cloudflare/test/seed.test.ts` covers each state.
-- Keep `cloudflare/test/seed.test.ts` within the Worker suite's 5-second test
- timeout as the seed grows; it was the only test to time out under 8× CPU
- overload in [kixelated/quest#177](https://github.com/kixelated/quest/pull/177).
- Slim it rather than raising its timeout.
+- Retention (user): `RETENTION` in `cloudflare/src/github/state.ts` is 30
+ days until the user decides. Pruning, paged reads, draft claims, and the
+ `UNKNOWN` merge state shipped with it.
+- Activity rows mirror GitHub entities one to one, so the counts come from
+ GitHub. Measured 2026-10-21 on
+ [moq-dev/moq](https://github.com/moq-dev/moq), the busiest candidate pilot:
+ 6,265 issues and PRs, 98 open, and 766 closed in the last 7 days, 2,380 in
+ 30, 3,687 in 90, and 4,795 in 180. Steady-state rows are the open count plus
+ those closed within the period: about 0.9k at 7 days, 2.5k at 30, and 3.8k
+ at 90. [kixelated/quest](https://github.com/kixelated/quest) keeps every
+ row under any period (192 in total).
+- Only the paged activity log shows closed rows; the board, inbox, and jobs
+ read open rows through a partial index whatever the period.
+- Once the period is chosen, set `RETENTION` with its reason in the comment
+ beside it, and delete this quest.
diff --git a/src/cli/claims.ts b/src/cli/claims.ts
index b5ff04d2..637d365b 100644
--- a/src/cli/claims.ts
+++ b/src/cli/claims.ts
@@ -3,6 +3,7 @@
// here first, and `quest start` records a claim the same way.
import { execFileSync } from "node:child_process";
+import { handedBack } from "../core";
import {
type GitHubDependencies,
type Repository,
@@ -13,9 +14,6 @@ import {
string,
} from "./github";
-/** The heading that hands a draft back to the maintainer; only its presence matters. */
-const HAND_BACK = /^#{2}[ \t]+Decisions needed[ \t]*#*[ \t]*$/im;
-
/** A quest's branch: its path without `.md`. */
export function questBranch(path: string): string {
return path.replace(/\.md$/, "");
@@ -134,7 +132,7 @@ export async function openPullRequests(github: GitHub, repository: Repository):
draft: row.draft === true,
created: Date.parse(string(row.created_at)),
updated: Date.parse(string(row.updated_at)),
- handedBack: typeof row.body === "string" && HAND_BACK.test(row.body),
+ handedBack: typeof row.body === "string" && handedBack(row.body),
};
});
}
diff --git a/src/core/claim.ts b/src/core/claim.ts
new file mode 100644
index 00000000..5079c0bf
--- /dev/null
+++ b/src/core/claim.ts
@@ -0,0 +1,10 @@
+// How a quest's PR says whose move it is. A draft is its starter's claim until
+// its body hands it back with a `## Decisions needed` section (the guide's
+// Execution section); the CLI and the Worker read that the same way.
+
+const HAND_BACK = /^#{2}[ \t]+Decisions needed[ \t]*#*[ \t]*$/im;
+
+/** Whether a PR body hands its draft back to the maintainer; only the heading's presence matters. */
+export function handedBack(body: string): boolean {
+ return HAND_BACK.test(body);
+}
diff --git a/src/core/index.ts b/src/core/index.ts
index 005bbbe1..69f6004f 100644
--- a/src/core/index.ts
+++ b/src/core/index.ts
@@ -23,6 +23,7 @@ export {
resolve,
rooted,
} from "./doc";
+export { handedBack } from "./claim";
export { type Missed, type Moved, type Source, move, relocate, remove } from "./edit";
export { comparePaths, normalize } from "./path";
export { type Blocker, blockers, label, lookup, ready, renderBlocker } from "./ready";
From a3ca546dcf17c6d165c324fc9c2e16a7e18ff917 Mon Sep 17 00:00:00 2001
From: Luke Curley
Date: Thu, 22 Oct 2026 08:40:31 -0700
Subject: [PATCH 3/3] feat(kix): settle 30-day activity retention and finish
the quest
The user chose 30 days, so RETENTION is final and the remaining
inbox-accuracy quest is deleted with its links.
Co-Authored-By: Claude Opus 5.5
---
cloudflare/src/github/state.ts | 2 ++
quest/m0/kix/README.md | 1 -
quest/m0/kix/inbox-accuracy.md | 25 -------------------------
3 files changed, 2 insertions(+), 26 deletions(-)
delete mode 100644 quest/m0/kix/inbox-accuracy.md
diff --git a/cloudflare/src/github/state.ts b/cloudflare/src/github/state.ts
index 35181293..3c2bbbec 100644
--- a/cloudflare/src/github/state.ts
+++ b/cloudflare/src/github/state.ts
@@ -73,6 +73,8 @@ export type ProjectState = {
/**
* How long a closed issue or PR stays in the activity log. Only the log shows
* closed entities, so after this its row is pruned when history next reads it.
+ * 30 days keeps a month of closed history while bounding storage: about 2.5k
+ * rows on moq-dev/moq (measured 2026-10-21), against 6.3k unpruned and growing.
*/
export const RETENTION = 30 * 24 * 60 * 60 * 1000;
diff --git a/quest/m0/kix/README.md b/quest/m0/kix/README.md
index 479d091d..2cba0716 100644
--- a/quest/m0/kix/README.md
+++ b/quest/m0/kix/README.md
@@ -99,7 +99,6 @@ operator-paid-hosting decisions:
- [Operator setup](/quest/m0/kix/operator-setup.md) - fork namespace, pilot installations, and a verified test PR
- [Per-project forks](/quest/m0/kix/forks.md) - each project publishes through its own fork, recorded by repository ID
- [Epic branches](/quest/m0/kix/epic-branches.md) - kix starts and links an epic on its `quest//README` branch
-- [Activity retention](/quest/m0/kix/inbox-accuracy.md) - the user picks how long closed activity stays
- [Job recovery](/quest/m0/kix/recovery.md) - retries keep their answers, and blocked branches can start over
- [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
diff --git a/quest/m0/kix/inbox-accuracy.md b/quest/m0/kix/inbox-accuracy.md
deleted file mode 100644
index 72d0f55f..00000000
--- a/quest/m0/kix/inbox-accuracy.md
+++ /dev/null
@@ -1,25 +0,0 @@
-# [XS] Choose the activity retention period
-
-## Goal
-
-The Worker prunes a closed issue or PR's activity row after a retention period
-the user chose from measured row counts, so the activity log
-keeps the closed history maintainers use and storage stays bounded.
-
-## Plan
-
-- Retention (user): `RETENTION` in `cloudflare/src/github/state.ts` is 30
- days until the user decides. Pruning, paged reads, draft claims, and the
- `UNKNOWN` merge state shipped with it.
-- Activity rows mirror GitHub entities one to one, so the counts come from
- GitHub. Measured 2026-10-21 on
- [moq-dev/moq](https://github.com/moq-dev/moq), the busiest candidate pilot:
- 6,265 issues and PRs, 98 open, and 766 closed in the last 7 days, 2,380 in
- 30, 3,687 in 90, and 4,795 in 180. Steady-state rows are the open count plus
- those closed within the period: about 0.9k at 7 days, 2.5k at 30, and 3.8k
- at 90. [kixelated/quest](https://github.com/kixelated/quest) keeps every
- row under any period (192 in total).
-- Only the paged activity log shows closed rows; the board, inbox, and jobs
- read open rows through a partial index whatever the period.
-- Once the period is chosen, set `RETENTION` with its reason in the comment
- beside it, and delete this quest.