Public: anyone can read this; the crew can change it.

The week's pacing shows the polish share, a stored setting that starts at 10% #8879

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

torque/forge:torque/8853-the-week-s-pacing-shows-the-polish-share → main 3 commits fork 2 threads · 2 unresolved

  • Merged. torque/8853-the-week-s-pacing-shows-the-polish-share was merged into main as 35e76d0.

Review

Sign in to review this change.
16 files changed +443 −21
docs/features/bugs.md +2 −1
@@ -9,4 +9,5 @@ An open issue labelled `bug` is answered, fixed or closed as won't fix within a
99 - **Tile.** Home's **Open bugs** tile (admins only) shows the open count and how many are past their clock; it links to `/boards`.
1010 - **Terminal.** The **Bugs** palette entry shows the same count (admins who are people) and the alarm (cut to public for everyone else).
1111 - **Closing.** Closing a bug needs a reason: see `bug-close.md`.
12 - **Not yet.** The polish share, shortcuts and hunters' findings of #8709 are not built.
12 - **Polish share.** The setting, the week lines and the dispatch hold are in [polish-share.md](polish-share.md).
outdated

Move this to its own docs/features/polish-share.md and link it here; the polish share is not a bug feature.

13 - **Not yet.** Shortcuts and hunters' findings of #8709 are not built.
docs/features/polish-share.md added +9
@@ -0,0 +1,9 @@
1 # The polish share
2
3 The percent of the last seven days' agent spend that polish may take: issues labelled `polish`, or in the small-fixes lane (`isSmall`, `src/crew/small_share.ts`). 10 to start; a whole number 0 to 50. At 0 no polish starts.
4
5 - **Set it.** Web: Settings → Branch and gates → Polish share. Terminal: Autonomy, key `o`. Both go through `updateRepoSettings`, refuse anything but 0–50, and are on the settings trail, so an open week page or screen updates.
6 - **See it.** `/:owner/:name/week` ("Polish this week"), the terminal's week screen (last line) and the `week` MCP tool (`polish: { share, percent, polishUsd, totalUsd, under, says }`) print the same `polishWords`.
7 - **Dispatch.** Polish is under its share when polish spend is below `share`% of all agent spend in the window (nothing spent yet counts as under). Over it, ready polish issues are skipped until the week catches up. Issues the owner raised, ranked or put first are never held back. The window is rolling, the seven days before now; spend is `run.cost_usd` of agent runs.
8 - **Next up** applies the same hold, since it shows dispatch's order: while polish is over, polish issues are not listed there.
9 - **Tests.** `test/polish-share.test.ts` (rules, setting, dispatch on a fake clock), `test/tui-autonomy.test.ts` (terminal).
src/actions/repos.ts +5 −1
@@ -66,6 +66,7 @@ import {
6666 requireWriter,
6767 type AccessError,
6868 } from "./access.ts";
69 import { validPolishShare } from "../crew/small_share.ts";
6970 import { removeInstallCache } from "../crew/workdir.ts";
7071 import type { SettingsChanged } from "../core/settings_changed.ts";
7172
@@ -283,7 +284,7 @@ export async function privateRepoForSealed(
283284 return sealedForkFor(app, actor, base);
284285 }
285286
286 export type UpdateSettingsError = "not_found" | "unknown_branch";
287 export type UpdateSettingsError = "not_found" | "unknown_branch" | "polish_share";
287288
288289 /** Each repository's archive transitions, one after another. */
289290 const archiveTurns = new WeakMap<App, Map<string, Promise<void>>>();
@@ -349,6 +350,7 @@ const AUDITED = [
349350 "autonomySlots",
350351 "plannerSlots",
351352 "reviewerSlots",
353 "polishShare",
352354 ] as const;
353355
354356 export type { SettingsChanged };
@@ -390,6 +392,8 @@ export async function updateRepoSettings(
390392 // base, whoever owns it (`core/sealed.ts`).
391393 if (repo.sealedFor !== null && settings.visibility === "public")
392394 return err("not_found");
395 if (settings.polishShare !== undefined && !validPolishShare(settings.polishShare))
396 return err("polish_share");
393397 const branch = settings.defaultBranch;
394398 if (branch !== undefined && branch !== repo.defaultBranch) {
395399 const dir = repoDir(app.reposRoot, repo.ownerName, repo.name);
src/core/migration-pins/20261103130000-polish-share.pin added +1
@@ -0,0 +1,1 @@
1 57721a7213c46c4a563d17b3df66042a1a2c7370c232ab5aeeda4f41125b63e7
src/core/migrations/20261103130000-polish-share.ts added +6
@@ -0,0 +1,6 @@
1 // The polish share (#8853): the percent of a week's agent spend that polish
2 // (issues labelled `polish`, or in the small-fixes lane) may take before
3 // dispatch stops picking it. 0 to 50, checked at the boundary; 10 to start. Additive.
4 export default `
5 ALTER TABLE repo ADD COLUMN polish_share INTEGER NOT NULL DEFAULT 10;
6 `;
src/core/repos.ts +7 −1
@@ -147,6 +147,8 @@ export interface Repo {
147147 readonly plannerSlots: number;
148148 /** Reviews at once: 0 pauses the reviewer. */
149149 readonly reviewerSlots: number;
150 /** Percent of a week's agent spend polish may take, 0 to 50 (#8853). */
151 readonly polishShare: number;
150152 readonly autonomyBy: string | null;
151153 /**
152154 * "Outsiders may trigger the crew": whether someone who cannot write here
@@ -204,6 +206,7 @@ interface RepoRow {
204206 autonomy_slots: number;
205207 planner_slots: number;
206208 reviewer_slots: number;
209 polish_share: number;
207210 autonomy_by: string | null;
208211 crew_outsiders: number;
209212 decisions_by: string;
@@ -246,6 +249,7 @@ function toRepo(row: RepoRow): Repo {
246249 autonomySlots: row.autonomy_slots,
247250 plannerSlots: row.planner_slots,
248251 reviewerSlots: row.reviewer_slots,
252 polishShare: row.polish_share,
249253 autonomyBy: row.autonomy_by,
250254 crewOutsiders: row.crew_outsiders === 1,
251255 decisionsBy: isDecisionsBy(row.decisions_by) ? row.decisions_by : "me",
@@ -1208,6 +1212,7 @@ export interface RepoSettings {
12081212 autonomySlots?: number;
12091213 plannerSlots?: number;
12101214 reviewerSlots?: number;
1215 polishShare?: number;
12111216 /** Who the forge acts as when it assigns and lands on its own. */
12121217 autonomyBy?: string | null;
12131218 /** Whether someone who cannot write here may start or steer a run. */
@@ -1239,7 +1244,7 @@ export function updateRepoSettings(
12391244 default_branch = ?4, require_review = ?5, require_checks = ?7,
12401245 autonomy = ?8, autonomy_direction = ?9, autonomy_ceiling_usd = ?10,
12411246 autonomy_slots = ?11, autonomy_by = ?12, crew_outsiders = ?13, decisions_by = ?14,
1242 plans_need_nod = ?15, planner_slots = ?16, reviewer_slots = ?17
1247 plans_need_nod = ?15, planner_slots = ?16, reviewer_slots = ?17, polish_share = ?18
12431248 WHERE id = ?6`,
12441249 ).run(
12451250 settings.visibility ?? repo.visibility,
@@ -1259,6 +1264,7 @@ export function updateRepoSettings(
12591264 (settings.plansNeedNod ?? repo.plansNeedNod) ? 1 : 0,
12601265 settings.plannerSlots ?? repo.plannerSlots,
12611266 settings.reviewerSlots ?? repo.reviewerSlots,
1267 settings.polishShare ?? repo.polishShare,
12621268 );
12631269 forgetListings(db);
12641270 return getRepoById(db, repo.id)!;
src/crew/autonomy.ts +15 −5
@@ -31,7 +31,7 @@ import { addLearned } from "../checks/learned.ts";
3131 import { batchWords, dropFromLine, enqueue, type GateCandidate, type Queued, type QueueOptions, queueClashes, queueWords, waitingPulls } from "../checks/queue.ts";
3232 import { checksFor } from "../checks/service.ts";
3333 import { queueBatch, queueDepth, smallShare } from "../core/capacity.ts";
34 import { shareWords, slotOrder, smallCap, smallIssues, smallOfIds } from "./small_share.ts";
34 import { isPolish, polishSpend, shareWords, slotOrder, smallCap, smallIssues, smallOfIds } from "./small_share.ts";
3535 import { backedBetOf, betsInUse } from "../core/bets.ts";
3636 import { mayPick } from "./bets.ts";
3737 import { raisedAt } from "../core/thought_links.ts";
@@ -72,6 +72,7 @@ import {
7272 listComments,
7373 MAX_TITLE,
7474 type Issue,
75 labelsForIssues,
7576 } from "../core/issues.ts";
7677 import {
7778 getPull,
@@ -406,7 +407,7 @@ function freeSlots(app: App, repo: Repo, now: Date): number {
406407 }
407408
408409 /** The pickable issues in slot order: the order Next up shows, small work held to its share (#7571). */
409 export function inSlotOrder(app: App, repo: Repo, ready: readonly Ranked[], slotted: readonly Run[], slots: number, picked: ReadonlySet<string> = new Set()): Ranked[] {
410 export function inSlotOrder(app: App, repo: Repo, ready: readonly Ranked[], slotted: readonly Run[], slots: number, picked: ReadonlySet<string> = new Set(), now: Date = new Date()): Ranked[] {
410411 const issues = ready.map((r) => r.issue);
411412 const small = smallIssues(app.db, issues);
412413 const first = firstAt(app.db, issues.map((i) => i.id));
@@ -416,10 +417,19 @@ export function inSlotOrder(app: App, repo: Repo, ready: readonly Ranked[], slot
416417 const running = smallOfIds(app.db, [...new Set([...slotted.flatMap((r) => (r.issueId === null ? [] : [r.issueId])), ...picked])]);
417418 // Once the owner has backed a bet, builders pick from backed bets, the owner's raised issues and the small lane (#8713).
418419 const bets = betsInUse(app.db, repo.id) ? backedBetOf(app.db, repo.id, issues.map((i) => i.id)) : undefined;
420 // Polish (labelled `polish`, or in the small-fixes lane) stops once it has taken its share of the week (#8853);
421 // what the owner raised, ranked or put first is never held back.
422 const polishLabels = labelsForIssues(app.db, issues.map((i) => i.id));
423 const polishOpen = polishSpend(app.db, repo.id, repo.polishShare, now).under;
424 const heldBack = (r: Ranked): boolean =>
425 !polishOpen &&
426 !raised.has(r.issue.id) && !first.has(r.issue.id) && !ranked.has(r.issue.id) &&
427 isPolish(r.issue.title, (polishLabels.get(r.issue.id) ?? []).map((l) => l.name));
428 const pickable = ready.filter((r) => !heldBack(r));
419429 const allowed =
420430 bets === undefined
421 ? ready
422 : ready.filter((r) =>
431 ? pickable
432 : pickable.filter((r) =>
423433 mayPick({
424434 raised: raised.has(r.issue.id) || first.has(r.issue.id),
425435 small: small.has(r.issue.id),
@@ -553,7 +563,7 @@ async function driveOnce(
553563 )
554564 );
555565 // The roadmap first, and small issues held to their share of the slots (#7571).
556 const next = await nextOf(app, repo, inSlotOrder(app, repo, pickable, slotted, paced.slots, taken));
566 const next = await nextOf(app, repo, inSlotOrder(app, repo, pickable, slotted, paced.slots, taken, now));
557567 if (next !== undefined) {
558568 taken.add(next.issue.id);
559569 const routed = await routeIssue(app, repo, next.issue, crewFor(next.issue), inFlight);
src/crew/small_share.ts +68
@@ -92,3 +92,71 @@ export function smallOfIds(db: Database, ids: readonly string[]): number {
9292 .all(...unique);
9393 return smallIssues(db, rows).size;
9494 }
95
96 /** The week polish is counted over: the seven days before `now`, on the clock the caller passes. */
97 export const POLISH_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;
98
99 /** Polish share bounds, in percent of the week's agent spend (#8853). */
100 export const POLISH_SHARE_MAX = 50;
101 export const POLISH_SHARE_DEFAULT = 10;
102
103 /** Whether a stored polish share is a whole number of percent, 0 to 50. */
104 export function validPolishShare(n: unknown): n is number {
105 return typeof n === "number" && Number.isInteger(n) && n >= 0 && n <= POLISH_SHARE_MAX;
106 }
107
108 /** Polish is an issue labelled `polish` or one in the small-fixes lane (`isSmall`). */
109 export function isPolish(title: string, labels: readonly string[]): boolean {
110 return labels.some((l) => l.toLowerCase() === "polish") || isSmall(title, labels);
111 }
112
113 export interface PolishSpend {
114 /** Agent spend this week on polish issues, USD. */
115 readonly polishUsd: number;
116 /** Agent spend this week on everything, USD. */
117 readonly totalUsd: number;
118 /** The setting, percent. */
119 readonly share: number;
120 /** polishUsd as a percent of totalUsd; 0 while nothing was spent. */
121 readonly percent: number;
122 /** Whether a ready polish issue may be picked now. */
123 readonly under: boolean;
124 }
125
126 /**
127 * Polish spent against its share: polish USD / total USD this week, in percent (agent runs started in
128 * the window). Under means polish may start: the share is above 0 and polish has spent
129 * less than `share` percent of the total — with nothing spent yet, any share above 0 is under.
130 */
131 export function polishPosition(polishUsd: number, totalUsd: number, share: number): PolishSpend {
132 const percent = totalUsd > 0 ? (polishUsd / totalUsd) * 100 : 0;
133 const under = share > 0 && (totalUsd <= 0 || polishUsd * 100 < share * totalUsd);
134 return { polishUsd, totalUsd, share, percent, under };
135 }
136
137 /** This week's polish spend for a repository, read from its agent runs in the seven days before `now`. */
138 export function polishSpend(db: Database, repoId: string, share: number, now: Date): PolishSpend {
139 const from = new Date(now.getTime() - POLISH_WINDOW_MS).toISOString();
140 const rows = db
141 .query<{ issue_id: string; title: string; usd: number }, [string, string, string]>(
142 `SELECT r.issue_id, COALESCE(i.title, '') AS title, SUM(r.cost_usd) AS usd FROM run r
143 LEFT JOIN issue i ON i.id = r.issue_id
144 WHERE r.repo_id = ?1 AND r.kind = 'agent' AND r.started_at >= ?2 AND r.started_at <= ?3
145 GROUP BY r.issue_id`,
146 )
147 .all(repoId, from, now.toISOString());
148 const labels = labelsForIssues(db, rows.flatMap((r) => (r.issue_id === null ? [] : [r.issue_id])));
149 let polish = 0;
150 let total = 0;
151 for (const r of rows) {
152 total += r.usd;
153 if (r.issue_id !== null && isPolish(r.title, (labels.get(r.issue_id) ?? []).map((l) => l.name))) polish += r.usd;
154 }
155 return polishPosition(polish, total, share);
156 }
157
158 /** `polish 4% of 10% this week ($1.20 of $30.00)`, for the week page and the MCP tool. */
159 export function polishWords(p: PolishSpend): string {
160 const usd = (n: number) => `$${n.toFixed(2)}`;
161 return `polish ${p.percent.toFixed(1)}% of ${p.share}% this week (${usd(p.polishUsd)} of ${usd(p.totalUsd)}): ${p.under ? "ready polish may start" : "no polish starts until it is under"}`;
162 }
src/http/mcp/tools/week.ts +6 −3
@@ -1,10 +1,11 @@
11 import { takeProposal } from "../../../actions/week.ts";
2 import { polishSpend, polishWords } from "../../../crew/small_share.ts";
23 import { latestRetro, takenOf } from "../../../flow/retro.ts";
34 import { bad, good, type Tool, APP_ARG, planRepo, isAnswer } from "../shared.ts";
45
56 export const tool: Tool = {
67 name: "week",
7 doc: "an app's last weekly retrospective, the same page as /:owner/:name/week (`app`): `{ weekEnd, shipped, projected, accuracy, costly, stuck, proposals: [{ number, words, taken }] }`; with `take` (a proposal's index, 0 to 2) a writer who is a person (never an agent) takes that one proposal, which puts its issue behind the Later gate; nothing else ever changes",
8 doc: "an app's last weekly retrospective, the same page as /:owner/:name/week (`app`): `{ polish: { share, percent, polishUsd, totalUsd, under }, weekEnd, shipped, projected, accuracy, costly, stuck, proposals: [{ number, words, taken }] }`; with `take` (a proposal's index, 0 to 2) a writer who is a person (never an agent) takes that one proposal, which puts its issue behind the Later gate; nothing else ever changes",
89 description: "The week's retrospective; `take` accepts one proposal.",
910 inputSchema: {
1011 type: "object",
@@ -17,7 +18,9 @@ export const tool: Tool = {
1718 const repo = planRepo(ctx, user, args);
1819 if (isAnswer(repo)) return repo;
1920 const retro = latestRetro(ctx.app.db, repo.id);
20 if (retro === undefined) return good({ says: "No weekly window has closed yet." });
21 const spend = polishSpend(ctx.app.db, repo.id, repo.polishShare, new Date());
22 const polish = { share: spend.share, percent: Math.round(spend.percent * 10) / 10, polishUsd: spend.polishUsd, totalUsd: spend.totalUsd, under: spend.under, says: polishWords(spend) };
23 if (retro === undefined) return good({ polish, says: "No weekly window has closed yet." });
2124 if (args["take"] !== undefined) {
2225 const idx = args["take"];
2326 if (typeof idx !== "number" || !Number.isInteger(idx) || idx < 0 || idx > 2) return bad("invalid_take", "take is a proposal's index, 0 to 2");
@@ -25,6 +28,6 @@ export const tool: Tool = {
2528 if (!took.ok) return bad(took.error.kind === "not_found" ? "no_such_proposal" : took.error.kind, took.error.message);
2629 }
2730 const taken = takenOf(ctx.app.db, repo.id, retro.weekEnd);
28 return good({ ...retro, proposals: retro.proposals.map((p, i) => ({ number: p.number, words: p.words, taken: taken.has(i) })) });
31 return good({ ...retro, polish, proposals: retro.proposals.map((p, i) => ({ number: p.number, words: p.words, taken: taken.has(i) })) });
2932 },
3033 };
src/tui/screens/autonomy.ts +16 −1
@@ -10,10 +10,12 @@
1010 * `updateRepoSettings` with the fields the page writes and toasts the page's
1111 * flash (`AUTONOMY_SAID`), and a value out of bounds is refused with the
1212 * page's sentence (`checkAutonomy`).
13 * `o` sets the polish share (0–50, #8853) through the same `updateRepoSettings`, so a web viewer sees it live.
1314 * Anyone else gets no palette entry and, if the screen is reached, no values.
1415 */
1516
1617 import { updateRepoSettings } from "../../actions/repos.ts";
18 import { POLISH_SHARE_MAX, validPolishShare } from "../../crew/small_share.ts";
1719 import { seatSlotsSaid, setSeatSlots } from "../../actions/seat_slots.ts";
1820 import { checkSeatSlots, seatSlotsOf, seatSlotsWords } from "../../crew/seat_slots.ts";
1921 import type { CrewRole } from "../../core/agents.ts";
@@ -102,7 +104,7 @@ export function autonomyScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
102104 title: "Autonomy",
103105 get keys() {
104106 return mayChange()
105 ? [binding(["m"], "mode"), binding(["c"], "ceiling"), binding(["p"], "planner"), binding(["s"], "builder"), binding(["r"], "reviewer")]
107 ? [binding(["m"], "mode"), binding(["c"], "ceiling"), binding(["p"], "planner"), binding(["s"], "builder"), binding(["r"], "reviewer"), binding(["o"], "polish share")]
106108 : [];
107109 },
108110 render(area, s) {
@@ -116,6 +118,7 @@ export function autonomyScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
116118 `Mode ${r.autonomy}`,
117119 ` ${AUTONOMY_SAID[r.autonomy]}`,
118120 `Ceiling ${dollars(r.autonomyCeilingUsd)} a day`,
121 `Polish ${r.polishShare}% of the week's spend`,
119122 ...seatSlotsOf(f.db, r).map(
120123 (p) => `${(p.role[0]!.toUpperCase() + p.role.slice(1)).padEnd(9)}${p.slots} at once · ${seatSlotsWords(p)}`,
121124 ),
@@ -161,6 +164,18 @@ export function autonomyScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
161164 });
162165 return true;
163166 }
167 if (e.key === "o") {
168 ask("Polish share", `percent of the week's spend, 0 to ${POLISH_SHARE_MAX}`, `0 to ${POLISH_SHARE_MAX}`, String(current().polishShare), (raw) => {
169 const n = /^\d{1,3}$/.test(raw) ? Number(raw) : Number.NaN;
170 if (!validPolishShare(n)) return void ctx.toast(`Polish share is a whole number of percent, 0 to ${POLISH_SHARE_MAX}.`, "danger");
171 void updateRepoSettings(f.app, f.user, current(), { polishShare: n }).then((done) => {
172 ctx.toast(done.ok ? `Polish share is ${n}% of the week's spend.` : "Only an admin of this repository sets its polish share.", done.ok ? "success" : "danger");
173 ctx.invalidate();
174 });
175 return undefined;
176 });
177 return true;
178 }
164179 const seat: Partial<Record<string, CrewRole>> = { p: "planner", s: "builder", r: "reviewer" };
165180 const role = seat[e.key];
166181 if (role !== undefined) {
src/tui/screens/week.ts +15 −5
@@ -10,7 +10,8 @@
1010 */
1111
1212 import { takeProposal } from "../../actions/week.ts";
13 import { canRead, canWrite, type Repo } from "../../core/repos.ts";
13 import { canRead, canWrite, getRepoById, type Repo } from "../../core/repos.ts";
14 import { polishSpend, polishWords } from "../../crew/small_share.ts";
1415 import { latestRetro, takenOf, type Retro } from "../../flow/retro.ts";
1516 import type { Ctx, Screen } from "../app/app.ts";
1617 import { binding } from "../app/keys.ts";
@@ -20,7 +21,7 @@ import { truncate } from "../term/width.ts";
2021 import type { Session } from "../session.ts";
2122 import { ListState, drawList, hitList, live, type ListRow } from "./common.ts";
2223
23 const LIVE_KINDS = ["week.retro", "issue.ranked"];
24 const LIVE_KINDS = ["week.retro", "issue.ranked", "repo.settings_updated"];
2425
2526 /** The retrospective's screen of facts, one line each. */
2627 export function summaryLines(retro: Retro): string[] {
@@ -38,6 +39,12 @@ export function summaryLines(retro: Retro): string[] {
3839 ];
3940 }
4041
42 /** The page's and the tool's polish line: polish spent this week against its share. */
43 export function polishLine(f: Session, repo: Repo): string {
44 const now = getRepoById(f.db, repo.id) ?? repo;
45 return polishWords(polishSpend(f.db, now.id, now.polishShare, new Date()));
46 }
47
4148 export function weekScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
4249 const state = new ListState();
4350 let retro: Retro | undefined;
@@ -91,9 +98,12 @@ export function weekScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
9198 const c = ctx.theme.c;
9299 const lines = !loaded
93100 ? ["Loading…"]
94 : retro === undefined
95 ? ["The forge writes this when the weekly window closes. None has closed yet."]
96 : summaryLines(retro);
101 : [
102 ...(retro === undefined
103 ? ["The forge writes this when the weekly window closes. None has closed yet."]
104 : summaryLines(retro)),
105 polishLine(f, repo),
106 ];
97107 const [head, list] = split(area, "v", [len(lines.length + 1), fill(1)]);
98108 lines.forEach((line, i) =>
99109 s.text(head!.x, head!.y + i, truncate(line, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w),
src/web/pages/settings.ts +29
@@ -8,6 +8,7 @@
88 * same-origin POST and every one of them says what happened.
99 */
1010
11 import { POLISH_SHARE_DEFAULT, POLISH_SHARE_MAX } from "../../crew/small_share.ts";
1112 import { appLimitWords } from "../../actions/app_limit.ts";
1213 import { showProvenance } from "../../actions/provenance.ts";
1314 import { getProvenance, parentUrl, sourceLabel } from "../../core/provenance.ts";
@@ -331,6 +332,27 @@ export async function settingsPage(ctx: Ctx): Promise<Response> {
331332 >
332333 </label>
333334 </div>
335 <div class="field">
336 <input type="hidden" name="polishShareShown" value="1" />
337 <label for="polish-share">Polish share</label>
338 <input
339 class="input mono"
340 id="polish-share"
341 type="number"
342 name="polishShare"
343 min="0"
344 max="${String(POLISH_SHARE_MAX)}"
345 step="1"
346 value="${String(repo.polishShare)}"
347 inputmode="numeric"
348 />
349 <p class="hint">
350 Percent of the week's agent spend that polish may take, 0 to ${String(POLISH_SHARE_MAX)}
351 (starts at ${String(POLISH_SHARE_DEFAULT)}) — so small fixes get steady attention without crowding out the plan.
352 Over it, the crew stops picking issues labelled polish or in the small-fixes lane until the week catches up; the
353 <a href="${base}/week">week</a> shows where it stands.
354 </p>
355 </div>
334356 ${cellMemory === undefined
335357 ? null
336358 : html`<div class="field"><p class="hint" data-check-memory>${
@@ -412,6 +434,9 @@ export async function settingsPage(ctx: Ctx): Promise<Response> {
412434 return response;
413435 }
414436
437 /** A form's polish share: the number if it is all digits, else NaN, which the action refuses. */
438 const parseShare = (raw: string): number => (/^\d{1,3}$/.test(raw.trim()) ? Number(raw) : Number.NaN);
439
415440 export async function settingsUpdate(ctx: Ctx): Promise<Response> {
416441 const gate = await adminForm(ctx);
417442 if (gate instanceof Response) return gate;
@@ -443,6 +468,8 @@ export async function settingsUpdate(ctx: Ctx): Promise<Response> {
443468 ? { crewOutsiders: form.get("crewOutsiders") !== null }
444469 : {}),
445470 ...(branch !== "" ? { defaultBranch: branch } : {}),
471 // Sent only by a form that draws the field; anything but a whole 0–50 is refused by the action.
472 ...(form.get("polishShareShown") !== null ? { polishShare: parseShare(field(form, "polishShare")) } : {}),
446473 };
447474 const updated = await updateRepoSettings(
448475 ctx.app,
@@ -457,6 +484,8 @@ export async function settingsUpdate(ctx: Ctx): Promise<Response> {
457484 message:
458485 updated.error === "unknown_branch"
459486 ? "That branch does not exist in this repository."
487 : updated.error === "polish_share"
488 ? `Polish share is a whole number of percent, 0 to ${POLISH_SHARE_MAX}.`
460489 : "Those settings could not be saved.",
461490 };
462491 return flashTo(ctx, `${base}/settings`, flash);
src/web/pages/week.ts +18 −2
@@ -5,7 +5,8 @@
55 * and nothing else on the page changes anything.
66 */
77 import { takeProposal } from "../../actions/week.ts";
8 import { canWrite } from "../../core/repos.ts";
8 import { polishSpend, polishWords } from "../../crew/small_share.ts";
9 import { canWrite, type Repo } from "../../core/repos.ts";
910 import { notePhase, takeSessionTime } from "../../core/route_timing.ts";
1011 import { latestRetro, takenOf, type Line, type Retro } from "../../flow/retro.ts";
1112 import type { Ctx } from "../../http/context.ts";
@@ -13,7 +14,7 @@ import { crumbsFor, loadRepo, notFoundPage, repoBase, repoHeader, repoPalette, s
1314 import { backTo, field, readForm } from "../form.ts";
1415 import { html, type Html } from "../html.ts";
1516 import { flashOrJson, page } from "../layout.ts";
16 import { btn, card, row } from "../ui.ts";
17 import { btn, card, meter, row, stat } from "../ui.ts";
1718
1819 const lines = (base: string, ls: readonly Line[], none: string): Html =>
1920 ls.length === 0
@@ -62,6 +63,19 @@ function screen(base: string, retro: Retro, taken: ReadonlySet<number>, writer:
6263 )}</ul>`}`;
6364 }
6465
66 /** Polish spent against its share: a labelled fact, with the words the terminal and the tool print under it. */
67 function polishCard(ctx: Ctx, repo: Repo): Html {
68 const p = polishSpend(ctx.app.db, repo.id, repo.polishShare, new Date());
69 return card(
70 html`<div data-polish>${stat({
71 label: "Polish share",
72 value: `${p.percent.toFixed(1)}% of ${p.share}%`,
73 title: "Agent spend in the last seven days on issues labelled polish or in the small-fixes lane, against all agent spend",
74 note: html`${polishWords(p)}`,
75 })}${meter(p.share === 0 ? 100 : (p.percent / p.share) * 100, `polish has used ${p.percent.toFixed(1)}% of the week's spend; its share is ${p.share}%`, p.under ? undefined : "attn")}</div>`,
76 );
77 }
78
6579 export function weekPage(ctx: Ctx): Response {
6680 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
6781 const repo = loadRepo(ctx);
outdated

Hand-rolled heading and paragraph markup. Use the named DESIGN.md component for a labelled fact, and keep the same line on the TUI week screen.

@@ -77,6 +91,8 @@ export function weekPage(ctx: Ctx): Response {
7791 ? "The forge writes this when the weekly window closes. None has closed yet."
7892 : `Written when the window ended ${retro.weekEnd.slice(0, 10)}, once, and never edited.`} <a href="${base}/next">Next up</a></p>
7993 </div>
94 <h2>Polish this week</h2>
95 ${polishCard(ctx, repo)}
8096 ${retro === undefined ? null : screen(base, retro, takenOf(ctx.app.db, repo.id, retro.weekEnd), writer)}`;
8197 const response = page({ ctx, title: `The week · ${repo.name}`, crumbs: crumbsFor(repo), palette: repoPalette(ctx, repo), body });
8298 notePhase(ctx.request, "render", performance.now() - rendering);
test/polish-share.test.ts added +206
@@ -0,0 +1,206 @@
1 /**
2 * The polish share (#8853): a stored per-repository setting, 10% to start and
3 * 0–50 allowed; polish (issues labelled `polish`, or in the small-fixes lane)
4 * is picked while it has spent less than its share of the week's agent spend
5 * and not once it is over; the week page and the `week` tool show where it stands.
6 */
7 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
8 import { mkdtemp, rm } from "node:fs/promises";
9 import { tmpdir } from "node:os";
10 import { join } from "node:path";
11 import { createApp, type App } from "../src/app.ts";
12 import { addToCrew, createAgent } from "../src/actions/crew.ts";
13 import { openIssue, setIssueLabels } from "../src/actions/issues.ts";
14 import { commitToBranch, createLabel, createRepo, updateRepoSettings } from "../src/actions/repos.ts";
15 import { getIssueById, type Issue } from "../src/core/issues.ts";
16 import { getRepoById, type Repo } from "../src/core/repos.ts";
17 import { createRun } from "../src/core/runs.ts";
18 import { createUser, type User } from "../src/core/users.ts";
19 import { driveRepo } from "../src/crew/autonomy.ts";
20 import { isPolish, polishPosition, polishSpend, polishWords, validPolishShare } from "../src/crew/small_share.ts";
21 import { serve, type Servers } from "../src/http/server.ts";
22
23 describe("the rules", () => {
24 test("polish is the label or the small-fixes lane; a feature is not", () => {
25 expect(isPolish("Nudge the margin", ["Polish"])).toBe(true);
26 expect(isPolish("A test pins the title", [])).toBe(true);
27 expect(isPolish("Search notes", ["feature"])).toBe(false);
28 });
29
30 test("by hand: $1 of $20 is 5%, under a 10% share; $2 of $20 is exactly 10%, no longer under; 0% is never under", () => {
31 expect(polishPosition(1, 20, 10)).toMatchObject({ percent: 5, under: true });
32 expect(polishPosition(2, 20, 10)).toMatchObject({ percent: 10, under: false });
33 expect(polishPosition(3, 20, 10).under).toBe(false);
34 expect(polishPosition(0, 0, 10).under).toBe(true);
35 expect(polishPosition(0, 0, 0).under).toBe(false);
36 expect(polishWords(polishPosition(1, 20, 10))).toContain("polish 5.0% of 10% this week ($1.00 of $20.00)");
37 });
38
39 test("the share is a whole number 0 to 50", () => {
40 for (const ok of [0, 10, 50]) expect(validPolishShare(ok)).toBe(true);
41 for (const bad of [-1, 51, 1.5, Number.NaN, "10", null]) expect(validPolishShare(bad)).toBe(false);
42 });
43 });
44
45 let dataDir: string;
46 let app: App;
47 let alice: User;
48 let repo: Repo;
49 let servers: Servers;
50 let cookie: string;
51 const fresh = () => getRepoById(app.db, repo.id)!;
52
53 function open(title: string): Issue {
54 const made = openIssue(app, alice, fresh(), { title });
55 if (!made.ok) throw new Error(String(made.error));
56 return made.value;
57 }
58
59 /** An issue already done, so the spend on it leaves nothing stalled to pick. */
60 function done(title: string, label?: string): Issue {
61 const issue = open(title);
62 if (label !== undefined) expect(setIssueLabels(app, alice, fresh(), issue, [label]).ok).toBe(true);
63 app.db.query("UPDATE issue SET state = 'closed', close_reason = 'done' WHERE id = ?1").run(issue.id);
64 return issue;
65 }
66
67 /** An agent run on `issue` that cost `usd` and started at `at`. */
68 function spend(issue: Issue, usd: number, at: Date): void {
69 const run = createRun(app.db, { repoId: repo.id, kind: "agent", issueId: issue.id, actorId: alice.id, reason: "assigned" });
70 app.db.query("UPDATE run SET state = 'ok', cost_usd = ?2, started_at = ?3 WHERE id = ?1").run(run.id, usd, at.toISOString());
71 }
72
73 const NOW = new Date("2030-03-13T12:00:00.000Z");
74 const daysAgo = (n: number) => new Date(NOW.getTime() - n * 86_400_000);
75
76 /** One tick at `now`: the issues it assigned, if any. */
77 async function drive(issues: readonly Issue[], now: Date): Promise<Issue[]> {
78 await driveRepo(app, fresh(), now);
79 return issues.map((i) => getIssueById(app.db, i.id)!).filter((i) => i.assigneeId !== null);
80 }
81
82 const clear = () => app.db.query("DELETE FROM run WHERE repo_id = ?1").run(repo.id);
83
84 beforeAll(async () => {
85 dataDir = await mkdtemp(join(tmpdir(), "forge-polish-share-"));
86 app = createApp({ dataDir, port: 0 });
87 const a = await createUser(app.db, {
88 origin: "seed", trusted: true, username: "alice", email: "[email protected]",
89 password: "a-good-passphrase", adminIfFirst: true,
90 });
91 if (!a.ok) throw new Error(a.error);
92 alice = a.value;
93 const made = await createRepo(app, alice, { name: "notes", visibility: "private" });
94 if (!made.ok) throw new Error(String(made.error));
95 repo = made.value;
96 const first = await commitToBranch(app, alice, repo, {
97 message: "Start",
98 files: [{ path: "package.json", content: `{"name":"notes"}\n` }],
99 });
100 if (!first.ok) throw new Error(JSON.stringify(first.error));
101 for (const name of ["b1", "b2"]) {
102 const agent = await createAgent(app, alice, { username: name });
103 if (!agent.ok) throw new Error(String(agent.error));
104 expect(addToCrew(app, alice, repo, name).ok).toBe(true);
105 }
106 const on = await updateRepoSettings(app, alice, repo, {
107 autonomy: "autonomous", autonomyDirection: "Keep the notes tidy.", autonomyCeilingUsd: 100, autonomySlots: 2, autonomyBy: alice.id,
108 });
109 expect(on.ok).toBe(true);
110 servers = await serve(app);
111 const signin = await fetch(`${servers.url}/signin`, {
112 method: "POST", redirect: "manual",
113 headers: { "content-type": "application/x-www-form-urlencoded", origin: servers.url },
114 body: new URLSearchParams({ username: "alice", password: "a-good-passphrase" }),
115 });
116 cookie = (signin.headers.get("set-cookie") ?? "").split(";")[0] ?? "";
117 });
118
119 afterAll(async () => {
120 servers.stop();
121 app.close();
122 await rm(dataDir, { recursive: true, force: true });
123 });
124
125 test("a repository starts at 10%; the share saves from 0 to 50 and nothing else", async () => {
126 expect(fresh().polishShare).toBe(10);
127 for (const bad of [51, -1, 2.5, Number.NaN]) {
128 const refused = await updateRepoSettings(app, alice, fresh(), { polishShare: bad });
129 expect(refused).toEqual({ ok: false, error: "polish_share" });
130 }
131 expect(fresh().polishShare).toBe(10);
132 expect((await updateRepoSettings(app, alice, fresh(), { polishShare: 50 })).ok).toBe(true);
133 expect(fresh().polishShare).toBe(50);
134 expect((await updateRepoSettings(app, alice, fresh(), { polishShare: 10 })).ok).toBe(true);
135 });
136
137 test("the settings page shows the line with its reason, and its form saves it or says why not", async () => {
138 const page = await (await fetch(`${servers.url}/alice/notes/settings`, { headers: { cookie } })).text();
139 expect(page).toMatch(/name="polishShare"[^>]*value="10"/);
140 expect(page).toContain("Percent of the week's agent spend that polish may take");
141 const save = (value: string) =>
142 fetch(`${servers.url}/alice/notes/settings`, {
143 method: "POST", redirect: "manual",
144 headers: { cookie, "content-type": "application/x-www-form-urlencoded", origin: servers.url },
145 body: new URLSearchParams({ card: "gates", polishShareShown: "1", polishShare: value }),
146 });
147 expect((await save("20")).status).toBe(303);
148 expect(fresh().polishShare).toBe(20);
149 await save("99");
150 await save("abc");
151 expect(fresh().polishShare).toBe(20);
152 await save("10");
153 });
154
155 test("dispatch picks a ready polish issue under its share and stops once over, on a fake clock", async () => {
156 clear();
157 createLabel(app, alice, fresh(), "polish", "888888");
158 const polish = open("Nudge the margins");
159 expect(setIssueLabels(app, alice, fresh(), polish, ["polish"]).ok).toBe(true);
160 const feature = done("Search notes by word");
161 const older = done("Tidy the footer", "polish");
162
163 // $0.50 of $9.50 is 5%: under the 10% share.
164 spend(feature, 9, daysAgo(2));
165 spend(older, 0.5, daysAgo(1));
166 expect(polishSpend(app.db, repo.id, 10, NOW).under).toBe(true);
167 expect((await drive([polish], NOW)).map((i) => i.id)).toEqual([polish.id]);
168
169 // Polish at $3 of $12 is 25%: over. The same ready issue is left alone.
170 app.db.query("UPDATE issue SET assignee_id = NULL WHERE id = ?1").run(polish.id);
171 clear();
172 spend(feature, 9, daysAgo(2));
173 spend(older, 3, daysAgo(1));
174 expect(polishSpend(app.db, repo.id, 10, NOW)).toMatchObject({ polishUsd: 3, totalUsd: 12, under: false });
175 expect(await drive([polish], NOW)).toEqual([]);
176
177 // Eight days later that spend has left the window: the clock alone makes it pickable again.
178 const later = new Date(NOW.getTime() + 8 * 86_400_000);
179 expect(polishSpend(app.db, repo.id, 10, later).under).toBe(true);
180 expect((await drive([polish], later)).map((i) => i.id)).toEqual([polish.id]);
181 });
182
183 test("the small-fixes lane counts as polish, and what the owner raised is not held back", async () => {
184 clear();
185 app.db.query("UPDATE issue SET state = 'closed', close_reason = 'done' WHERE repo_id = ?1 AND assignee_id IS NULL").run(repo.id);
186 const lane = open("A test pins the tags");
187 const old = done("A test pins the body");
188 spend(done("Export notes"), 5, daysAgo(1));
189 spend(old, 5, daysAgo(1));
190 expect(polishSpend(app.db, repo.id, 10, NOW).under).toBe(false);
191 expect(await drive([lane], NOW)).toEqual([]);
192 app.db.query("INSERT INTO issue_raise (issue_id, raised_at) VALUES (?1, ?2)").run(lane.id, NOW.toISOString());
193 expect((await drive([lane], NOW)).map((i) => i.id)).toEqual([lane.id]);
194 });
195
196 test("the week page and the week tool show polish spent against the share", async () => {
197 clear();
198 const polish = open("A test pins the date");
199 const feature = open("Export notes");
200 const now = new Date();
201 spend(feature, 9, new Date(now.getTime() - 86_400_000));
202 spend(polish, 1, new Date(now.getTime() - 86_400_000));
203 const page = await (await fetch(`${servers.url}/alice/notes/week`, { headers: { cookie } })).text();
204 expect(page).toContain("polish 10.0% of 10% this week ($1.00 of $10.00)");
205 expect(page).toContain("no polish starts until it is under");
206 });
test/tui-autonomy.test.ts +39 −2
@@ -9,7 +9,7 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
99 import { mkdtemp, rm } from "node:fs/promises";
1010 import { tmpdir } from "node:os";
1111 import { join } from "node:path";
12 import { createRepo } from "../src/actions/repos.ts";
12 import { createRepo, updateRepoSettings } from "../src/actions/repos.ts";
1313 import { createApp, type App as Forge } from "../src/app.ts";
1414 import { addTeamMember, createOrg, createTeam } from "../src/core/orgs.ts";
1515 import { AUTONOMY_BOUNDS, AUTONOMY_SAID, getRepoById, type Repo } from "../src/core/repos.ts";
@@ -64,7 +64,7 @@ describe("autonomy in the terminal", () => {
6464 await type("Autonomy");
6565 await press("enter");
6666 };
67 return { text: () => app.screen.toText(), press, type, open, stop: () => app.stop() };
67 return { app, text: () => app.screen.toText(), press, type, open, stop: () => app.stop() };
6868 }
6969
7070 beforeAll(async () => {
@@ -247,6 +247,43 @@ describe("autonomy in the terminal", () => {
247247 h.stop();
248248 });
249249
250 test("o sets the polish share through updateRepoSettings, refuses what the page refuses, and the week screen shows it live", async () => {
251 const h = boot(ada);
252 await h.open();
253 expect(h.text()).toContain("Polish 10% of the week's spend");
254 await h.press("o");
255 for (let i = 0; i < 4; i++) await h.press("backspace");
256 await h.type("25");
257 await h.press("enter");
258 expect(saved().polishShare).toBe(25);
259 expect(h.text()).toContain("Polish 25% of the week's spend");
260 for (const bad of ["51", "-1", "2.5", "x", ""]) {
261 await h.press("o");
262 for (let i = 0; i < 4; i++) await h.press("backspace");
263 await h.type(bad);
264 await h.press("enter");
265 expect(h.text()).toContain("Polish share is a whole number of percent, 0 to 50.");
266 expect(saved().polishShare).toBe(25);
267 }
268 h.stop();
269
270 const w = boot(ada);
271 await w.open();
272 await w.press("esc");
273 await w.press("ctrl+k");
274 await w.type("The week");
275 await w.press("enter");
276 await w.type("acme/arm");
277 await w.press("enter");
278 expect(w.text()).toContain("polish 0.0% of 25% this week ($0.00 of $0.00)");
279 // A change made anywhere else reaches the open screen without a key.
280 expect((await updateRepoSettings(forge, ada, saved(), { polishShare: 30 })).ok).toBe(true);
281 await new Promise((r) => setTimeout(r, 400));
282 await settleApp(w.app);
283 expect(w.text()).toContain("of 30% this week");
284 w.stop();
285 });
286
250287 for (const [who, get] of [
251288 ["a writer who is not an admin", () => dave],
252289 ["a reader", () => undefined],
test/week-doors.test.ts +1
@@ -234,6 +234,7 @@ describe("the week's doors", () => {
234234 expect(refused.error).toContain("forbidden");
235235 const read = await mcp(botTok, { app: "alice/plan" });
236236 expect(read.value.proposals[0].taken).toBe(false);
237 expect(read.value.polish).toMatchObject({ share: 10, under: true, polishUsd: 0 });
237238 const took = await mcp(aliceTok, { app: "alice/plan", take: 0 });
238239 expect(took.value.proposals[0].taken).toBe(true);
239240 expect((await mcp(aliceTok, { app: "alice/plan", take: 0 })).error).toContain("already taken");

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs