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
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/8853-the-week-s-pacing-shows-the-polish-share → main 3 commits fork 2 threads · 2 unresolved
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 | ||
| 9 | 9 | - **Tile.** Home's **Open bugs** tile (admins only) shows the open count and how many are past their clock; it links to `/boards`. |
| 10 | 10 | - **Terminal.** The **Bugs** palette entry shows the same count (admins who are people) and the alarm (cut to public for everyone else). |
| 11 | 11 | - **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). | |
| 13 | - **Not yet.** Shortcuts and hunters' findings of #8709 are not built. |
src/actions/repos.ts +5 −1
| @@ -66,6 +66,7 @@ import { | ||
| 66 | 66 | requireWriter, |
| 67 | 67 | type AccessError, |
| 68 | 68 | } from "./access.ts"; |
| 69 | import { validPolishShare } from "../crew/small_share.ts"; | |
| 69 | 70 | import { removeInstallCache } from "../crew/workdir.ts"; |
| 70 | 71 | import type { SettingsChanged } from "../core/settings_changed.ts"; |
| 71 | 72 | |
| @@ -283,7 +284,7 @@ export async function privateRepoForSealed( | ||
| 283 | 284 | return sealedForkFor(app, actor, base); |
| 284 | 285 | } |
| 285 | 286 | |
| 286 | export type UpdateSettingsError = "not_found" | "unknown_branch"; | |
| 287 | export type UpdateSettingsError = "not_found" | "unknown_branch" | "polish_share"; | |
| 287 | 288 | |
| 288 | 289 | /** Each repository's archive transitions, one after another. */ |
| 289 | 290 | const archiveTurns = new WeakMap<App, Map<string, Promise<void>>>(); |
| @@ -349,6 +350,7 @@ const AUDITED = [ | ||
| 349 | 350 | "autonomySlots", |
| 350 | 351 | "plannerSlots", |
| 351 | 352 | "reviewerSlots", |
| 353 | "polishShare", | |
| 352 | 354 | ] as const; |
| 353 | 355 | |
| 354 | 356 | export type { SettingsChanged }; |
| @@ -390,6 +392,8 @@ export async function updateRepoSettings( | ||
| 390 | 392 | // base, whoever owns it (`core/sealed.ts`). |
| 391 | 393 | if (repo.sealedFor !== null && settings.visibility === "public") |
| 392 | 394 | return err("not_found"); |
| 395 | if (settings.polishShare !== undefined && !validPolishShare(settings.polishShare)) | |
| 396 | return err("polish_share"); | |
| 393 | 397 | const branch = settings.defaultBranch; |
| 394 | 398 | if (branch !== undefined && branch !== repo.defaultBranch) { |
| 395 | 399 | const dir = repoDir(app.reposRoot, repo.ownerName, repo.name); |
src/core/repos.ts +7 −1
| @@ -147,6 +147,8 @@ export interface Repo { | ||
| 147 | 147 | readonly plannerSlots: number; |
| 148 | 148 | /** Reviews at once: 0 pauses the reviewer. */ |
| 149 | 149 | readonly reviewerSlots: number; |
| 150 | /** Percent of a week's agent spend polish may take, 0 to 50 (#8853). */ | |
| 151 | readonly polishShare: number; | |
| 150 | 152 | readonly autonomyBy: string | null; |
| 151 | 153 | /** |
| 152 | 154 | * "Outsiders may trigger the crew": whether someone who cannot write here |
| @@ -204,6 +206,7 @@ interface RepoRow { | ||
| 204 | 206 | autonomy_slots: number; |
| 205 | 207 | planner_slots: number; |
| 206 | 208 | reviewer_slots: number; |
| 209 | polish_share: number; | |
| 207 | 210 | autonomy_by: string | null; |
| 208 | 211 | crew_outsiders: number; |
| 209 | 212 | decisions_by: string; |
| @@ -246,6 +249,7 @@ function toRepo(row: RepoRow): Repo { | ||
| 246 | 249 | autonomySlots: row.autonomy_slots, |
| 247 | 250 | plannerSlots: row.planner_slots, |
| 248 | 251 | reviewerSlots: row.reviewer_slots, |
| 252 | polishShare: row.polish_share, | |
| 249 | 253 | autonomyBy: row.autonomy_by, |
| 250 | 254 | crewOutsiders: row.crew_outsiders === 1, |
| 251 | 255 | decisionsBy: isDecisionsBy(row.decisions_by) ? row.decisions_by : "me", |
| @@ -1208,6 +1212,7 @@ export interface RepoSettings { | ||
| 1208 | 1212 | autonomySlots?: number; |
| 1209 | 1213 | plannerSlots?: number; |
| 1210 | 1214 | reviewerSlots?: number; |
| 1215 | polishShare?: number; | |
| 1211 | 1216 | /** Who the forge acts as when it assigns and lands on its own. */ |
| 1212 | 1217 | autonomyBy?: string | null; |
| 1213 | 1218 | /** Whether someone who cannot write here may start or steer a run. */ |
| @@ -1239,7 +1244,7 @@ export function updateRepoSettings( | ||
| 1239 | 1244 | default_branch = ?4, require_review = ?5, require_checks = ?7, |
| 1240 | 1245 | autonomy = ?8, autonomy_direction = ?9, autonomy_ceiling_usd = ?10, |
| 1241 | 1246 | 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 | |
| 1243 | 1248 | WHERE id = ?6`, |
| 1244 | 1249 | ).run( |
| 1245 | 1250 | settings.visibility ?? repo.visibility, |
| @@ -1259,6 +1264,7 @@ export function updateRepoSettings( | ||
| 1259 | 1264 | (settings.plansNeedNod ?? repo.plansNeedNod) ? 1 : 0, |
| 1260 | 1265 | settings.plannerSlots ?? repo.plannerSlots, |
| 1261 | 1266 | settings.reviewerSlots ?? repo.reviewerSlots, |
| 1267 | settings.polishShare ?? repo.polishShare, | |
| 1262 | 1268 | ); |
| 1263 | 1269 | forgetListings(db); |
| 1264 | 1270 | return getRepoById(db, repo.id)!; |
src/crew/autonomy.ts +15 −5
| @@ -31,7 +31,7 @@ import { addLearned } from "../checks/learned.ts"; | ||
| 31 | 31 | import { batchWords, dropFromLine, enqueue, type GateCandidate, type Queued, type QueueOptions, queueClashes, queueWords, waitingPulls } from "../checks/queue.ts"; |
| 32 | 32 | import { checksFor } from "../checks/service.ts"; |
| 33 | 33 | 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"; | |
| 35 | 35 | import { backedBetOf, betsInUse } from "../core/bets.ts"; |
| 36 | 36 | import { mayPick } from "./bets.ts"; |
| 37 | 37 | import { raisedAt } from "../core/thought_links.ts"; |
| @@ -72,6 +72,7 @@ import { | ||
| 72 | 72 | listComments, |
| 73 | 73 | MAX_TITLE, |
| 74 | 74 | type Issue, |
| 75 | labelsForIssues, | |
| 75 | 76 | } from "../core/issues.ts"; |
| 76 | 77 | import { |
| 77 | 78 | getPull, |
| @@ -406,7 +407,7 @@ function freeSlots(app: App, repo: Repo, now: Date): number { | ||
| 406 | 407 | } |
| 407 | 408 | |
| 408 | 409 | /** 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[] { | |
| 410 | 411 | const issues = ready.map((r) => r.issue); |
| 411 | 412 | const small = smallIssues(app.db, issues); |
| 412 | 413 | 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 | ||
| 416 | 417 | const running = smallOfIds(app.db, [...new Set([...slotted.flatMap((r) => (r.issueId === null ? [] : [r.issueId])), ...picked])]); |
| 417 | 418 | // Once the owner has backed a bet, builders pick from backed bets, the owner's raised issues and the small lane (#8713). |
| 418 | 419 | 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)); | |
| 419 | 429 | const allowed = |
| 420 | 430 | bets === undefined |
| 421 | ? ready | |
| 422 | : ready.filter((r) => | |
| 431 | ? pickable | |
| 432 | : pickable.filter((r) => | |
| 423 | 433 | mayPick({ |
| 424 | 434 | raised: raised.has(r.issue.id) || first.has(r.issue.id), |
| 425 | 435 | small: small.has(r.issue.id), |
| @@ -553,7 +563,7 @@ async function driveOnce( | ||
| 553 | 563 | ) |
| 554 | 564 | ); |
| 555 | 565 | // 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)); | |
| 557 | 567 | if (next !== undefined) { |
| 558 | 568 | taken.add(next.issue.id); |
| 559 | 569 | const routed = await routeIssue(app, repo, next.issue, crewFor(next.issue), inFlight); |
src/http/mcp/tools/week.ts +6 −3
| @@ -1,10 +1,11 @@ | ||
| 1 | 1 | import { takeProposal } from "../../../actions/week.ts"; |
| 2 | import { polishSpend, polishWords } from "../../../crew/small_share.ts"; | |
| 2 | 3 | import { latestRetro, takenOf } from "../../../flow/retro.ts"; |
| 3 | 4 | import { bad, good, type Tool, APP_ARG, planRepo, isAnswer } from "../shared.ts"; |
| 4 | 5 | |
| 5 | 6 | export const tool: Tool = { |
| 6 | 7 | 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", | |
| 8 | 9 | description: "The week's retrospective; `take` accepts one proposal.", |
| 9 | 10 | inputSchema: { |
| 10 | 11 | type: "object", |
| @@ -17,7 +18,9 @@ export const tool: Tool = { | ||
| 17 | 18 | const repo = planRepo(ctx, user, args); |
| 18 | 19 | if (isAnswer(repo)) return repo; |
| 19 | 20 | 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." }); | |
| 21 | 24 | if (args["take"] !== undefined) { |
| 22 | 25 | const idx = args["take"]; |
| 23 | 26 | 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 = { | ||
| 25 | 28 | if (!took.ok) return bad(took.error.kind === "not_found" ? "no_such_proposal" : took.error.kind, took.error.message); |
| 26 | 29 | } |
| 27 | 30 | 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) })) }); | |
| 29 | 32 | }, |
| 30 | 33 | }; |
src/tui/screens/autonomy.ts +16 −1
| @@ -10,10 +10,12 @@ | ||
| 10 | 10 | * `updateRepoSettings` with the fields the page writes and toasts the page's |
| 11 | 11 | * flash (`AUTONOMY_SAID`), and a value out of bounds is refused with the |
| 12 | 12 | * page's sentence (`checkAutonomy`). |
| 13 | * `o` sets the polish share (0–50, #8853) through the same `updateRepoSettings`, so a web viewer sees it live. | |
| 13 | 14 | * Anyone else gets no palette entry and, if the screen is reached, no values. |
| 14 | 15 | */ |
| 15 | 16 | |
| 16 | 17 | import { updateRepoSettings } from "../../actions/repos.ts"; |
| 18 | import { POLISH_SHARE_MAX, validPolishShare } from "../../crew/small_share.ts"; | |
| 17 | 19 | import { seatSlotsSaid, setSeatSlots } from "../../actions/seat_slots.ts"; |
| 18 | 20 | import { checkSeatSlots, seatSlotsOf, seatSlotsWords } from "../../crew/seat_slots.ts"; |
| 19 | 21 | import type { CrewRole } from "../../core/agents.ts"; |
| @@ -102,7 +104,7 @@ export function autonomyScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 102 | 104 | title: "Autonomy", |
| 103 | 105 | get keys() { |
| 104 | 106 | 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")] | |
| 106 | 108 | : []; |
| 107 | 109 | }, |
| 108 | 110 | render(area, s) { |
| @@ -116,6 +118,7 @@ export function autonomyScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 116 | 118 | `Mode ${r.autonomy}`, |
| 117 | 119 | ` ${AUTONOMY_SAID[r.autonomy]}`, |
| 118 | 120 | `Ceiling ${dollars(r.autonomyCeilingUsd)} a day`, |
| 121 | `Polish ${r.polishShare}% of the week's spend`, | |
| 119 | 122 | ...seatSlotsOf(f.db, r).map( |
| 120 | 123 | (p) => `${(p.role[0]!.toUpperCase() + p.role.slice(1)).padEnd(9)}${p.slots} at once · ${seatSlotsWords(p)}`, |
| 121 | 124 | ), |
| @@ -161,6 +164,18 @@ export function autonomyScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 161 | 164 | }); |
| 162 | 165 | return true; |
| 163 | 166 | } |
| 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 | } | |
| 164 | 179 | const seat: Partial<Record<string, CrewRole>> = { p: "planner", s: "builder", r: "reviewer" }; |
| 165 | 180 | const role = seat[e.key]; |
| 166 | 181 | if (role !== undefined) { |
src/tui/screens/week.ts +15 −5
| @@ -10,7 +10,8 @@ | ||
| 10 | 10 | */ |
| 11 | 11 | |
| 12 | 12 | 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"; | |
| 14 | 15 | import { latestRetro, takenOf, type Retro } from "../../flow/retro.ts"; |
| 15 | 16 | import type { Ctx, Screen } from "../app/app.ts"; |
| 16 | 17 | import { binding } from "../app/keys.ts"; |
| @@ -20,7 +21,7 @@ import { truncate } from "../term/width.ts"; | ||
| 20 | 21 | import type { Session } from "../session.ts"; |
| 21 | 22 | import { ListState, drawList, hitList, live, type ListRow } from "./common.ts"; |
| 22 | 23 | |
| 23 | const LIVE_KINDS = ["week.retro", "issue.ranked"]; | |
| 24 | const LIVE_KINDS = ["week.retro", "issue.ranked", "repo.settings_updated"]; | |
| 24 | 25 | |
| 25 | 26 | /** The retrospective's screen of facts, one line each. */ |
| 26 | 27 | export function summaryLines(retro: Retro): string[] { |
| @@ -38,6 +39,12 @@ export function summaryLines(retro: Retro): string[] { | ||
| 38 | 39 | ]; |
| 39 | 40 | } |
| 40 | 41 | |
| 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 | ||
| 41 | 48 | export function weekScreen(f: Session, ctx: Ctx, repo: Repo): Screen { |
| 42 | 49 | const state = new ListState(); |
| 43 | 50 | let retro: Retro | undefined; |
| @@ -91,9 +98,12 @@ export function weekScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 91 | 98 | const c = ctx.theme.c; |
| 92 | 99 | const lines = !loaded |
| 93 | 100 | ? ["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 | ]; | |
| 97 | 107 | const [head, list] = split(area, "v", [len(lines.length + 1), fill(1)]); |
| 98 | 108 | lines.forEach((line, i) => |
| 99 | 109 | 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 @@ | ||
| 8 | 8 | * same-origin POST and every one of them says what happened. |
| 9 | 9 | */ |
| 10 | 10 | |
| 11 | import { POLISH_SHARE_DEFAULT, POLISH_SHARE_MAX } from "../../crew/small_share.ts"; | |
| 11 | 12 | import { appLimitWords } from "../../actions/app_limit.ts"; |
| 12 | 13 | import { showProvenance } from "../../actions/provenance.ts"; |
| 13 | 14 | import { getProvenance, parentUrl, sourceLabel } from "../../core/provenance.ts"; |
| @@ -331,6 +332,27 @@ export async function settingsPage(ctx: Ctx): Promise<Response> { | ||
| 331 | 332 | > |
| 332 | 333 | </label> |
| 333 | 334 | </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> | |
| 334 | 356 | ${cellMemory === undefined |
| 335 | 357 | ? null |
| 336 | 358 | : html`<div class="field"><p class="hint" data-check-memory>${ |
| @@ -412,6 +434,9 @@ export async function settingsPage(ctx: Ctx): Promise<Response> { | ||
| 412 | 434 | return response; |
| 413 | 435 | } |
| 414 | 436 | |
| 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 | ||
| 415 | 440 | export async function settingsUpdate(ctx: Ctx): Promise<Response> { |
| 416 | 441 | const gate = await adminForm(ctx); |
| 417 | 442 | if (gate instanceof Response) return gate; |
| @@ -443,6 +468,8 @@ export async function settingsUpdate(ctx: Ctx): Promise<Response> { | ||
| 443 | 468 | ? { crewOutsiders: form.get("crewOutsiders") !== null } |
| 444 | 469 | : {}), |
| 445 | 470 | ...(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")) } : {}), | |
| 446 | 473 | }; |
| 447 | 474 | const updated = await updateRepoSettings( |
| 448 | 475 | ctx.app, |
| @@ -457,6 +484,8 @@ export async function settingsUpdate(ctx: Ctx): Promise<Response> { | ||
| 457 | 484 | message: |
| 458 | 485 | updated.error === "unknown_branch" |
| 459 | 486 | ? "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}.` | |
| 460 | 489 | : "Those settings could not be saved.", |
| 461 | 490 | }; |
| 462 | 491 | return flashTo(ctx, `${base}/settings`, flash); |
src/web/pages/week.ts +18 −2
| @@ -5,7 +5,8 @@ | ||
| 5 | 5 | * and nothing else on the page changes anything. |
| 6 | 6 | */ |
| 7 | 7 | 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"; | |
| 9 | 10 | import { notePhase, takeSessionTime } from "../../core/route_timing.ts"; |
| 10 | 11 | import { latestRetro, takenOf, type Line, type Retro } from "../../flow/retro.ts"; |
| 11 | 12 | import type { Ctx } from "../../http/context.ts"; |
| @@ -13,7 +14,7 @@ import { crumbsFor, loadRepo, notFoundPage, repoBase, repoHeader, repoPalette, s | ||
| 13 | 14 | import { backTo, field, readForm } from "../form.ts"; |
| 14 | 15 | import { html, type Html } from "../html.ts"; |
| 15 | 16 | import { flashOrJson, page } from "../layout.ts"; |
| 16 | import { btn, card, row } from "../ui.ts"; | |
| 17 | import { btn, card, meter, row, stat } from "../ui.ts"; | |
| 17 | 18 | |
| 18 | 19 | const lines = (base: string, ls: readonly Line[], none: string): Html => |
| 19 | 20 | ls.length === 0 |
| @@ -62,6 +63,19 @@ function screen(base: string, retro: Retro, taken: ReadonlySet<number>, writer: | ||
| 62 | 63 | )}</ul>`}`; |
| 63 | 64 | } |
| 64 | 65 | |
| 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 | ||
| 65 | 79 | export function weekPage(ctx: Ctx): Response { |
| 66 | 80 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); |
| 67 | 81 | 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 { | ||
| 77 | 91 | ? "The forge writes this when the weekly window closes. None has closed yet." |
| 78 | 92 | : `Written when the window ended ${retro.weekEnd.slice(0, 10)}, once, and never edited.`} <a href="${base}/next">Next up</a></p> |
| 79 | 93 | </div> |
| 94 | <h2>Polish this week</h2> | |
| 95 | ${polishCard(ctx, repo)} | |
| 80 | 96 | ${retro === undefined ? null : screen(base, retro, takenOf(ctx.app.db, repo.id, retro.weekEnd), writer)}`; |
| 81 | 97 | const response = page({ ctx, title: `The week · ${repo.name}`, crumbs: crumbsFor(repo), palette: repoPalette(ctx, repo), body }); |
| 82 | 98 | notePhase(ctx.request, "render", performance.now() - rendering); |
test/tui-autonomy.test.ts +39 −2
| @@ -9,7 +9,7 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | ||
| 9 | 9 | import { mkdtemp, rm } from "node:fs/promises"; |
| 10 | 10 | import { tmpdir } from "node:os"; |
| 11 | 11 | import { join } from "node:path"; |
| 12 | import { createRepo } from "../src/actions/repos.ts"; | |
| 12 | import { createRepo, updateRepoSettings } from "../src/actions/repos.ts"; | |
| 13 | 13 | import { createApp, type App as Forge } from "../src/app.ts"; |
| 14 | 14 | import { addTeamMember, createOrg, createTeam } from "../src/core/orgs.ts"; |
| 15 | 15 | import { AUTONOMY_BOUNDS, AUTONOMY_SAID, getRepoById, type Repo } from "../src/core/repos.ts"; |
| @@ -64,7 +64,7 @@ describe("autonomy in the terminal", () => { | ||
| 64 | 64 | await type("Autonomy"); |
| 65 | 65 | await press("enter"); |
| 66 | 66 | }; |
| 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() }; | |
| 68 | 68 | } |
| 69 | 69 | |
| 70 | 70 | beforeAll(async () => { |
| @@ -247,6 +247,43 @@ describe("autonomy in the terminal", () => { | ||
| 247 | 247 | h.stop(); |
| 248 | 248 | }); |
| 249 | 249 | |
| 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 | ||
| 250 | 287 | for (const [who, get] of [ |
| 251 | 288 | ["a writer who is not an admin", () => dave], |
| 252 | 289 | ["a reader", () => undefined], |
test/week-doors.test.ts +1
| @@ -234,6 +234,7 @@ describe("the week's doors", () => { | ||
| 234 | 234 | expect(refused.error).toContain("forbidden"); |
| 235 | 235 | const read = await mcp(botTok, { app: "alice/plan" }); |
| 236 | 236 | expect(read.value.proposals[0].taken).toBe(false); |
| 237 | expect(read.value.polish).toMatchObject({ share: 10, under: true, polishUsd: 0 }); | |
| 237 | 238 | const took = await mcp(aliceTok, { app: "alice/plan", take: 0 }); |
| 238 | 239 | expect(took.value.proposals[0].taken).toBe(true); |
| 239 | 240 | expect((await mcp(aliceTok, { app: "alice/plan", take: 0 })).error).toContain("already taken"); |
new above
offline
Move this to its own docs/features/polish-share.md and link it here; the polish share is not a bug feature.