Public: anyone can read this; the crew can change it.
The week is the sprint: a cut line on Next up shows what this week's capacity can ship, and the forge writes its own retrospective #8809
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/8704-the-week-is-the-sprint-a-cut-line-on-nex → main 3 commits fork 1 thread · 1 unresolved
25 files
changed +1467
−14
docs/design/areas/week.md added +3
| @@ -0,0 +1,3 @@ | ||
| 1 | # The week | |
| 2 | ||
| 3 | On Next up (`src/web/pages/next.ts`) `.cut-line` is the separator row drawn between what this week's capacity can ship and what reads as next week: a dashed rule in `--series-1` with muted words under it. `.estimate` is a row's cost range, in `--fg` with tabular figures so ranges line up down the list. `/:owner/:name/week` uses only existing components (`card`, `row`, `btn`). |
docs/features/next-up.md +13
| @@ -11,3 +11,16 @@ | ||
| 11 | 11 | - A rank is cleared when the issue closes. Dependencies order within the list and are shown, never edited here. |
| 12 | 12 | |
| 13 | 13 | **Terminal:** the palette's "Next up in a repository" opens the same list; `t`, `u`, `d` and `n` are Top, Up, Down and Not now on the row under the cursor, through the same action as the page and MCP. A reader sees the order and the keys do nothing; a move made anywhere reorders an open screen live. At most 200 issues are ranked; a move past that is refused and says so. |
| 14 | ||
| 15 | ## The week is the sprint (#8704) | |
| 16 | ||
| 17 | The subscription's weekly reset is the sprint boundary; what the crew may still spend before it is the capacity (`flow/week.ts`, from `core/standing.ts`). No ceremony and no locked scope: rerank any time and the line moves on the next paint. | |
| 18 | ||
| 19 | - **Estimates:** each row shows a cost range (the 25th to 75th percentile of what past closed issues of the same size and kind cost, every agent run on them summed, `flow/estimate.ts`). With fewer than three comparable issues it falls back to size alone, then says "too little history". Measured, never asked of an agent: no run is spent. | |
| 20 | - **Cut line:** walking from the top, the running median estimate is held against the capacity; the line falls before the first issue that does not fit and everything under it reads as next week. An issue with no estimate counts as the median of the others; with none estimated, or no capacity read, there is no line and the sentence says why. Same list and history, same line. | |
| 21 | - **Projection:** one sentence above the list: how many should ship by the reset, as a range from the high and low ends of the estimates, and a confidence (high at eight or more samples on every row). | |
| 22 | - **Retrospective:** once an hour the forge keeps the projection of the open window (`week_projection`) and, for a window that has ended, writes `week_retro` once (`flow/retro.ts`): what shipped against the projection, how many shipped issues cost inside their range, what open work cost most, what is stuck (two failed runs or more, and the class of the last failure, never the run's own output), and at most three proposals. `/:owner/:name/week` and the MCP `week` tool show it. A proposal is one tap (`POST /:owner/:name/week`, or `week { app, take }`) by a writer who is a person; it puts the issue behind the Later gate, the same move as `Not now`, and is taken at most once. Nothing changes without that tap. Only a person who writes can take one: `takeProposal` refuses an agent and a reader for every door. | |
| 23 | - **Not done here:** the line falls between issues, not whole scopes, because bets and scopes (#8713) are not in the forge yet; when they land the line moves to their edges. The health mark per group is left for the same reason. | |
| 24 | - **Live:** a drag posts the move and the page reloads itself from the server, which redraws the line; there is no manual reload, but it is a reload, not a client-side recompute. | |
| 25 | - **Terminal:** Next up shows each row's range and a muted "this week ends here" line under the last issue that fits, with the projection in the header. "The week in a repository" (palette) shows the retrospective; `j`/`k` choose a proposal and `enter` or `a` takes it, through the same `takeProposal`. A reader or an agent sees it and the key does nothing; a new retrospective (`week.retro`) or a proposal taken anywhere updates the open screen. MCP `next_up` rows carry `estimate` and `thisWeek`, with a `projection` sentence. | |
| 26 | - Sealed issues are left out of history and of the retrospective, so their cost never shapes a visible range. |
src/actions/week.ts added +41
| @@ -0,0 +1,41 @@ | ||
| 1 | /** | |
| 2 | * Taking a proposal from the week's retrospective (#8704): one tap by a person | |
| 3 | * who writes here, and it is the same move as the Next up page's `Not now` | |
| 4 | * (`actions/next.ts`). Each proposal is taken at most once; nothing else in | |
| 5 | * the retrospective ever changes anything. | |
| 6 | */ | |
| 7 | import type { App } from "../app.ts"; | |
| 8 | import { getIssue } from "../core/issues.ts"; | |
| 9 | import { canWrite, type Repo } from "../core/repos.ts"; | |
| 10 | import type { User } from "../core/users.ts"; | |
| 11 | import { err, ok, type Result } from "../kernel/result.ts"; | |
| 12 | import { latestRetro, takenOf } from "../flow/retro.ts"; | |
| 13 | import { rankIssue, type NextError } from "./next.ts"; | |
| 14 | ||
| 15 | export const WEEK_PERSON = "Only a person takes a proposal; an agent proposes and a person lands."; | |
| 16 | export const WEEK_FORBIDDEN = "Only someone with write access can take a proposal."; | |
| 17 | ||
| 18 | export function takeProposal( | |
| 19 | app: App, | |
| 20 | actor: User, | |
| 21 | repo: Repo, | |
| 22 | weekEnd: string, | |
| 23 | idx: number, | |
| 24 | ): Result<string, NextError> { | |
| 25 | // A person's tap, whatever the door: an agent proposes and never takes, a reader cannot. | |
| 26 | if (actor.kind === "agent") return err({ kind: "forbidden", message: WEEK_PERSON }); | |
| 27 | if (!canWrite(app.db, repo, actor)) return err({ kind: "forbidden", message: WEEK_FORBIDDEN }); | |
| 28 | const retro = latestRetro(app.db, repo.id); | |
| 29 | const proposal = retro?.weekEnd === weekEnd ? retro.proposals[idx] : undefined; | |
| 30 | if (proposal === undefined) return err({ kind: "not_found", message: "No such proposal." }); | |
| 31 | if (takenOf(app.db, repo.id, weekEnd).has(idx)) | |
| 32 | return err({ kind: "invalid", message: "That proposal was already taken." }); | |
| 33 | const issue = getIssue(app.db, repo.id, proposal.number); | |
| 34 | if (issue === undefined) return err({ kind: "not_found", message: "No such issue." }); | |
| 35 | const moved = rankIssue(app, actor, repo, proposal.number, { to: "not_now" }); | |
| 36 | if (!moved.ok) return moved; | |
| 37 | app.db | |
| 38 | .query("INSERT OR IGNORE INTO week_retro_taken (repo_id, week_end, idx, actor_id, at) VALUES (?1, ?2, ?3, ?4, ?5)") | |
| 39 | .run(repo.id, weekEnd, idx, actor.id, new Date().toISOString()); | |
| 40 | return ok(`#${proposal.number} is not now.`); | |
| 41 | } |
src/core/event_audience.ts +1
| @@ -38,6 +38,7 @@ const READER_KINDS: ReadonlySet<string> = new Set([ | ||
| 38 | 38 | "issue.labeled", |
| 39 | 39 | "issue.first", |
| 40 | 40 | "issue.ranked", |
| 41 | "week.retro", | |
| 41 | 42 | "issue.closed", |
| 42 | 43 | "issue.gate_reviewed", |
| 43 | 44 | "issue.commented", |
src/core/migration-pins/20261103110000-week-retro.pin added +1
| @@ -0,0 +1,1 @@ | ||
| 1 | 8d5ade86821784d4cf4fc987b5e843b7df9f3855e8640fb54c34d73be83cc70f |
src/core/migrations/20261103110000-week-retro.ts added +28
| @@ -0,0 +1,28 @@ | ||
| 1 | // The week as the sprint (#8704): the projection the forge made when a weekly | |
| 2 | // window opened, and the retrospective it wrote when that window closed, | |
| 3 | // frozen once. A proposal in a retrospective is accepted at most once, by a | |
| 4 | // person: `week_retro_taken` holds who and when. | |
| 5 | export default ` | |
| 6 | CREATE TABLE IF NOT EXISTS week_projection ( | |
| 7 | repo_id TEXT NOT NULL REFERENCES repo(id) ON DELETE CASCADE, | |
| 8 | week_end TEXT NOT NULL, | |
| 9 | body TEXT NOT NULL, | |
| 10 | at TEXT NOT NULL, | |
| 11 | PRIMARY KEY (repo_id, week_end) | |
| 12 | ); | |
| 13 | CREATE TABLE IF NOT EXISTS week_retro ( | |
| 14 | repo_id TEXT NOT NULL REFERENCES repo(id) ON DELETE CASCADE, | |
| 15 | week_end TEXT NOT NULL, | |
| 16 | body TEXT NOT NULL, | |
| 17 | at TEXT NOT NULL, | |
| 18 | PRIMARY KEY (repo_id, week_end) | |
| 19 | ); | |
| 20 | CREATE TABLE IF NOT EXISTS week_retro_taken ( | |
| 21 | repo_id TEXT NOT NULL, | |
| 22 | week_end TEXT NOT NULL, | |
| 23 | idx INTEGER NOT NULL, | |
| 24 | actor_id TEXT NOT NULL, | |
| 25 | at TEXT NOT NULL, | |
| 26 | PRIMARY KEY (repo_id, week_end, idx) | |
| 27 | ); | |
| 28 | `; |
src/flow/estimate.ts added +92
| @@ -0,0 +1,92 @@ | ||
| 1 | /** | |
| 2 | * What an issue will cost, as a range, from what past issues of its size and | |
| 3 | * kind cost (#8704). Measured, never guessed, and no agent run is spent: the | |
| 4 | * history is the ledger of finished work. Same history, same range. Plain data | |
| 5 | * in and out; `issueHistory` is the one read. | |
| 6 | */ | |
| 7 | ||
| 8 | import type { Database } from "bun:sqlite"; | |
| 9 | import type { Issue } from "../core/issues.ts"; | |
| 10 | ||
| 11 | /** Samples a group needs before it may speak; fewer and the estimate says so. */ | |
| 12 | export const MIN_SAMPLES = 3; | |
| 13 | /** Samples at which a group is trusted. */ | |
| 14 | export const SURE_SAMPLES = 8; | |
| 15 | /** The most past issues one estimate reads. */ | |
| 16 | const HISTORY_MAX = 400; | |
| 17 | ||
| 18 | export type Size = "small" | "medium" | "large"; | |
| 19 | ||
| 20 | export interface Sample { | |
| 21 | readonly size: Size; | |
| 22 | readonly kind: string; | |
| 23 | readonly usd: number; | |
| 24 | } | |
| 25 | ||
| 26 | export type Estimate = | |
| 27 | | { readonly known: true; readonly low: number; readonly mid: number; readonly high: number; readonly samples: number; readonly basis: string } | |
| 28 | | { readonly known: false; readonly samples: number; readonly basis: string }; | |
| 29 | ||
| 30 | /** How big an issue reads, from the words written on it. */ | |
| 31 | export function sizeOf(issue: Pick<Issue, "title" | "body">): Size { | |
| 32 | const n = issue.title.length + issue.body.length; | |
| 33 | return n < 400 ? "small" : n < 1500 ? "medium" : "large"; | |
| 34 | } | |
| 35 | ||
| 36 | const quantile = (sorted: readonly number[], q: number): number => sorted[Math.round((sorted.length - 1) * q)]!; | |
| 37 | const cents = (n: number): number => Math.round(n * 100) / 100; | |
| 38 | ||
| 39 | function rangeOf(group: readonly Sample[], basis: string): Estimate { | |
| 40 | const usd = group.map((s) => s.usd).sort((a, b) => a - b); | |
| 41 | return { | |
| 42 | known: true, | |
| 43 | low: cents(quantile(usd, 0.25)), | |
| 44 | mid: cents(quantile(usd, 0.5)), | |
| 45 | high: cents(quantile(usd, 0.75)), | |
| 46 | samples: usd.length, | |
| 47 | basis, | |
| 48 | }; | |
| 49 | } | |
| 50 | ||
| 51 | /** Past issues of the same size and kind; if too few, of the same size; if still too few, no estimate. */ | |
| 52 | export function estimate(history: readonly Sample[], size: Size, kind: string): Estimate { | |
| 53 | const same = history.filter((s) => s.size === size && s.kind === kind); | |
| 54 | if (same.length >= MIN_SAMPLES) return rangeOf(same, `${same.length} past ${size} ${kind === "" ? "issues" : `“${kind}” issues`}`); | |
| 55 | const sized = history.filter((s) => s.size === size); | |
| 56 | if (sized.length >= MIN_SAMPLES) return rangeOf(sized, `${sized.length} past ${size} issues`); | |
| 57 | return { known: false, samples: sized.length, basis: `too little history: ${sized.length} past ${size} issues` }; | |
| 58 | } | |
| 59 | ||
| 60 | /** The kind of an issue: its first label by name, lowercase, or "". */ | |
| 61 | export const kindOf = (labels: readonly string[]): string => [...labels].map((l) => l.toLowerCase()).sort()[0] ?? ""; | |
| 62 | ||
| 63 | /** Labels of the given issues, by issue id (the issue_label key leads with the issue). */ | |
| 64 | export function labelsOf(db: Database, issueIds: readonly string[]): Map<string, string[]> { | |
| 65 | const out = new Map<string, string[]>(); | |
| 66 | for (let at = 0; at < issueIds.length; at += 200) { | |
| 67 | const part = issueIds.slice(at, at + 200); | |
| 68 | const rows = db | |
| 69 | .query<{ issue_id: string; name: string }, string[]>( | |
| 70 | `SELECT il.issue_id, l.name FROM issue_label il JOIN label l ON l.id = il.label_id | |
| 71 | WHERE il.issue_id IN (${part.map((_, i) => `?${i + 1}`).join(",")})`, | |
| 72 | ) | |
| 73 | .all(...part); | |
| 74 | for (const r of rows) out.set(r.issue_id, [...(out.get(r.issue_id) ?? []), r.name]); | |
| 75 | } | |
| 76 | return out; | |
| 77 | } | |
| 78 | ||
| 79 | /** What closed issues cost: every agent run on each, summed; the latest {@link HISTORY_MAX} by number. */ | |
| 80 | export function issueHistory(db: Database, repoId: string): Sample[] { | |
| 81 | const rows = db | |
| 82 | .query<{ id: string; title: string; body: string; usd: number }, [string, number]>( | |
| 83 | `SELECT i.id, i.title, i.body, | |
| 84 | (SELECT SUM(r.cost_usd) FROM run r WHERE r.issue_id = i.id AND r.kind = 'agent') AS usd | |
| 85 | FROM issue i | |
| 86 | WHERE i.repo_id = ?1 AND i.kind = 'issue' AND i.state = 'closed' AND i.sealed = 0 AND i.close_reason = 'done' AND usd > 0 | |
| 87 | ORDER BY i.number DESC LIMIT ?2`, | |
| 88 | ) | |
| 89 | .all(repoId, HISTORY_MAX); | |
| 90 | const labels = labelsOf(db, rows.map((r) => r.id)); | |
| 91 | return rows.map((r) => ({ size: sizeOf(r), kind: kindOf(labels.get(r.id) ?? []), usd: r.usd })); | |
| 92 | } |
src/flow/retro.ts added +263
| @@ -0,0 +1,263 @@ | ||
| 1 | /** | |
| 2 | * The week's retrospective (#8704): when a weekly window closes the forge | |
| 3 | * writes one frozen screen per repository, unasked — what shipped against the | |
| 4 | * projection it made when the window opened, how close the estimates ran, | |
| 5 | * what cost most for least, what got stuck and why — and ends it in at most | |
| 6 | * three proposals. A proposal changes nothing until a person takes it | |
| 7 | * (`actions/week.ts`). Plain code over the ledger, no model, no agent run. | |
| 8 | */ | |
| 9 | ||
| 10 | import type { Database } from "bun:sqlite"; | |
| 11 | import type { Repo } from "../core/repos.ts"; | |
| 12 | import { nextUp } from "../core/next_up.ts"; | |
| 13 | import type { Estimate } from "./estimate.ts"; | |
| 14 | import { cutLine, priceRows, projection, weekCapacity, type Confidence } from "./week.ts"; | |
| 15 | ||
| 16 | export const WEEK_MS = 7 * 86_400_000; | |
| 17 | export const MAX_PROPOSALS = 3; | |
| 18 | /** A run that ended failed this many times in the window marks an issue stuck. */ | |
| 19 | const STUCK_FAILS = 2; | |
| 20 | ||
| 21 | export interface Projected { | |
| 22 | readonly sentence: string; | |
| 23 | readonly fits: readonly number[]; | |
| 24 | readonly shipLow: number; | |
| 25 | readonly shipHigh: number; | |
| 26 | readonly confidence: Confidence; | |
| 27 | readonly ranges: Readonly<Record<string, { low: number; high: number }>>; | |
| 28 | } | |
| 29 | ||
| 30 | export interface Line { | |
| 31 | readonly number: number; | |
| 32 | readonly title: string; | |
| 33 | readonly usd: number; | |
| 34 | readonly why: string; | |
| 35 | } | |
| 36 | ||
| 37 | export interface Proposal { | |
| 38 | /** The one change a tap makes: put the issue behind the Later gate. */ | |
| 39 | readonly kind: "not_now"; | |
| 40 | readonly number: number; | |
| 41 | readonly words: string; | |
| 42 | } | |
| 43 | ||
| 44 | export interface Retro { | |
| 45 | readonly weekEnd: string; | |
| 46 | readonly shipped: readonly Line[]; | |
| 47 | readonly projected: Projected | null; | |
| 48 | /** Shipped issues that had a range, and how many cost inside it. */ | |
| 49 | readonly accuracy: { readonly compared: number; readonly inside: number }; | |
| 50 | readonly costly: readonly Line[]; | |
| 51 | readonly stuck: readonly Line[]; | |
| 52 | readonly proposals: readonly Proposal[]; | |
| 53 | } | |
| 54 | ||
| 55 | const usd = (n: number): string => `$${n.toFixed(2)}`; | |
| 56 | ||
| 57 | /** The projection made now for the open window; null while no weekly reset is known. */ | |
| 58 | export function projectNow(db: Database, repo: Repo, now: Date): { weekEnd: string; projected: Projected } | null { | |
| 59 | const capacity = weekCapacity(db, now); | |
| 60 | if (capacity?.resetsAt == null) return null; | |
| 61 | const rows = nextUp(db, repo).rows; | |
| 62 | const priced = priceRows(db, repo.id, rows); | |
| 63 | const cut = cutLine(priced, capacity); | |
| 64 | const ranges: Record<string, { low: number; high: number }> = {}; | |
| 65 | for (const p of priced) if (p.estimate.known) ranges[String(p.number)] = rangeOf(p.estimate); | |
| 66 | return { | |
| 67 | weekEnd: capacity.resetsAt, | |
| 68 | projected: { | |
| 69 | sentence: projection(cut, rows.length, capacity), | |
| 70 | fits: cut.fits, | |
| 71 | shipLow: cut.shipLow, | |
| 72 | shipHigh: cut.shipHigh, | |
| 73 | confidence: cut.confidence, | |
| 74 | ranges, | |
| 75 | }, | |
| 76 | }; | |
| 77 | } | |
| 78 | ||
| 79 | const rangeOf = (e: Estimate & { known: true }) => ({ low: e.low, high: e.high }); | |
| 80 | ||
| 81 | interface SpentRow { | |
| 82 | number: number; | |
| 83 | title: string; | |
| 84 | state: string; | |
| 85 | usd: number; | |
| 86 | failed: number; | |
| 87 | last: string; | |
| 88 | } | |
| 89 | ||
| 90 | /** What each issue touched in the window cost, and how its runs ended; the dearest first. */ | |
| 91 | export function windowSpend(db: Database, repoId: string, from: string, to: string): SpentRow[] { | |
| 92 | const runs = db | |
| 93 | .query<{ id: string; issue_id: string; cost_usd: number; state: string; summary: string }, [string, string, string]>( | |
| 94 | `SELECT r.id, r.issue_id, r.cost_usd, r.state, r.summary FROM run r | |
| 95 | WHERE r.kind = 'agent' AND r.started_at >= ?2 AND r.started_at < ?3 AND r.repo_id = ?1 AND r.issue_id IS NOT NULL`, | |
| 96 | ) | |
| 97 | .all(repoId, from, to); | |
| 98 | const by = new Map<string, { usd: number; failed: number; last: string; lastId: string }>(); | |
| 99 | for (const r of runs) { | |
| 100 | const was = by.get(r.issue_id) ?? { usd: 0, failed: 0, last: "", lastId: "" }; | |
| 101 | was.usd += r.cost_usd; | |
| 102 | if (r.state === "failed") { | |
| 103 | was.failed++; | |
| 104 | if (r.id > was.lastId) { | |
| 105 | was.lastId = r.id; | |
| 106 | was.last = r.summary; | |
| 107 | } | |
| 108 | } | |
| 109 | by.set(r.issue_id, was); | |
| 110 | } | |
| 111 | const out: SpentRow[] = []; | |
| 112 | for (const [issueId, w] of by) { | |
| 113 | const i = db | |
| 114 | .query<{ number: number; title: string; state: string; sealed: number }, [string]>( | |
| 115 | "SELECT number, title, state, sealed FROM issue WHERE id = ?1 AND kind = 'issue'", | |
| 116 | ) | |
| 117 | .get(issueId); | |
| 118 | if (i !== null && i.sealed === 0) out.push({ number: i.number, title: i.title, state: i.state, usd: w.usd, failed: w.failed, last: w.last }); | |
| 119 | } | |
| 120 | return out.sort((a, b) => b.usd - a.usd || a.number - b.number); | |
| 121 | } | |
| 122 | ||
| 123 | /** Issues closed done in the window, with the spend of every run on each. */ | |
| 124 | export function windowShipped(db: Database, repoId: string, from: string, to: string): Line[] { | |
| 125 | return db | |
| 126 | .query<{ number: number; title: string; usd: number }, [string, string, string]>( | |
| 127 | `SELECT i.number, i.title, COALESCE((SELECT SUM(cost_usd) FROM run WHERE issue_id = i.id AND kind = 'agent'), 0) AS usd | |
| 128 | FROM issue i | |
| 129 | WHERE i.repo_id = ?1 AND i.kind = 'issue' AND i.state = 'closed' AND i.sealed = 0 AND i.close_reason = 'done' | |
| 130 | AND i.closed_at >= ?2 AND i.closed_at < ?3 | |
| 131 | ORDER BY i.number`, | |
| 132 | ) | |
| 133 | .all(repoId, from, to) | |
| 134 | .map((r) => ({ number: r.number, title: r.title, usd: r.usd, why: `cost ${usd(r.usd)}` })); | |
| 135 | } | |
| 136 | ||
| 137 | /** A failed run's words reduced to a class: its own output is never copied, since it may hold anything. */ | |
| 138 | export function failureClass(summary: string): string { | |
| 139 | const t = summary.toLowerCase(); | |
| 140 | if (/time ?out|timed out/.test(t)) return "timed out"; | |
| 141 | if (/limit|quota|rate/.test(t)) return "hit a usage limit"; | |
| 142 | if (/check|ci\b|lint|type/.test(t)) return "its check failed"; | |
| 143 | if (/test/.test(t)) return "its tests failed"; | |
| 144 | if (/conflict|merge/.test(t)) return "hit a merge conflict"; | |
| 145 | return "ended without finishing"; | |
| 146 | } | |
| 147 | ||
| 148 | /** Pure: the retrospective from what was read. */ | |
| 149 | export function composeRetro( | |
| 150 | weekEnd: string, | |
| 151 | shipped: readonly Line[], | |
| 152 | spent: readonly SpentRow[], | |
| 153 | projected: Projected | null, | |
| 154 | ): Retro { | |
| 155 | let compared = 0; | |
| 156 | let inside = 0; | |
| 157 | for (const s of shipped) { | |
| 158 | const range = projected?.ranges[String(s.number)]; | |
| 159 | if (range === undefined) continue; | |
| 160 | compared++; | |
| 161 | if (s.usd >= range.low && s.usd <= range.high) inside++; | |
| 162 | } | |
| 163 | const open = spent.filter((s) => s.state === "open" && s.usd > 0); | |
| 164 | const costly = open | |
| 165 | .slice(0, MAX_PROPOSALS) | |
| 166 | .map((s) => ({ number: s.number, title: s.title, usd: s.usd, why: `spent ${usd(s.usd)} and is still open` })); | |
| 167 | const stuck = open | |
| 168 | .filter((s) => s.failed >= STUCK_FAILS) | |
| 169 | .map((s) => ({ | |
| 170 | number: s.number, | |
| 171 | title: s.title, | |
| 172 | usd: s.usd, | |
| 173 | why: `${s.failed} failed runs, the last ${failureClass(s.last)}`, | |
| 174 | })); | |
| 175 | const picked = new Map<number, Proposal>(); | |
| 176 | for (const l of [...stuck, ...costly]) { | |
| 177 | if (picked.size >= MAX_PROPOSALS) break; | |
| 178 | if (!picked.has(l.number)) | |
| 179 | picked.set(l.number, { | |
| 180 | kind: "not_now", | |
| 181 | number: l.number, | |
| 182 | words: `Put #${l.number} behind the Later gate: it ${l.why.startsWith("spent") ? l.why : `has ${l.why}`}.`, | |
| 183 | }); | |
| 184 | } | |
| 185 | return { weekEnd, shipped, projected, accuracy: { compared, inside }, costly, stuck, proposals: [...picked.values()] }; | |
| 186 | } | |
| 187 | ||
| 188 | /** Write the retrospective of the window that ended at `weekEnd`, once. True when this call wrote it. */ | |
| 189 | export function writeRetro(db: Database, repo: Repo, weekEnd: string, now: Date): boolean { | |
| 190 | const had = db | |
| 191 | .query<{ n: number }, [string, string]>("SELECT COUNT(*) AS n FROM week_retro WHERE repo_id = ?1 AND week_end = ?2") | |
| 192 | .get(repo.id, weekEnd); | |
| 193 | if (had !== null && had.n > 0) return false; | |
| 194 | const from = new Date(Date.parse(weekEnd) - WEEK_MS).toISOString(); | |
| 195 | const stored = db | |
| 196 | .query<{ body: string }, [string, string]>("SELECT body FROM week_projection WHERE repo_id = ?1 AND week_end = ?2") | |
| 197 | .get(repo.id, weekEnd); | |
| 198 | const projected = stored === null ? null : (JSON.parse(stored.body) as Projected); | |
| 199 | const retro = composeRetro(weekEnd, windowShipped(db, repo.id, from, weekEnd), windowSpend(db, repo.id, from, weekEnd), projected); | |
| 200 | db.query("INSERT OR IGNORE INTO week_retro (repo_id, week_end, body, at) VALUES (?1, ?2, ?3, ?4)").run( | |
| 201 | repo.id, | |
| 202 | weekEnd, | |
| 203 | JSON.stringify(retro), | |
| 204 | now.toISOString(), | |
| 205 | ); | |
| 206 | return true; | |
| 207 | } | |
| 208 | ||
| 209 | /** The latest retrospective of a repository, or undefined. */ | |
| 210 | export function latestRetro(db: Database, repoId: string): Retro | undefined { | |
| 211 | const row = db | |
| 212 | .query<{ body: string }, [string]>("SELECT body FROM week_retro WHERE repo_id = ?1 ORDER BY week_end DESC LIMIT 1") | |
| 213 | .get(repoId); | |
| 214 | return row === null ? undefined : (JSON.parse(row.body) as Retro); | |
| 215 | } | |
| 216 | ||
| 217 | /** Which proposals of a retrospective have been taken. */ | |
| 218 | export function takenOf(db: Database, repoId: string, weekEnd: string): Set<number> { | |
| 219 | return new Set( | |
| 220 | db | |
| 221 | .query<{ idx: number }, [string, string]>("SELECT idx FROM week_retro_taken WHERE repo_id = ?1 AND week_end = ?2") | |
| 222 | .all(repoId, weekEnd) | |
| 223 | .map((r) => r.idx), | |
| 224 | ); | |
| 225 | } | |
| 226 | ||
| 227 | /** | |
| 228 | * Each hour: close the retrospective of every window that has ended, then keep | |
| 229 | * the projection of the window that is open. Only repositories with a ranked | |
| 230 | * list take part. Safe to call as often as you like. | |
| 231 | */ | |
| 232 | export function tickWeeks(db: Database, repos: readonly Repo[], now: Date): Repo[] { | |
| 233 | const wrote: Repo[] = []; | |
| 234 | for (const repo of repos) { | |
| 235 | const due = db | |
| 236 | .query<{ week_end: string }, [string, string]>( | |
| 237 | `SELECT p.week_end FROM week_projection p | |
| 238 | WHERE p.repo_id = ?1 AND p.week_end <= ?2 | |
| 239 | AND NOT EXISTS (SELECT 1 FROM week_retro r WHERE r.repo_id = p.repo_id AND r.week_end = p.week_end)`, | |
| 240 | ) | |
| 241 | .all(repo.id, now.toISOString()); | |
| 242 | for (const d of due) if (writeRetro(db, repo, d.week_end, now)) wrote.push(repo); | |
| 243 | const open = projectNow(db, repo, now); | |
| 244 | if (open !== null) | |
| 245 | db.query("INSERT OR IGNORE INTO week_projection (repo_id, week_end, body, at) VALUES (?1, ?2, ?3, ?4)").run( | |
| 246 | repo.id, | |
| 247 | open.weekEnd, | |
| 248 | JSON.stringify(open.projected), | |
| 249 | now.toISOString(), | |
| 250 | ); | |
| 251 | } | |
| 252 | return wrote; | |
| 253 | } | |
| 254 | ||
| 255 | /** Repositories that have a ranked list: the ones whose week is planned. */ | |
| 256 | export function plannedRepoIds(db: Database): string[] { | |
| 257 | return db | |
| 258 | .query<{ repo_id: string }, []>( | |
| 259 | `SELECT DISTINCT i.repo_id FROM issue_rank k JOIN issue i ON i.id = k.issue_id WHERE i.state = 'open'`, | |
| 260 | ) | |
| 261 | .all() | |
| 262 | .map((r) => r.repo_id); | |
| 263 | } |
src/flow/week.ts added +127
| @@ -0,0 +1,127 @@ | ||
| 1 | /** | |
| 2 | * The week is the sprint (#8704): the subscription's weekly reset is the | |
| 3 | * boundary and what the crew may still spend before it is the capacity. The | |
| 4 | * cut line falls on the owner's ranked list after the last issue whose | |
| 5 | * running estimate still fits, building strictly from the top, so reranking | |
| 6 | * moves it. Same list, same history, same capacity: same line. No agent run | |
| 7 | * is spent. `cutLine` is plain data in and out; `weekCapacity` is the one read. | |
| 8 | */ | |
| 9 | ||
| 10 | import type { Database } from "bun:sqlite"; | |
| 11 | import type { NextRow } from "../core/next_up.ts"; | |
| 12 | import { standings } from "../core/standing.ts"; | |
| 13 | import { listCredentials } from "../core/subscriptions.ts"; | |
| 14 | import { estimate, issueHistory, kindOf, labelsOf, MIN_SAMPLES, SURE_SAMPLES, sizeOf, type Estimate, type Sample } from "./estimate.ts"; | |
| 15 | ||
| 16 | export interface Capacity { | |
| 17 | /** What the crew may still spend before the reset, in US dollars. */ | |
| 18 | readonly usd: number; | |
| 19 | /** The soonest weekly reset among the accounts that report one, ISO. */ | |
| 20 | readonly resetsAt: string | null; | |
| 21 | } | |
| 22 | ||
| 23 | /** The crew's remaining weekly capacity over every account whose cap is known; null when none is. */ | |
| 24 | export function weekCapacity(db: Database, now: Date): Capacity | null { | |
| 25 | const ids = listCredentials(db).map((c) => c.id); | |
| 26 | let usd = 0; | |
| 27 | let resetsAt: string | null = null; | |
| 28 | let any = false; | |
| 29 | for (const s of standings(db, ids, now).values()) { | |
| 30 | const week = s.windows.find((w) => w.window === "week"); | |
| 31 | if (week?.crewRemaining == null) continue; | |
| 32 | any = true; | |
| 33 | usd += week.crewRemaining.value; | |
| 34 | const at = week.resetsAt?.value ?? null; | |
| 35 | if (at !== null && (resetsAt === null || at < resetsAt)) resetsAt = at; | |
| 36 | } | |
| 37 | return any ? { usd: Math.round(usd * 100) / 100, resetsAt } : null; | |
| 38 | } | |
| 39 | ||
| 40 | export interface Priced { | |
| 41 | readonly number: number; | |
| 42 | readonly estimate: Estimate; | |
| 43 | } | |
| 44 | ||
| 45 | export type Confidence = "high" | "medium" | "low"; | |
| 46 | ||
| 47 | export interface Cut { | |
| 48 | /** Issue numbers that fit this week, in list order; the rest read as next week. */ | |
| 49 | readonly fits: readonly number[]; | |
| 50 | /** The least and most that should ship by the reset, from the range's two ends. */ | |
| 51 | readonly shipLow: number; | |
| 52 | readonly shipHigh: number; | |
| 53 | readonly confidence: Confidence; | |
| 54 | /** Why there is no line: capacity or history missing. Null when drawn. */ | |
| 55 | readonly unsure: string | null; | |
| 56 | } | |
| 57 | ||
| 58 | /** Walk the list from the top, adding each row's cost while it fits `usd`; stop at the first that does not. */ | |
| 59 | function fitting(rows: readonly Priced[], cost: (e: Estimate & { known: true }) => number, fill: number, usd: number): number[] { | |
| 60 | const out: number[] = []; | |
| 61 | let used = 0; | |
| 62 | for (const r of rows) { | |
| 63 | used += r.estimate.known ? cost(r.estimate) : fill; | |
| 64 | if (used > usd) break; | |
| 65 | out.push(r.number); | |
| 66 | } | |
| 67 | return out; | |
| 68 | } | |
| 69 | ||
| 70 | /** | |
| 71 | * Where the week's capacity ends on the ranked list. An issue with no | |
| 72 | * estimate counts as the median of those that have one; with none at all | |
| 73 | * there is no line, and it says so. | |
| 74 | */ | |
| 75 | export function cutLine(rows: readonly Priced[], capacity: Capacity | null): Cut { | |
| 76 | const none = (unsure: string): Cut => ({ fits: [], shipLow: 0, shipHigh: 0, confidence: "low", unsure }); | |
| 77 | if (capacity === null) return none("the weekly capacity has not been read yet"); | |
| 78 | const known = rows.flatMap((r) => (r.estimate.known ? [r.estimate] : [])); | |
| 79 | if (known.length === 0) return none("there is too little history to estimate any issue"); | |
| 80 | const mids = known.map((e) => e.mid).sort((a, b) => a - b); | |
| 81 | const fill = mids[Math.floor((mids.length - 1) / 2)]!; | |
| 82 | const fits = fitting(rows, (e) => e.mid, fill, capacity.usd); | |
| 83 | const least = fitting(rows, (e) => e.high, fill, capacity.usd).length; | |
| 84 | const most = fitting(rows, (e) => e.low, fill, capacity.usd).length; | |
| 85 | const weakest = Math.min(...rows.map((r) => r.estimate.samples)); | |
| 86 | const confidence: Confidence = | |
| 87 | known.length < rows.length ? "low" : weakest >= SURE_SAMPLES ? "high" : weakest >= MIN_SAMPLES ? "medium" : "low"; | |
| 88 | return { fits, shipLow: least, shipHigh: most, confidence, unsure: null }; | |
| 89 | } | |
| 90 | ||
| 91 | /** The projection in one sentence. */ | |
| 92 | export function projection(cut: Cut, total: number, capacity: Capacity | null): string { | |
| 93 | if (cut.unsure !== null) return `No projection yet: ${cut.unsure}.`; | |
| 94 | const by = capacity?.resetsAt == null ? "the reset" : `the reset on ${capacity.resetsAt.slice(0, 10)}`; | |
| 95 | const count = cut.shipLow === cut.shipHigh ? `${cut.shipHigh}` : `${cut.shipLow} to ${cut.shipHigh}`; | |
| 96 | return `At the current rate ${count} of the ${total} issues on the list should ship by ${by}; ${cut.confidence} confidence.`; | |
| 97 | } | |
| 98 | ||
| 99 | /** Price each row of the list from the repository's history. */ | |
| 100 | export function priceRows(db: Database, repoId: string, rows: readonly NextRow[], history?: readonly Sample[]): Priced[] { | |
| 101 | const past = history ?? issueHistory(db, repoId); | |
| 102 | const labels = labelsOf(db, rows.map((r) => r.issue.id)); | |
| 103 | return rows.map((r) => ({ | |
| 104 | number: r.issue.number, | |
| 105 | estimate: estimate(past, sizeOf(r.issue), kindOf(labels.get(r.issue.id) ?? [])), | |
| 106 | })); | |
| 107 | } | |
| 108 | ||
| 109 | /** "$1.20–$3.40" or why there is none. */ | |
| 110 | export function rangeWords(e: Estimate): string { | |
| 111 | return e.known ? `$${e.low.toFixed(2)}–$${e.high.toFixed(2)} (${e.basis})` : e.basis; | |
| 112 | } | |
| 113 | ||
| 114 | /** Everything the page, the terminal and MCP draw of the week on Next up, from one read. */ | |
| 115 | export interface WeekView { | |
| 116 | readonly priced: readonly Priced[]; | |
| 117 | readonly cut: Cut; | |
| 118 | readonly capacity: Capacity | null; | |
| 119 | readonly sentence: string; | |
| 120 | } | |
| 121 | ||
| 122 | export function weekView(db: Database, repoId: string, rows: readonly NextRow[], now: Date): WeekView { | |
| 123 | const capacity = weekCapacity(db, now); | |
| 124 | const priced = priceRows(db, repoId, rows); | |
| 125 | const cut = cutLine(priced, capacity); | |
| 126 | return { priced, cut, capacity, sentence: projection(cut, rows.length, capacity) }; | |
| 127 | } |
src/http/mcp/tools/index.ts +1
| @@ -174,5 +174,6 @@ export { tool as vision } from "./vision.ts"; | ||
| 174 | 174 | export { tool as wait_for } from "./wait_for.ts"; |
| 175 | 175 | export { tool as watch } from "./watch.ts"; |
| 176 | 176 | export { tool as waves } from "./waves.ts"; |
| 177 | export { tool as week } from "./week.ts"; | |
| 177 | 178 | export { tool as whoami_can } from "./whoami_can.ts"; |
| 178 | 179 | export { tool as write_file } from "./write_file.ts"; |
src/http/mcp/tools/next_up.ts +13 −4
| @@ -1,9 +1,16 @@ | ||
| 1 | 1 | import { placeOf, rankIssue } from "../../../actions/next.ts"; |
| 2 | 2 | import { nextUp, TOP, type NextRow } from "../../../core/next_up.ts"; |
| 3 | import { weekView, type WeekView } from "../../../flow/week.ts"; | |
| 3 | 4 | import { sealedScope } from "../../../core/sealed.ts"; |
| 4 | 5 | import { bad, good, type Tool, APP_ARG, planRepo, isAnswer, issueArg } from "../shared.ts"; |
| 5 | 6 | |
| 6 | const brief = (r: NextRow) => ({ | |
| 7 | const brief = (r: NextRow, week?: WeekView) => ({ | |
| 8 | ...(week === undefined | |
| 9 | ? {} | |
| 10 | : { | |
| 11 | estimate: week.priced.find((p) => p.number === r.issue.number)?.estimate, | |
| 12 | thisWeek: week.cut.fits.includes(r.issue.number), | |
| 13 | }), | |
| 7 | 14 | issue: r.issue.number, |
| 8 | 15 | title: r.issue.title, |
| 9 | 16 | place: r.place, |
| @@ -15,7 +22,7 @@ const brief = (r: NextRow) => ({ | ||
| 15 | 22 | |
| 16 | 23 | export const tool: Tool = { |
| 17 | 24 | name: "next_up", |
| 18 | doc: "an app's one ranked list, the crew builds strictly from the top (`app`; with `issue` and `to` — `top`, `not-now` or `after:<number>` — a writer moves that issue and it saves at once; `top` lifts a gated issue past the Later gate; `all` lists beyond the top ten)", | |
| 25 | doc: "an app's one ranked list, the crew builds strictly from the top (`app`; with `issue` and `to` — `top`, `not-now` or `after:<number>` — a writer moves that issue and it saves at once; `top` lifts a gated issue past the Later gate; `all` lists beyond the top ten; each row carries its cost `estimate` range and `thisWeek`, and `projection` is one sentence on what the week can ship)", | |
| 19 | 26 | description: "The ranked list the crew builds from; `issue` + `to` (top, not-now, after:N) moves one.", |
| 20 | 27 | inputSchema: { |
| 21 | 28 | type: "object", |
| @@ -43,11 +50,13 @@ export const tool: Tool = { | ||
| 43 | 50 | } |
| 44 | 51 | const next = nextUp(ctx.app.db, repo, sealedScope(ctx.app.db, repo, user)); |
| 45 | 52 | const all = args["all"] === true; |
| 53 | const week = weekView(ctx.app.db, repo.id, next.rows, new Date()); | |
| 46 | 54 | return good({ |
| 47 | next: (all ? next.rows : next.rows.slice(0, TOP)).map(brief), | |
| 55 | projection: week.sentence, | |
| 56 | next: (all ? next.rows : next.rows.slice(0, TOP)).map((r) => brief(r, week)), | |
| 48 | 57 | more: all ? 0 : Math.max(0, next.rows.length - TOP), |
| 49 | 58 | notNow: next.notNowTotal, |
| 50 | ...(all ? { notNowRows: next.notNow.map(brief) } : {}), | |
| 59 | ...(all ? { notNowRows: next.notNow.map((r) => brief(r)) } : {}), | |
| 51 | 60 | }); |
| 52 | 61 | }, |
| 53 | 62 | }; |
src/http/mcp/tools/week.ts added +30
| @@ -0,0 +1,30 @@ | ||
| 1 | import { takeProposal } from "../../../actions/week.ts"; | |
| 2 | import { latestRetro, takenOf } from "../../../flow/retro.ts"; | |
| 3 | import { bad, good, type Tool, APP_ARG, planRepo, isAnswer } from "../shared.ts"; | |
| 4 | ||
| 5 | export const tool: Tool = { | |
| 6 | 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 | description: "The week's retrospective; `take` accepts one proposal.", | |
| 9 | inputSchema: { | |
| 10 | type: "object", | |
| 11 | properties: { app: APP_ARG, take: { type: "integer", minimum: 0, maximum: 2 } }, | |
| 12 | required: ["app"], | |
| 13 | }, | |
| 14 | writes: true, | |
| 15 | readsWithout: "take", | |
| 16 | run(ctx, user, args) { | |
| 17 | const repo = planRepo(ctx, user, args); | |
| 18 | if (isAnswer(repo)) return repo; | |
| 19 | const retro = latestRetro(ctx.app.db, repo.id); | |
| 20 | if (retro === undefined) return good({ says: "No weekly window has closed yet." }); | |
| 21 | if (args["take"] !== undefined) { | |
| 22 | const idx = args["take"]; | |
| 23 | if (typeof idx !== "number" || !Number.isInteger(idx) || idx < 0 || idx > 2) return bad("invalid_take", "take is a proposal's index, 0 to 2"); | |
| 24 | const took = takeProposal(ctx.app, user, repo, retro.weekEnd, idx); | |
| 25 | if (!took.ok) return bad(took.error.kind === "not_found" ? "no_such_proposal" : took.error.kind, took.error.message); | |
| 26 | } | |
| 27 | 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) })) }); | |
| 29 | }, | |
| 30 | }; |
src/http/server.ts +3
| @@ -201,6 +201,7 @@ import { globalMapPage } from "../web/pages/global-map.ts"; | ||
| 201 | 201 | import { mapPage } from "../web/pages/map.ts"; |
| 202 | 202 | import { firstSubmit } from "../web/pages/first.ts"; |
| 203 | 203 | import { nextPage, nextSubmit } from "../web/pages/next.ts"; |
| 204 | import { weekPage, weekSubmit } from "../web/pages/week.ts"; | |
| 204 | 205 | import { |
| 205 | 206 | milestonePage, |
| 206 | 207 | milestonesPage, |
| @@ -1033,6 +1034,8 @@ export function buildRouter(): Router<Handler> { | ||
| 1033 | 1034 | router.add("GET", "/:owner/:name/map", mapPage); |
| 1034 | 1035 | router.add("GET", "/:owner/:name/next", nextPage); |
| 1035 | 1036 | router.add("POST", "/:owner/:name/next", nextSubmit); |
| 1037 | router.add("GET", "/:owner/:name/week", weekPage); | |
| 1038 | router.add("POST", "/:owner/:name/week", weekSubmit); | |
| 1036 | 1039 | router.add("POST", "/:owner/:name/issues/:number/first", firstSubmit); |
| 1037 | 1040 | router.add("GET", "/:owner/:name/bets", betsPage); |
| 1038 | 1041 | router.add("POST", "/:owner/:name/bets/propose", betPropose); |
src/main.ts +13
| @@ -5,6 +5,8 @@ | ||
| 5 | 5 | */ |
| 6 | 6 | |
| 7 | 7 | import { runModelReview } from "./actions/model_review.ts"; |
| 8 | import { getRepoById } from "./core/repos.ts"; | |
| 9 | import { plannedRepoIds, tickWeeks } from "./flow/retro.ts"; | |
| 8 | 10 | import { BOOT_ACTOR, setSignup, signupFromEnv } from "./settings/signup.ts"; |
| 9 | 11 | import { startMemorySampler } from "./boards/memory.ts"; |
| 10 | 12 | import { startSampler } from "./boards/sampler.ts"; |
| @@ -640,6 +642,16 @@ const modelReviewTimer = setInterval(() => { | ||
| 640 | 642 | app.log.warn("model review failed", { error: String(error) }); |
| 641 | 643 | } |
| 642 | 644 | }, 3_600_000); |
| 645 | // The week is the sprint (#8704): at each weekly reset the forge writes its retrospective, unasked. | |
| 646 | const weekTimer = setInterval(() => { | |
| 647 | try { | |
| 648 | const repos = plannedRepoIds(app.db).flatMap((id) => getRepoById(app.db, id) ?? []); | |
| 649 | for (const repo of tickWeeks(app.db, repos, new Date())) | |
| 650 | app.bus.append({ kind: "week.retro", repoId: repo.id, payload: {} }); | |
| 651 | } catch (error) { | |
| 652 | app.log.warn("week retrospective failed", { error: String(error) }); | |
| 653 | } | |
| 654 | }, 3_600_000); | |
| 643 | 655 | const processTimer = setInterval(processTick, 60_000); |
| 644 | 656 | processTick(); |
| 645 | 657 | // A new version proves itself against the public URL; a failed one again, 5 min after and backing off to hourly (release/prove.ts). |
| @@ -791,6 +803,7 @@ function drain(signal: NodeJS.Signals): Promise<unknown> { | ||
| 791 | 803 | clearInterval(processTimer); |
| 792 | 804 | clearInterval(holdTimer); |
| 793 | 805 | clearInterval(modelReviewTimer); |
| 806 | clearInterval(weekTimer); | |
| 794 | 807 | clearInterval(leaseTimer); |
| 795 | 808 | // The platform's SIGTERM: the listener stays open while routers learn |
| 796 | 809 | // this process is going. A person's Ctrl-C does not wait for routers. |
src/tui/forge.ts +7
| @@ -78,6 +78,7 @@ import { stalledScreen } from "./screens/stalled.ts"; | ||
| 78 | 78 | import { issuesScreen, newIssueScreen } from "./screens/issues.ts"; |
| 79 | 79 | import { limbsScreen } from "./screens/limbs.ts"; |
| 80 | 80 | import { nextScreen } from "./screens/next.ts"; |
| 81 | import { weekScreen } from "./screens/week.ts"; | |
| 81 | 82 | import { newPullScreen } from "./screens/newpull.ts"; |
| 82 | 83 | import { newForgeScreen } from "./screens/newforge.ts"; |
| 83 | 84 | import { newOrgScreen } from "./screens/neworg.ts"; |
| @@ -133,6 +134,12 @@ const REPO_VERBS: readonly { | ||
| 133 | 134 | title: "Next up in a repository", |
| 134 | 135 | open: (f, ctx, repo) => nextScreen(f, ctx, repo), |
| 135 | 136 | }, |
| 137 | { | |
| 138 | // The retrospective the forge wrote at the last weekly reset, and its proposals (#8704). | |
| 139 | id: "week", | |
| 140 | title: "The week in a repository", | |
| 141 | open: (f, ctx, repo) => weekScreen(f, ctx, repo), | |
| 142 | }, | |
| 136 | 143 | { |
| 137 | 144 | // What the crew's tokens bought on one app, as MCP `returns` with `repo` (#7271). |
| 138 | 145 | id: "repo-usage", |
src/tui/screens/next.ts +19 −6
| @@ -15,6 +15,7 @@ import { rankedWords, rankIssue, stepTo, type Place, placeOf } from "../../actio | ||
| 15 | 15 | import { nextUp, type NextRow } from "../../core/next_up.ts"; |
| 16 | 16 | import { canRead, canWrite, type Repo } from "../../core/repos.ts"; |
| 17 | 17 | import { sealedScope } from "../../core/sealed.ts"; |
| 18 | import { rangeWords, weekView, type WeekView } from "../../flow/week.ts"; | |
| 18 | 19 | import type { Ctx, Screen } from "../app/app.ts"; |
| 19 | 20 | import { binding } from "../app/keys.ts"; |
| 20 | 21 | import { fill, len, split } from "../ui/layout.ts"; |
| @@ -32,6 +33,7 @@ export function nextScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 32 | 33 | const state = new ListState(); |
| 33 | 34 | let rows: readonly NextRow[] = []; |
| 34 | 35 | let away = 0; |
| 36 | let week: WeekView | undefined; | |
| 35 | 37 | let loaded = false; |
| 36 | 38 | // Read at use, not at open: access can change under a session. |
| 37 | 39 | const may = (): boolean => |
| @@ -41,10 +43,12 @@ export function nextScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 41 | 43 | if (!canRead(f.db, repo, f.user)) { |
| 42 | 44 | rows = []; |
| 43 | 45 | away = 0; |
| 46 | week = undefined; | |
| 44 | 47 | } else { |
| 45 | 48 | const next = nextUp(f.db, repo, sealedScope(f.db, repo, f.user)); |
| 46 | 49 | rows = next.rows; |
| 47 | 50 | away = next.notNowTotal; |
| 51 | week = weekView(f.db, repo.id, rows, new Date()); | |
| 48 | 52 | } |
| 49 | 53 | loaded = true; |
| 50 | 54 | if (follow !== undefined) { |
| @@ -78,12 +82,21 @@ export function nextScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 78 | 82 | if (row !== undefined) ctx.push(issueScreen(f, ctx, local(f, repo), row.issue.number)); |
| 79 | 83 | }; |
| 80 | 84 | |
| 85 | // The cut line is a muted line under the last row that fits, as on the page. | |
| 81 | 86 | const listRows = (): ListRow[] => |
| 82 | rows.map((r) => ({ | |
| 83 | title: `${r.place === null ? "·" : r.place}. #${r.issue.number} ${r.issue.title}`, | |
| 84 | secondary: r.role === "blocker" ? "Blocker" : "Enabler", | |
| 85 | meta: [r.onIt === null ? "" : `on it: ${r.onIt}`, r.why ?? ""].filter((x) => x !== "").join(" · "), | |
| 86 | })); | |
| 87 | rows.map((r, at) => { | |
| 88 | const estimate = week?.priced.find((p) => p.number === r.issue.number)?.estimate; | |
| 89 | const cut = week?.cut; | |
| 90 | const ends = cut !== undefined && cut.unsure === null && cut.fits.length > 0 && at === cut.fits.length - 1; | |
| 91 | return { | |
| 92 | title: `${r.place === null ? "·" : r.place}. #${r.issue.number} ${r.issue.title}`, | |
| 93 | secondary: r.role === "blocker" ? "Blocker" : "Enabler", | |
| 94 | meta: [estimate === undefined ? "" : rangeWords(estimate), r.onIt === null ? "" : `on it: ${r.onIt}`, r.why ?? ""] | |
| 95 | .filter((x) => x !== "") | |
| 96 | .join(" · "), | |
| 97 | ...(ends ? { note: `── this week ends here (${cut.fits.length} fit): below reads as next week ──` } : {}), | |
| 98 | }; | |
| 99 | }); | |
| 87 | 100 | |
| 88 | 101 | const screen: Screen = { |
| 89 | 102 | title: "Next up", |
| @@ -110,7 +123,7 @@ export function nextScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 110 | 123 | ? "Loading…" |
| 111 | 124 | : rows.length === 0 |
| 112 | 125 | ? "Nothing is ready: everything open waits for something." |
| 113 | : `The crew builds strictly from the top${may() ? "" : " · you can read this order, not change it"}${away > 0 ? ` · ${away} not now` : ""}`; | |
| 126 | : `${week?.sentence ?? ""}${may() ? "" : " · you can read this order, not change it"}${away > 0 ? ` · ${away} not now` : ""}`; | |
| 114 | 127 | s.text(head!.x, head!.y, truncate(note, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w); |
| 115 | 128 | drawList(s, list!, c, state, listRows()); |
| 116 | 129 | hitList(ctx, list!, state, rows.length, { open }); |
src/tui/screens/week.ts added +114
| @@ -0,0 +1,114 @@ | ||
| 1 | /** | |
| 2 | * The week in the terminal (#8704): the retrospective the forge wrote at the | |
| 3 | * last weekly reset, the page's door and not a second implementation. Its | |
| 4 | * proposals are the list; `enter` or `a` takes the one under the cursor | |
| 5 | * through `takeProposal`, the action the page and MCP `week` reach, so who may | |
| 6 | * take one is decided there: a reader or an agent sees the proposals and the | |
| 7 | * key does nothing. Subscribed to the bus while on the stack, so a new | |
| 8 | * retrospective (`week.retro`) or a proposal taken anywhere (`issue.ranked`) | |
| 9 | * updates the screen here. | |
| 10 | */ | |
| 11 | ||
| 12 | import { takeProposal } from "../../actions/week.ts"; | |
| 13 | import { canRead, canWrite, type Repo } from "../../core/repos.ts"; | |
| 14 | import { latestRetro, takenOf, type Retro } from "../../flow/retro.ts"; | |
| 15 | import type { Ctx, Screen } from "../app/app.ts"; | |
| 16 | import { binding } from "../app/keys.ts"; | |
| 17 | import { fill, len, split } from "../ui/layout.ts"; | |
| 18 | import { style } from "../term/style.ts"; | |
| 19 | import { truncate } from "../term/width.ts"; | |
| 20 | import type { Session } from "../session.ts"; | |
| 21 | import { ListState, drawList, hitList, live, type ListRow } from "./common.ts"; | |
| 22 | ||
| 23 | const LIVE_KINDS = ["week.retro", "issue.ranked"]; | |
| 24 | ||
| 25 | /** The retrospective's screen of facts, one line each. */ | |
| 26 | export function summaryLines(retro: Retro): string[] { | |
| 27 | const p = retro.projected; | |
| 28 | return [ | |
| 29 | `Week ending ${retro.weekEnd.slice(0, 10)}`, | |
| 30 | p === null | |
| 31 | ? `${retro.shipped.length} shipped; no projection was made.` | |
| 32 | : `${retro.shipped.length} shipped against a projection of ${p.shipLow === p.shipHigh ? p.shipHigh : `${p.shipLow} to ${p.shipHigh}`} (${p.confidence} confidence).`, | |
| 33 | retro.accuracy.compared === 0 | |
| 34 | ? "No shipped issue had an estimate to compare." | |
| 35 | : `${retro.accuracy.inside} of ${retro.accuracy.compared} shipped issues cost inside their range.`, | |
| 36 | `Cost most, still open: ${retro.costly.length === 0 ? "none" : retro.costly.map((l) => `#${l.number}`).join(", ")}`, | |
| 37 | `Stuck: ${retro.stuck.length === 0 ? "none" : retro.stuck.map((l) => `#${l.number} (${l.why})`).join("; ")}`, | |
| 38 | ]; | |
| 39 | } | |
| 40 | ||
| 41 | export function weekScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | |
| 42 | const state = new ListState(); | |
| 43 | let retro: Retro | undefined; | |
| 44 | let taken: ReadonlySet<number> = new Set(); | |
| 45 | let loaded = false; | |
| 46 | // Read at use, not at open: access can change under a session. | |
| 47 | const may = (): boolean => f.user !== undefined && f.user.kind === "user" && canWrite(f.db, repo, f.user); | |
| 48 | ||
| 49 | const reload = (): void => { | |
| 50 | if (!canRead(f.db, repo, f.user)) { | |
| 51 | retro = undefined; | |
| 52 | taken = new Set(); | |
| 53 | } else { | |
| 54 | retro = latestRetro(f.db, repo.id); | |
| 55 | taken = retro === undefined ? new Set() : takenOf(f.db, repo.id, retro.weekEnd); | |
| 56 | } | |
| 57 | loaded = true; | |
| 58 | state.clamp(retro?.proposals.length ?? 0); | |
| 59 | ctx.invalidate(); | |
| 60 | }; | |
| 61 | ||
| 62 | const take = (): void => { | |
| 63 | if (retro === undefined || !may() || taken.has(state.index)) return; | |
| 64 | const done = takeProposal(f.app, f.user!, repo, retro.weekEnd, state.index); | |
| 65 | if (!done.ok) { | |
| 66 | ctx.toast(done.error.message, "danger"); | |
| 67 | return; | |
| 68 | } | |
| 69 | reload(); | |
| 70 | ctx.toast(done.value, "success"); | |
| 71 | }; | |
| 72 | ||
| 73 | const listRows = (): ListRow[] => | |
| 74 | (retro?.proposals ?? []).map((p, i) => ({ | |
| 75 | title: p.words, | |
| 76 | secondary: taken.has(i) ? "Taken" : "Proposed", | |
| 77 | })); | |
| 78 | ||
| 79 | const screen: Screen = { | |
| 80 | title: "The week", | |
| 81 | get keys() { | |
| 82 | const on = may() && retro !== undefined && !taken.has(state.index) && retro.proposals.length > 0; | |
| 83 | return [binding(["j", "k"], "move cursor"), binding(["enter", "a"], "take it", { disabled: !on })]; | |
| 84 | }, | |
| 85 | load(signal) { | |
| 86 | live(f, signal, { repoId: repo.id, kinds: LIVE_KINDS }, () => reload()); | |
| 87 | reload(); | |
| 88 | return Promise.resolve(); | |
| 89 | }, | |
| 90 | render(area, s) { | |
| 91 | const c = ctx.theme.c; | |
| 92 | const lines = !loaded | |
| 93 | ? ["Loading…"] | |
| 94 | : retro === undefined | |
| 95 | ? ["The forge writes this when the weekly window closes. None has closed yet."] | |
| 96 | : summaryLines(retro); | |
| 97 | const [head, list] = split(area, "v", [len(lines.length + 1), fill(1)]); | |
| 98 | lines.forEach((line, i) => | |
| 99 | s.text(head!.x, head!.y + i, truncate(line, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w), | |
| 100 | ); | |
| 101 | drawList(s, list!, c, state, listRows()); | |
| 102 | hitList(ctx, list!, state, retro?.proposals.length ?? 0, { open: take }); | |
| 103 | }, | |
| 104 | key(e) { | |
| 105 | const n = retro?.proposals.length ?? 0; | |
| 106 | if (state.key(e, n)) return true; | |
| 107 | if (e.ctrl || e.alt) return false; | |
| 108 | if (e.key === "enter" || e.key === "a") take(); | |
| 109 | else return false; | |
| 110 | return true; | |
| 111 | }, | |
| 112 | }; | |
| 113 | return screen; | |
| 114 | } |
src/web/pages/next.ts +29 −3
| @@ -11,6 +11,7 @@ import { nextUp, TOP, type NextRow } from "../../core/next_up.ts"; | ||
| 11 | 11 | import { canWrite, type Repo } from "../../core/repos.ts"; |
| 12 | 12 | import { notePhase, takeSessionTime } from "../../core/route_timing.ts"; |
| 13 | 13 | import { sealedScope } from "../../core/sealed.ts"; |
| 14 | import { rangeWords, weekView, type Cut, type Priced } from "../../flow/week.ts"; | |
| 14 | 15 | import type { Ctx } from "../../http/context.ts"; |
| 15 | 16 | import { crumbsFor, loadRepo, notFoundPage, repoBase, repoHeader, repoPalette, signInFirst } from "../chrome.ts"; |
| 16 | 17 | import { backTo, field, readForm } from "../form.ts"; |
| @@ -75,7 +76,15 @@ function move(base: string, number: number, to: string, label: string, key: stri | ||
| 75 | 76 | </form>`; |
| 76 | 77 | } |
| 77 | 78 | |
| 78 | function line(base: string, rows: readonly NextRow[], at: number, writer: boolean, back: string, away: boolean): Html { | |
| 79 | function line( | |
| 80 | base: string, | |
| 81 | rows: readonly NextRow[], | |
| 82 | at: number, | |
| 83 | writer: boolean, | |
| 84 | back: string, | |
| 85 | away: boolean, | |
| 86 | priced: readonly Priced[] = [], | |
| 87 | ): Html { | |
| 79 | 88 | const r = rows[at]!; |
| 80 | 89 | const n = r.issue.number; |
| 81 | 90 | const numbers = rows.map((x) => x.issue.number); |
| @@ -94,13 +103,20 @@ function line(base: string, rows: readonly NextRow[], at: number, writer: boolea | ||
| 94 | 103 | titleAttr: r.issue.title, |
| 95 | 104 | lead: html`${r.place === null ? "·" : String(r.place)}`, |
| 96 | 105 | meta: html`${badge(r.role === "blocker" ? "Blocker" : "Enabler", { variant: r.role === "blocker" ? "secondary" : "outline" })} |
| 97 | #${n} · ${r.roleWhy}${r.onIt === null ? "" : ` · on it: ${r.onIt}`}${r.why === null ? "" : ` · ${r.why}`}`, | |
| 106 | #${n} · ${r.roleWhy}${away ? null : html` · <span class="estimate">${rangeWords(priced.find((p) => p.number === n)?.estimate ?? { known: false, samples: 0, basis: "no estimate" })}</span>`}${r.onIt === null ? "" : ` · on it: ${r.onIt}`}${r.why === null ? "" : ` · ${r.why}`}`, | |
| 98 | 107 | nav: true, |
| 99 | 108 | data: { number: String(n) }, |
| 100 | 109 | ...(controls === null ? {} : { after: controls }), |
| 101 | 110 | }); |
| 102 | 111 | } |
| 103 | 112 | |
| 113 | /** The cut line: everything above it fits this week's capacity, everything below reads as next week. */ | |
| 114 | function cutMark(cut: Cut, top: boolean): Html { | |
| 115 | return html`<li class="cut-line" role="separator">${top | |
| 116 | ? "Nothing here fits what is left of this week: all of this reads as next week." | |
| 117 | : `This week ends here (${cut.fits.length} fit): below this line reads as next week.`}</li>`; | |
| 118 | } | |
| 119 | ||
| 104 | 120 | export function nextPage(ctx: Ctx): Response { |
| 105 | 121 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); |
| 106 | 122 | const repo = loadRepo(ctx); |
| @@ -113,6 +129,15 @@ export function nextPage(ctx: Ctx): Response { | ||
| 113 | 129 | const rest = next.rows.length - shown.length; |
| 114 | 130 | const back = all ? `${base}/next?all=1` : `${base}/next`; |
| 115 | 131 | const rendering = performance.now(); |
| 132 | const { priced, cut, sentence } = weekView(ctx.app.db, repo.id, next.rows, new Date()); | |
| 133 | const rowsHtml = (): Html[] => { | |
| 134 | const out: Html[] = []; | |
| 135 | shown.forEach((_, at) => { | |
| 136 | if (at === cut.fits.length && cut.unsure === null) out.push(cutMark(cut, at === 0)); | |
| 137 | out.push(line(base, next.rows, at, writer, back, false, priced)); | |
| 138 | }); | |
| 139 | return out; | |
| 140 | }; | |
| 116 | 141 | const body = html`${repoHeader(ctx, repo, "issues")} |
| 117 | 142 | <div class="page-head mt"> |
| 118 | 143 | <h1>Next up</h1> |
| @@ -124,12 +149,13 @@ export function nextPage(ctx: Ctx): Response { | ||
| 124 | 149 | Dependencies order work within the list; they are shown, not edited. |
| 125 | 150 | </p> |
| 126 | 151 | </div> |
| 152 | <p class="muted" id="next-week">${sentence} <a href="${base}/week">The week</a></p> | |
| 127 | 153 | <p class="error" id="next-flash" role="alert" hidden></p> |
| 128 | 154 | ${next.rows.length === 0 |
| 129 | 155 | ? html`<p class="muted">Nothing is ready: everything open waits for something. <a href="${base}/map">See the map</a>.</p>` |
| 130 | 156 | : card( |
| 131 | 157 | html`<ul class="list setting-list" id="next-list"> |
| 132 | ${shown.map((_, at) => line(base, next.rows, at, writer, back, false))} | |
| 158 | ${rowsHtml()} | |
| 133 | 159 | </ul>`, |
| 134 | 160 | { pad: false }, |
| 135 | 161 | )} |
src/web/pages/week.ts added +104
| @@ -0,0 +1,104 @@ | ||
| 1 | /** | |
| 2 | * The week (#8704): `/:owner/:name/week`, the retrospective the forge wrote | |
| 3 | * when the last weekly window closed, on one screen. It ends in at most three | |
| 4 | * proposals, each its own form: one tap by someone who writes here takes it, | |
| 5 | * and nothing else on the page changes anything. | |
| 6 | */ | |
| 7 | import { takeProposal } from "../../actions/week.ts"; | |
| 8 | import { canWrite } from "../../core/repos.ts"; | |
| 9 | import { notePhase, takeSessionTime } from "../../core/route_timing.ts"; | |
| 10 | import { latestRetro, takenOf, type Line, type Retro } from "../../flow/retro.ts"; | |
| 11 | import type { Ctx } from "../../http/context.ts"; | |
| 12 | import { crumbsFor, loadRepo, notFoundPage, repoBase, repoHeader, repoPalette, signInFirst } from "../chrome.ts"; | |
| 13 | import { backTo, field, readForm } from "../form.ts"; | |
| 14 | import { html, type Html } from "../html.ts"; | |
| 15 | import { flashOrJson, page } from "../layout.ts"; | |
| 16 | import { btn, card, row } from "../ui.ts"; | |
| 17 | ||
| 18 | const lines = (base: string, ls: readonly Line[], none: string): Html => | |
| 19 | ls.length === 0 | |
| 20 | ? html`<p class="muted">${none}</p>` | |
| 21 | : card( | |
| 22 | html`<ul class="list setting-list">${ls.map((l) => | |
| 23 | row({ href: `${base}/issues/${l.number}`, title: l.title, meta: html`#${String(l.number)} · ${l.why}` }), | |
| 24 | )}</ul>`, | |
| 25 | { pad: false }, | |
| 26 | ); | |
| 27 | ||
| 28 | function screen(base: string, retro: Retro, taken: ReadonlySet<number>, writer: boolean): Html { | |
| 29 | const p = retro.projected; | |
| 30 | const shipped = retro.shipped.length; | |
| 31 | const acc = retro.accuracy; | |
| 32 | return html`<h2>What shipped</h2> | |
| 33 | <p>${p === null | |
| 34 | ? `${shipped} shipped. No projection was made for this week.` | |
| 35 | : `${shipped} shipped against a projection of ${p.shipLow === p.shipHigh ? p.shipHigh : `${p.shipLow} to ${p.shipHigh}`} (${p.confidence} confidence).`}</p> | |
| 36 | ${lines(base, retro.shipped, "Nothing shipped this week.")} | |
| 37 | <h2>Estimate accuracy</h2> | |
| 38 | <p>${acc.compared === 0 | |
| 39 | ? "No shipped issue had an estimate to compare." | |
| 40 | : `${acc.inside} of ${acc.compared} shipped issues cost inside their estimated range.`}</p> | |
| 41 | <h2>Cost most for least</h2> | |
| 42 | ${lines(base, retro.costly, "Nothing open spent money this week.")} | |
| 43 | <h2>Stuck</h2> | |
| 44 | ${lines(base, retro.stuck, "Nothing is stuck.")} | |
| 45 | <h2>Proposed changes</h2> | |
| 46 | ${retro.proposals.length === 0 | |
| 47 | ? html`<p class="muted">Nothing to propose.</p>` | |
| 48 | : html`<ul class="list setting-list">${retro.proposals.map((pr, idx) => | |
| 49 | row({ | |
| 50 | title: html`${pr.words}`, | |
| 51 | meta: html`${taken.has(idx) ? "Taken." : writer ? "Nothing changes until you tap." : "Only someone who writes here can take it."}`, | |
| 52 | ...(taken.has(idx) || !writer | |
| 53 | ? {} | |
| 54 | : { | |
| 55 | after: html`<form method="post" action="${base}/week"> | |
| 56 | <input type="hidden" name="week_end" value="${retro.weekEnd}" /> | |
| 57 | <input type="hidden" name="idx" value="${String(idx)}" /> | |
| 58 | ${btn("Take it", { size: "sm" })} | |
| 59 | </form>`, | |
| 60 | }), | |
| 61 | }), | |
| 62 | )}</ul>`}`; | |
| 63 | } | |
| 64 | ||
| 65 | export function weekPage(ctx: Ctx): Response { | |
| 66 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 67 | const repo = loadRepo(ctx); | |
| 68 | if (!repo) return notFoundPage(ctx); | |
| 69 | const base = repoBase(repo); | |
| 70 | const writer = ctx.auth.user !== undefined && ctx.auth.user.kind !== "agent" && canWrite(ctx.app.db, repo, ctx.auth.user); | |
| 71 | const retro = latestRetro(ctx.app.db, repo.id); | |
| 72 | const rendering = performance.now(); | |
| 73 | const body = html`${repoHeader(ctx, repo, "issues")} | |
| 74 | <div class="page-head mt"> | |
| 75 | <h1>The week</h1> | |
| 76 | <p class="muted">${retro === undefined | |
| 77 | ? "The forge writes this when the weekly window closes. None has closed yet." | |
| 78 | : `Written when the window ended ${retro.weekEnd.slice(0, 10)}, once, and never edited.`} <a href="${base}/next">Next up</a></p> | |
| 79 | </div> | |
| 80 | ${retro === undefined ? null : screen(base, retro, takenOf(ctx.app.db, repo.id, retro.weekEnd), writer)}`; | |
| 81 | const response = page({ ctx, title: `The week · ${repo.name}`, crumbs: crumbsFor(repo), palette: repoPalette(ctx, repo), body }); | |
| 82 | notePhase(ctx.request, "render", performance.now() - rendering); | |
| 83 | return response; | |
| 84 | } | |
| 85 | ||
| 86 | /** POST /:owner/:name/week — `week_end` and `idx`: take one proposal. */ | |
| 87 | export async function weekSubmit(ctx: Ctx): Promise<Response> { | |
| 88 | const repo = loadRepo(ctx); | |
| 89 | if (!repo) return notFoundPage(ctx); | |
| 90 | if (!ctx.auth.user) return signInFirst(ctx); | |
| 91 | const form = await readForm(ctx, "rw"); | |
| 92 | if (form instanceof Response) return form; | |
| 93 | const dest = backTo(form, `${repoBase(repo)}/week`); | |
| 94 | const weekEnd = field(form, "week_end"); | |
| 95 | const idx = /^[0-9]$/.test(field(form, "idx")) ? Number(field(form, "idx")) : -1; | |
| 96 | if (!/^\d{4}-\d\d-\d\dT[\d:.]{8,12}Z$/.test(weekEnd) || idx < 0) | |
| 97 | return flashOrJson(ctx, dest, { tone: "danger", message: "Say which proposal." }); | |
| 98 | const took = takeProposal(ctx.app, ctx.auth.user, repo, weekEnd, idx); | |
| 99 | if (!took.ok) { | |
| 100 | if (took.error.kind === "not_found") return notFoundPage(ctx); | |
| 101 | return flashOrJson(ctx, dest, { tone: "danger", message: took.error.message }); | |
| 102 | } | |
| 103 | return flashOrJson(ctx, dest, { message: took.value }); | |
| 104 | } |
src/web/style/content.ts +3
| @@ -862,6 +862,9 @@ textarea.editor:focus-visible { outline: none; } | ||
| 862 | 862 | .pipe-repo { font-size: 12.5px; fill: var(--muted-fg); } |
| 863 | 863 | .valve-form { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 4px 0; } |
| 864 | 864 | .valve-range { flex: 1 1 12rem; min-width: 8rem; } |
| 865 | /* The week on Next up (#8704): the cut line and a row's cost range. */ | |
| 866 | .cut-line { list-style: none; padding: 6px 16px; font-size: 13px; color: var(--muted-fg); border-top: 2px dashed var(--series-1); border-bottom: 1px solid var(--border); background: var(--bg); } | |
| 867 | .estimate { color: var(--fg); font-variant-numeric: tabular-nums; } | |
| 865 | 868 | /* The config form (#7336): nested objects as quiet fieldsets, scalar |
| 866 | 869 | arrays as a wrapping row of chips, object arrays as cards. */ |
| 867 | 870 | .config-group { border-left: 2px solid var(--border); padding-left: 12px; } |
test/conversation-confirm.test.ts +1 −1
| @@ -124,7 +124,7 @@ describe("the one list of what asks first", () => { | ||
| 124 | 124 | "assign", "attach_look", "bet", "board_pins", "board_write", "branches", "capacity", "valves", "cloud_accounts", "close", "confirm_wait", |
| 125 | 125 | "crew", "decisions_by", "detach", "edit", "email_verify", "feed_chips", "feed_dismiss", "flag_set", "flag_winner", |
| 126 | 126 | "follow_mode", "front_page", "hold", "issue_teams", "keep_plan", "labels", "lift", "limb", "make_it_yours", "map", "merge", "new_app", |
| 127 | "next_up", "notify", "org_create", "origin_apps", "pins", "plan", "profile", "propose_to_parent_forge", "repo_labels", "reports", | |
| 127 | "next_up", "week", "notify", "org_create", "origin_apps", "pins", "plan", "profile", "propose_to_parent_forge", "repo_labels", "reports", | |
| 128 | 128 | "restore_drill", "review", "say", "signup", "snapshot_now", "spend", "steer", "sync", "take_back", "take_updates", |
| 129 | 129 | "teams", "tell", "threads", "trail", "upstream", "variant", "wait_for", "watch", "whoami_can", "write_file", |
| 130 | 130 | ]); |
test/mcp.test.ts +1
| @@ -333,6 +333,7 @@ describe("the forge over MCP", () => { | ||
| 333 | 333 | "repo_labels", |
| 334 | 334 | "wait_for", |
| 335 | 335 | "next_up", |
| 336 | "week", | |
| 336 | 337 | "lift", |
| 337 | 338 | "held", |
| 338 | 339 | "confirm_wait", |
test/palette-roster.test.ts +4
| @@ -54,6 +54,7 @@ test("a site-admin person is offered the whole roster", () => { | ||
| 54 | 54 | ["pulls", "Pull requests in a repository", "owner/name"], |
| 55 | 55 | ["milestones", "Milestones in a repository", "owner/name"], |
| 56 | 56 | ["next-up", "Next up in a repository", "owner/name"], |
| 57 | ["week", "The week in a repository", "owner/name"], | |
| 57 | 58 | ["repo-usage", "Usage of a repository", "owner/name"], |
| 58 | 59 | ["new-issue", "New issue", "owner/name"], |
| 59 | 60 | ["new-pull", "New pull request", "owner/name"], |
| @@ -141,6 +142,7 @@ test("a site-admin agent is offered the roster without the person-only entries", | ||
| 141 | 142 | ["pulls", "Pull requests in a repository", "owner/name"], |
| 142 | 143 | ["milestones", "Milestones in a repository", "owner/name"], |
| 143 | 144 | ["next-up", "Next up in a repository", "owner/name"], |
| 145 | ["week", "The week in a repository", "owner/name"], | |
| 144 | 146 | ["repo-usage", "Usage of a repository", "owner/name"], |
| 145 | 147 | ["new-issue", "New issue", "owner/name"], |
| 146 | 148 | ["new-pull", "New pull request", "owner/name"], |
| @@ -199,6 +201,7 @@ test("a member person is offered the signed-in roster without the admin entries" | ||
| 199 | 201 | ["pulls", "Pull requests in a repository", "owner/name"], |
| 200 | 202 | ["milestones", "Milestones in a repository", "owner/name"], |
| 201 | 203 | ["next-up", "Next up in a repository", "owner/name"], |
| 204 | ["week", "The week in a repository", "owner/name"], | |
| 202 | 205 | ["repo-usage", "Usage of a repository", "owner/name"], |
| 203 | 206 | ["new-issue", "New issue", "owner/name"], |
| 204 | 207 | ["new-pull", "New pull request", "owner/name"], |
| @@ -252,6 +255,7 @@ test("signed out, the palette offers sign-in and the public entries", () => { | ||
| 252 | 255 | ["pulls", "Pull requests in a repository", "owner/name"], |
| 253 | 256 | ["milestones", "Milestones in a repository", "owner/name"], |
| 254 | 257 | ["next-up", "Next up in a repository", "owner/name"], |
| 258 | ["week", "The week in a repository", "owner/name"], | |
| 255 | 259 | ["repo-usage", "Usage of a repository", "owner/name"], |
| 256 | 260 | ["new-issue", "New issue", "owner/name"], |
| 257 | 261 | ["new-pull", "New pull request", "owner/name"], |
test/week-doors.test.ts added +266
| @@ -0,0 +1,266 @@ | ||
| 1 | /** | |
| 2 | * The week's doors (#8704): the page, MCP and the terminal share one | |
| 3 | * `takeProposal`, so a proposal is taken by a person who writes and by no one | |
| 4 | * else, once; the cut line is drawn once capacity is read; and pricing a list | |
| 5 | * or writing a retrospective spends no agent run. | |
| 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 { openIssue, setIssueState } from "../src/actions/issues.ts"; | |
| 12 | import { addToCrew, createAgent } from "../src/actions/crew.ts"; | |
| 13 | import { createRepo } from "../src/actions/repos.ts"; | |
| 14 | import { WEEK_FORBIDDEN, WEEK_PERSON, takeProposal } from "../src/actions/week.ts"; | |
| 15 | import { createApp, type App as Forge } from "../src/app.ts"; | |
| 16 | import { getIssue } from "../src/core/issues.ts"; | |
| 17 | import { nextUp } from "../src/core/next_up.ts"; | |
| 18 | import type { Repo } from "../src/core/repos.ts"; | |
| 19 | import { createRun } from "../src/core/runs.ts"; | |
| 20 | import { noteLimit } from "../src/core/standing.ts"; | |
| 21 | import { addSubscription } from "../src/core/subscriptions.ts"; | |
| 22 | import { createToken } from "../src/core/tokens.ts"; | |
| 23 | import { recordUsage } from "../src/core/usage.ts"; | |
| 24 | import { createUser, getUserByName, type User } from "../src/core/users.ts"; | |
| 25 | import { latestRetro, takenOf, tickWeeks, failureClass } from "../src/flow/retro.ts"; | |
| 26 | import { weekView } from "../src/flow/week.ts"; | |
| 27 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 28 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 29 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 30 | import { quietLogs } from "./helpers/globals.ts"; | |
| 31 | import { settleApp } from "./helpers/tui.ts"; | |
| 32 | ||
| 33 | quietLogs(); | |
| 34 | ||
| 35 | describe("the week's doors", () => { | |
| 36 | let dataDir: string; | |
| 37 | let forge: Forge; | |
| 38 | let servers: Servers; | |
| 39 | let base: string; | |
| 40 | let alice: User; | |
| 41 | let bob: User; | |
| 42 | let bot: User; | |
| 43 | let repo: Repo; | |
| 44 | let aliceCookie = ""; | |
| 45 | let bobCookie = ""; | |
| 46 | let aliceTok = ""; | |
| 47 | let botTok = ""; | |
| 48 | const n: Record<string, number> = {}; | |
| 49 | const day = 86_400_000; | |
| 50 | ||
| 51 | const open = (title: string): number => { | |
| 52 | const o = openIssue(forge, alice, repo, { title }); | |
| 53 | if (!o.ok) throw new Error(String(o.error)); | |
| 54 | return o.value.number; | |
| 55 | }; | |
| 56 | const runRows = (): number => forge.db.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM run").get()!.n; | |
| 57 | const spend = (number: number, usd: number, state: string, at: string, summary = "") => | |
| 58 | forge.db | |
| 59 | .query( | |
| 60 | `INSERT INTO run (id, repo_id, kind, issue_id, state, cost_usd, started_at, summary) | |
| 61 | VALUES (lower(hex(randomblob(8))), ?1, 'agent', ?2, ?3, ?4, ?5, ?6)`, | |
| 62 | ) | |
| 63 | .run(repo.id, getIssue(forge.db, repo.id, number)!.id, state, usd, at, summary); | |
| 64 | const signIn = async (username: string): Promise<string> => { | |
| 65 | const res = await fetch(`${base}/signin`, { | |
| 66 | method: "POST", | |
| 67 | body: new URLSearchParams({ username, password: "a-good-passphrase" }), | |
| 68 | redirect: "manual", | |
| 69 | }); | |
| 70 | return (res.headers.get("set-cookie") ?? "").split(";")[0]!; | |
| 71 | }; | |
| 72 | const mcp = async (token: string, args: unknown) => { | |
| 73 | const res = await fetch(`${servers.url}/mcp`, { | |
| 74 | method: "POST", | |
| 75 | headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${token}` }, | |
| 76 | body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "week", arguments: args } }), | |
| 77 | }); | |
| 78 | const body = (await res.json()) as any; | |
| 79 | const text: string = body.result.content[0].text; | |
| 80 | return body.result.isError === true ? { error: text } : { value: JSON.parse(text) }; | |
| 81 | }; | |
| 82 | const post = (headers: Record<string, string>, idx = 0) => | |
| 83 | fetch(`${base}/alice/plan/week`, { | |
| 84 | method: "POST", | |
| 85 | headers: { accept: "application/json", ...headers }, | |
| 86 | body: new URLSearchParams({ week_end: latestRetro(forge.db, repo.id)!.weekEnd, idx: String(idx) }), | |
| 87 | redirect: "manual", | |
| 88 | }); | |
| 89 | ||
| 90 | beforeAll(async () => { | |
| 91 | dataDir = await mkdtemp(join(tmpdir(), "forge-week-doors-")); | |
| 92 | forge = createApp({ dataDir, port: 0 }); | |
| 93 | servers = await serve(forge); | |
| 94 | base = `http://127.0.0.1:${servers.port}`; | |
| 95 | for (const [username, first] of [["alice", true], ["bob", false]] as const) { | |
| 96 | const made = await createUser(forge.db, { | |
| 97 | origin: "seed", trusted: true, username, email: `${username}@forge.local`, | |
| 98 | password: "a-good-passphrase", adminIfFirst: first, | |
| 99 | }); | |
| 100 | if (!made.ok) throw new Error(made.error); | |
| 101 | } | |
| 102 | alice = getUserByName(forge.db, "alice")!; | |
| 103 | bob = getUserByName(forge.db, "bob")!; | |
| 104 | const made = await createRepo(forge, alice, { name: "plan", visibility: "public" }); | |
| 105 | if (!made.ok) throw new Error(String(made.error)); | |
| 106 | repo = made.value; | |
| 107 | const agent = await createAgent(forge, alice, { username: "bot" }); | |
| 108 | if (!agent.ok) throw new Error(String(agent.error)); | |
| 109 | bot = agent.value.user; | |
| 110 | if (!addToCrew(forge, alice, repo, "bot").ok) throw new Error("crew"); | |
| 111 | botTok = createToken(forge.db, bot.id, "rw", "rw").token; | |
| 112 | aliceTok = createToken(forge.db, alice.id, "rw", "rw").token; | |
| 113 | ||
| 114 | n.gate = open("Later gate: not now"); | |
| 115 | const past = new Date(Date.now() - 3 * day).toISOString(); | |
| 116 | for (const usd of [1, 2, 3, 4, 5]) { | |
| 117 | const done = open(`Past ${usd}`); | |
| 118 | spend(done, usd, "ok", past); | |
| 119 | const r = setIssueState(forge, alice, repo, getIssue(forge.db, repo.id, done)!, "closed"); | |
| 120 | if (!r.ok) throw new Error(String(r.error)); | |
| 121 | } | |
| 122 | n.stuck = open("Stuck one"); | |
| 123 | for (let i = 0; i < 6; i++) open(`Work ${i}`); | |
| 124 | for (let i = 0; i < 3; i++) spend(n.stuck, 3, "failed", new Date(Date.now() - 2 * day).toISOString(), "secret token sk-123 leaked in the check output"); | |
| 125 | ||
| 126 | n.costly = open("Costly one"); | |
| 127 | spend(n.costly, 4, "ok", new Date(Date.now() - 2 * day).toISOString()); | |
| 128 | // Capacity: the provider refused at $20 a week ago; the next window is open and unspent. | |
| 129 | const sub = addSubscription(forge.db, dataDir, { name: "acct", token: "sk-ant-oat01-doors-test-1" }); | |
| 130 | if (!sub.ok) throw new Error(sub.error); | |
| 131 | recordUsage(forge.db, { | |
| 132 | runId: createRun(forge.db, { repoId: repo.id, kind: "agent" }).id, repoId: repo.id, issueId: null, agentId: null, | |
| 133 | subscriptionId: sub.value.id, tokensIn: 1, tokensOut: 1, costUsd: 20, wallMs: 1000, outcome: "ok", | |
| 134 | at: new Date(Date.now() - 8.5 * day).toISOString(), | |
| 135 | }); | |
| 136 | noteLimit(forge.db, sub.value.id, { | |
| 137 | window: "week", resetsAt: new Date(Date.now() - 2 * day).toISOString(), | |
| 138 | observedAt: new Date(Date.now() - 8 * day).toISOString(), said: "weekly limit", | |
| 139 | }); | |
| 140 | aliceCookie = await signIn("alice"); | |
| 141 | bobCookie = await signIn("bob"); | |
| 142 | }, 30_000); | |
| 143 | ||
| 144 | afterAll(async () => { | |
| 145 | await servers.stop(); | |
| 146 | forge.close(); | |
| 147 | await rm(dataDir, { recursive: true, force: true }); | |
| 148 | }); | |
| 149 | ||
| 150 | test("once capacity is read, Next up draws the cut line and the rows below read as next week", async () => { | |
| 151 | const view = weekView(forge.db, repo.id, nextUp(forge.db, repo).rows, new Date()); | |
| 152 | expect(view.cut.unsure).toBeNull(); | |
| 153 | expect(view.cut.fits.length).toBeGreaterThan(0); | |
| 154 | expect(view.cut.fits.length).toBeLessThan(nextUp(forge.db, repo).rows.length); | |
| 155 | const page = await (await fetch(`${base}/alice/plan/next`, { headers: { cookie: aliceCookie } })).text(); | |
| 156 | expect(page.match(/role="separator"/g)).toHaveLength(1); | |
| 157 | expect(page).toContain(`This week ends here (${view.cut.fits.length} fit)`); | |
| 158 | expect(page).toContain("should ship by"); | |
| 159 | // The terminal draws the same line and the same ranges. | |
| 160 | const terminal = headlessTerminal(140, 40); | |
| 161 | const { app } = createForgeApp(forge, { sshUsername: "t", remoteAddress: "127.0.0.1", user: alice }, { terminal, exitHooks: false }); | |
| 162 | app.start(); | |
| 163 | const key = async (...keys: string[]) => { | |
| 164 | for (const k of keys) app.dispatch({ type: "key", key: k, ctrl: k === "ctrl+k" ? false : false, alt: false, shift: false }); | |
| 165 | await settleApp(app); | |
| 166 | }; | |
| 167 | app.dispatch({ type: "key", key: "k", ctrl: true, alt: false, shift: false }); | |
| 168 | await settleApp(app); | |
| 169 | for (const ch of "Next up in a repository") app.dispatch({ type: "key", key: ch, ctrl: false, alt: false, shift: false }); | |
| 170 | await key("enter"); | |
| 171 | for (const ch of "alice/plan") app.dispatch({ type: "key", key: ch, ctrl: false, alt: false, shift: false }); | |
| 172 | await key("enter"); | |
| 173 | expect(terminal && app.screen.toText()).toContain("this week ends here"); | |
| 174 | expect(app.screen.toText()).toContain("$"); | |
| 175 | app.stop(); | |
| 176 | }); | |
| 177 | ||
| 178 | test("pricing, projecting and writing a retrospective spend no agent run", async () => { | |
| 179 | const before = runRows(); | |
| 180 | weekView(forge.db, repo.id, nextUp(forge.db, repo).rows, new Date()); | |
| 181 | await fetch(`${base}/alice/plan/next`, { headers: { cookie: aliceCookie } }); | |
| 182 | // The window that ended two days ago had its projection stored; the tick closes it. | |
| 183 | const open = nextUp(forge.db, repo).rows; | |
| 184 | forge.db | |
| 185 | .query("INSERT INTO week_projection (repo_id, week_end, body, at) VALUES (?1, ?2, ?3, ?4)") | |
| 186 | .run(repo.id, new Date(Date.now() - day).toISOString(), JSON.stringify({ sentence: "s", fits: [], shipLow: 0, shipHigh: 1, confidence: "low", ranges: {} }), new Date(Date.now() - 8 * day).toISOString()); | |
| 187 | expect(open.length).toBeGreaterThan(0); | |
| 188 | expect(tickWeeks(forge.db, [repo], new Date())).toHaveLength(1); | |
| 189 | expect(runRows()).toBe(before); | |
| 190 | }); | |
| 191 | ||
| 192 | test("a stuck reason is a failure class, never the run's own words", () => { | |
| 193 | const retro = latestRetro(forge.db, repo.id)!; | |
| 194 | const stuck = retro.stuck.find((s) => s.number === n.stuck)!; | |
| 195 | expect(stuck.why).toContain("3 failed runs"); | |
| 196 | expect(JSON.stringify(retro)).not.toContain("sk-123"); | |
| 197 | expect(failureClass("the check failed")).toBe("its check failed"); | |
| 198 | expect(failureClass("whatever else")).toBe("ended without finishing"); | |
| 199 | }); | |
| 200 | ||
| 201 | test("a sealed issue's cost does not shape visible ranges", () => { | |
| 202 | const before = weekView(forge.db, repo.id, nextUp(forge.db, repo).rows, new Date()).priced; | |
| 203 | const s = openIssue(forge, alice, repo, { title: "Sealed" }); | |
| 204 | if (!s.ok) throw new Error(String(s.error)); | |
| 205 | spend(s.value.number, 900, "ok", new Date().toISOString()); | |
| 206 | forge.db.query("UPDATE issue SET state = 'closed', close_reason = 'done', closed_at = ?2 WHERE id = ?1").run(s.value.id, new Date().toISOString()); | |
| 207 | forge.db.query("UPDATE issue SET sealed = 1 WHERE id = ?1").run(s.value.id); | |
| 208 | expect(weekView(forge.db, repo.id, nextUp(forge.db, repo).rows, new Date()).priced).toEqual(before); | |
| 209 | }); | |
| 210 | ||
| 211 | test("takeProposal refuses a reader and an agent, with their own words, and takes nothing", () => { | |
| 212 | const retro = latestRetro(forge.db, repo.id)!; | |
| 213 | expect(retro.proposals.length).toBeGreaterThan(0); | |
| 214 | const reader = takeProposal(forge, bob, repo, retro.weekEnd, 0); | |
| 215 | expect(reader.ok).toBe(false); | |
| 216 | if (!reader.ok) expect(reader.error.message).toBe(WEEK_FORBIDDEN); | |
| 217 | const agent = takeProposal(forge, bot, repo, retro.weekEnd, 0); | |
| 218 | expect(agent.ok).toBe(false); | |
| 219 | if (!agent.ok) expect(agent.error.message).toBe(WEEK_PERSON); | |
| 220 | expect(takenOf(forge.db, repo.id, retro.weekEnd).size).toBe(0); | |
| 221 | }); | |
| 222 | ||
| 223 | test("POST /week: no cookie, another origin, a reader and an agent are refused", async () => { | |
| 224 | const same = { origin: base }; | |
| 225 | expect((await post({ ...same })).status).not.toBe(200); | |
| 226 | expect((await post({ cookie: aliceCookie, origin: "https://evil.example" })).status).toBe(403); | |
| 227 | expect((await post({ cookie: bobCookie, ...same })).status).toBe(422); | |
| 228 | expect((await post({ authorization: `Bearer ${botTok}`, ...same })).status).toBe(422); | |
| 229 | expect(takenOf(forge.db, repo.id, latestRetro(forge.db, repo.id)!.weekEnd).size).toBe(0); | |
| 230 | }); | |
| 231 | ||
| 232 | test("MCP week: an agent is refused; a person takes a proposal once", async () => { | |
| 233 | const refused = await mcp(botTok, { app: "alice/plan", take: 0 }); | |
| 234 | expect(refused.error).toContain("forbidden"); | |
| 235 | const read = await mcp(botTok, { app: "alice/plan" }); | |
| 236 | expect(read.value.proposals[0].taken).toBe(false); | |
| 237 | const took = await mcp(aliceTok, { app: "alice/plan", take: 0 }); | |
| 238 | expect(took.value.proposals[0].taken).toBe(true); | |
| 239 | expect((await mcp(aliceTok, { app: "alice/plan", take: 0 })).error).toContain("already taken"); | |
| 240 | expect(nextUp(forge.db, repo).rows.map((r) => r.issue.number)).not.toContain(latestRetro(forge.db, repo.id)!.proposals[0]!.number); | |
| 241 | }); | |
| 242 | ||
| 243 | test("the terminal shows the retrospective and a reader's key takes nothing", async () => { | |
| 244 | const heard = latestRetro(forge.db, repo.id)!; | |
| 245 | expect(heard.proposals.length).toBeGreaterThan(1); | |
| 246 | const terminal = headlessTerminal(140, 40); | |
| 247 | const { app } = createForgeApp(forge, { sshUsername: "t", remoteAddress: "127.0.0.1", user: bob }, { terminal, exitHooks: false }); | |
| 248 | app.start(); | |
| 249 | const send = (key: string, ctrl = false) => app.dispatch({ type: "key", key, ctrl, alt: false, shift: false }); | |
| 250 | await settleApp(app); | |
| 251 | send("k", true); | |
| 252 | await settleApp(app); | |
| 253 | for (const ch of "The week in a repository") send(ch); | |
| 254 | send("enter"); | |
| 255 | await settleApp(app); | |
| 256 | for (const ch of "alice/plan") send(ch); | |
| 257 | send("enter"); | |
| 258 | await settleApp(app); | |
| 259 | expect(app.screen.toText()).toContain("shipped"); | |
| 260 | send("j"); | |
| 261 | send("a"); | |
| 262 | await settleApp(app); | |
| 263 | expect(takenOf(forge.db, repo.id, heard.weekEnd).has(1)).toBe(false); | |
| 264 | app.stop(); | |
| 265 | }); | |
| 266 | }); |
test/week-sprint.test.ts added +290
| @@ -0,0 +1,290 @@ | ||
| 1 | /** | |
| 2 | * The week is the sprint (#8704): an estimate is a range from past issues of | |
| 3 | * its size and kind and says when it has too little history; the cut line | |
| 4 | * falls where the week's capacity ends and moves when the list is reranked; | |
| 5 | * the same state gives the same line; at the reset the forge writes one | |
| 6 | * frozen retrospective with at most three proposals, and nothing changes | |
| 7 | * until a person takes one. | |
| 8 | */ | |
| 9 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 10 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 11 | import { tmpdir } from "node:os"; | |
| 12 | import { join } from "node:path"; | |
| 13 | import { createApp, type App } from "../src/app.ts"; | |
| 14 | import { openIssue, setIssueState } from "../src/actions/issues.ts"; | |
| 15 | import { createRepo } from "../src/actions/repos.ts"; | |
| 16 | import { takeProposal } from "../src/actions/week.ts"; | |
| 17 | import { getIssue } from "../src/core/issues.ts"; | |
| 18 | import { nextUp } from "../src/core/next_up.ts"; | |
| 19 | import type { Repo } from "../src/core/repos.ts"; | |
| 20 | import { createUser, type User } from "../src/core/users.ts"; | |
| 21 | import { estimate, issueHistory, sizeOf, type Sample } from "../src/flow/estimate.ts"; | |
| 22 | import { noteLimit } from "../src/core/standing.ts"; | |
| 23 | import { addSubscription } from "../src/core/subscriptions.ts"; | |
| 24 | import { createRun } from "../src/core/runs.ts"; | |
| 25 | import { recordUsage } from "../src/core/usage.ts"; | |
| 26 | import { weekCapacity } from "../src/flow/week.ts"; | |
| 27 | import { latestRetro, takenOf, tickWeeks, windowShipped, windowSpend, writeRetro } from "../src/flow/retro.ts"; | |
| 28 | import { cutLine, priceRows, projection } from "../src/flow/week.ts"; | |
| 29 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 30 | ||
| 31 | const sample = (usd: number, size: Sample["size"] = "small", kind = ""): Sample => ({ size, kind, usd }); | |
| 32 | ||
| 33 | describe("estimates and the cut line, as data", () => { | |
| 34 | test("with too little history an estimate says so", () => { | |
| 35 | const e = estimate([sample(1), sample(2)], "small", ""); | |
| 36 | expect(e.known).toBe(false); | |
| 37 | expect(e.basis).toContain("too little history"); | |
| 38 | }); | |
| 39 | ||
| 40 | test("a range comes from past issues of the same size and kind, else the same size", () => { | |
| 41 | const history = [1, 2, 3, 4, 5].map((n) => sample(n)).concat([sample(40, "small", "bug")]); | |
| 42 | expect(estimate(history, "small", "")).toMatchObject({ known: true, samples: 5 }); | |
| 43 | // One "bug" sample is too few to speak for the kind: the size answers. | |
| 44 | expect(estimate(history, "small", "bug")).toMatchObject({ known: true, samples: 6 }); | |
| 45 | const bugs = estimate([...history, sample(8, "small", "bug"), sample(9, "small", "bug")], "small", "bug"); | |
| 46 | expect(bugs).toMatchObject({ known: true, samples: 3 }); | |
| 47 | expect(estimate(history, "large", "").known).toBe(false); | |
| 48 | }); | |
| 49 | ||
| 50 | test("the same history gives the same estimate", () => { | |
| 51 | const history = [5, 1, 4, 2, 3].map((n) => sample(n)); | |
| 52 | expect(estimate(history, "small", "")).toEqual(estimate([...history].reverse(), "small", "")); | |
| 53 | }); | |
| 54 | ||
| 55 | const priced = (mids: (number | null)[]) => | |
| 56 | mids.map((m, i) => ({ | |
| 57 | number: i + 1, | |
| 58 | estimate: | |
| 59 | m === null | |
| 60 | ? ({ known: false, samples: 0, basis: "x" } as const) | |
| 61 | : ({ known: true, low: m / 2, mid: m, high: m * 2, samples: 9, basis: "x" } as const), | |
| 62 | })); | |
| 63 | ||
| 64 | test("the line falls after the last issue that fits, strictly from the top", () => { | |
| 65 | const cut = cutLine(priced([2, 3, 1]), { usd: 5, resetsAt: null }); | |
| 66 | expect(cut.fits).toEqual([1, 2]); | |
| 67 | // Issue 3 would fit the money left over, but the crew builds from the top and stops. | |
| 68 | expect(cutLine(priced([4, 3, 1]), { usd: 5, resetsAt: null }).fits).toEqual([1]); | |
| 69 | }); | |
| 70 | ||
| 71 | test("reranking moves the line, and the same state gives the same line", () => { | |
| 72 | const cap = { usd: 5, resetsAt: null }; | |
| 73 | expect(cutLine(priced([2, 3, 1]), cap)).toEqual(cutLine(priced([2, 3, 1]), cap)); | |
| 74 | expect(cutLine(priced([2, 3, 1]), cap).fits).toEqual([1, 2]); | |
| 75 | const swapped = [priced([2, 3, 1])[2]!, priced([2, 3, 1])[0]!, priced([2, 3, 1])[1]!]; | |
| 76 | expect(cutLine(swapped, cap).fits).toEqual([3, 1]); | |
| 77 | }); | |
| 78 | ||
| 79 | test("no capacity or no history means no line, and the sentence says why", () => { | |
| 80 | expect(cutLine(priced([1]), null).unsure).toContain("capacity"); | |
| 81 | expect(cutLine(priced([null]), { usd: 9, resetsAt: null }).unsure).toContain("history"); | |
| 82 | expect(projection(cutLine(priced([null]), null), 1, null)).toStartWith("No projection yet"); | |
| 83 | }); | |
| 84 | ||
| 85 | test("the projection is one sentence with a range and a confidence", () => { | |
| 86 | const cap = { usd: 5, resetsAt: "2026-11-09T00:00:00.000Z" }; | |
| 87 | const words = projection(cutLine(priced([2, 3, 1]), cap), 3, cap); | |
| 88 | expect(words).toContain("of the 3 issues"); | |
| 89 | expect(words).toContain("2026-11-09"); | |
| 90 | expect(words).toContain("high confidence"); | |
| 91 | }); | |
| 92 | }); | |
| 93 | ||
| 94 | describe("the week, in a forge", () => { | |
| 95 | let dataDir: string; | |
| 96 | let app: App; | |
| 97 | let servers: Servers; | |
| 98 | let alice: User; | |
| 99 | let repo: Repo; | |
| 100 | let cookie: string; | |
| 101 | const n: Record<string, number> = {}; | |
| 102 | const WEEK_END = "2026-11-09T00:00:00.000Z"; | |
| 103 | ||
| 104 | const open = (title: string, body = ""): number => { | |
| 105 | const o = openIssue(app, alice, repo, { title, body }); | |
| 106 | if (!o.ok) throw new Error(String(o.error)); | |
| 107 | return o.value.number; | |
| 108 | }; | |
| 109 | const close = (number: number) => { | |
| 110 | const r = setIssueState(app, alice, repo, getIssue(app.db, repo.id, number)!, "closed"); | |
| 111 | if (!r.ok) throw new Error(String(r.error)); | |
| 112 | }; | |
| 113 | const spend = (number: number, usd: number, state = "ok", startedAt = "2026-11-04T10:00:00.000Z", summary = "") => { | |
| 114 | app.db | |
| 115 | .query( | |
| 116 | `INSERT INTO run (id, repo_id, kind, issue_id, state, cost_usd, started_at, summary) | |
| 117 | VALUES (lower(hex(randomblob(8))), ?1, 'agent', ?2, ?3, ?4, ?5, ?6)`, | |
| 118 | ) | |
| 119 | .run(repo.id, getIssue(app.db, repo.id, number)!.id, state, usd, startedAt, summary); | |
| 120 | }; | |
| 121 | ||
| 122 | beforeAll(async () => { | |
| 123 | dataDir = await mkdtemp(join(tmpdir(), "forge-week-")); | |
| 124 | app = createApp({ dataDir, port: 0 }); | |
| 125 | servers = await serve(app); | |
| 126 | const made = await createUser(app.db, { | |
| 127 | origin: "seed", trusted: true, username: "alice", email: "[email protected]", | |
| 128 | password: "a-good-passphrase", adminIfFirst: true, | |
| 129 | }); | |
| 130 | if (!made.ok) throw new Error(made.error); | |
| 131 | alice = made.value; | |
| 132 | const r = await createRepo(app, alice, { name: "plan", visibility: "public" }); | |
| 133 | if (!r.ok) throw new Error(String(r.error)); | |
| 134 | repo = r.value; | |
| 135 | n.gate = open("Later gate: not now"); | |
| 136 | // Past work: five small issues done at $1..$5 are the history. | |
| 137 | for (const usd of [1, 2, 3, 4, 5]) { | |
| 138 | const done = open(`Past ${usd}`); | |
| 139 | spend(done, usd); | |
| 140 | close(done); | |
| 141 | } | |
| 142 | n.a = open("Alpha"); | |
| 143 | n.b = open("Beta"); | |
| 144 | n.stuck = open("Stuck one"); | |
| 145 | const res = await fetch(`${servers.url}/signin`, { | |
| 146 | method: "POST", | |
| 147 | body: new URLSearchParams({ username: "alice", password: "a-good-passphrase" }), | |
| 148 | redirect: "manual", | |
| 149 | }); | |
| 150 | cookie = (res.headers.get("set-cookie") ?? "").split(";")[0]!; | |
| 151 | }); | |
| 152 | ||
| 153 | afterAll(async () => { | |
| 154 | await servers.stop(); | |
| 155 | await rm(dataDir, { recursive: true, force: true }); | |
| 156 | }); | |
| 157 | ||
| 158 | test("history is the closed issues' real spend", () => { | |
| 159 | const h = issueHistory(app.db, repo.id); | |
| 160 | expect(h.map((s) => s.usd).sort()).toEqual([1, 2, 3, 4, 5]); | |
| 161 | expect(sizeOf({ title: "x", body: "" })).toBe("small"); | |
| 162 | }); | |
| 163 | ||
| 164 | test("each row of Next up has a range, and the same rows give the same prices", () => { | |
| 165 | const rows = nextUp(app.db, repo).rows; | |
| 166 | const one = priceRows(app.db, repo.id, rows); | |
| 167 | expect(one).toEqual(priceRows(app.db, repo.id, rows)); | |
| 168 | expect(one.every((p) => p.estimate.known)).toBe(true); | |
| 169 | }); | |
| 170 | ||
| 171 | test("the page shows ranges and the projection sentence; no capacity read means no line", async () => { | |
| 172 | const res = await fetch(`${servers.url}/alice/plan/next`, { headers: { cookie } }); | |
| 173 | expect(res.status).toBe(200); | |
| 174 | const text = await res.text(); | |
| 175 | expect(text).toContain("$2.00–$4.00"); | |
| 176 | expect(text).toContain("No projection yet: the weekly capacity has not been read yet."); | |
| 177 | expect(text).not.toContain('role="separator"'); | |
| 178 | }); | |
| 179 | ||
| 180 | test("capacity is what the weekly window leaves, read from the ledger", () => { | |
| 181 | expect(weekCapacity(app.db, new Date("2026-11-06T00:00:00Z"))).toBeNull(); | |
| 182 | const added = addSubscription(app.db, dataDir, { name: "week account", token: "sk-ant-oat01-week-test-1" }); | |
| 183 | if (!added.ok) throw new Error(added.error); | |
| 184 | const sub = added.value.id; | |
| 185 | const ran = (at: string, usd: number) => | |
| 186 | recordUsage(app.db, { | |
| 187 | runId: createRun(app.db, { repoId: repo.id, kind: "agent" }).id, repoId: repo.id, issueId: null, agentId: null, | |
| 188 | subscriptionId: sub, tokensIn: 1, tokensOut: 1, costUsd: usd, wallMs: 1000, outcome: "ok", at, | |
| 189 | }); | |
| 190 | ran("2026-10-30T10:00:00.000Z", 20); | |
| 191 | noteLimit(app.db, sub, { | |
| 192 | window: "week", resetsAt: "2026-11-02T00:00:00.000Z", observedAt: "2026-10-31T00:00:00.000Z", said: "weekly limit", | |
| 193 | }); | |
| 194 | ran("2026-11-05T10:00:00.000Z", 5); | |
| 195 | // The provider refused at $20 of spend and reset on the 2nd: that is the cap, the next window ends a week on, and $5 of it is used. | |
| 196 | const cap = weekCapacity(app.db, new Date("2026-11-06T00:00:00Z"))!; | |
| 197 | expect(cap.resetsAt).toBe(WEEK_END); | |
| 198 | expect(cap.usd).toBeGreaterThan(0); | |
| 199 | expect(cap.usd).toBeLessThanOrEqual(15); | |
| 200 | }); | |
| 201 | ||
| 202 | test("with capacity, the page draws the cut line between what fits and next week", () => { | |
| 203 | const rows = nextUp(app.db, repo).rows; | |
| 204 | const priced = priceRows(app.db, repo.id, rows); | |
| 205 | const cut = cutLine(priced, { usd: 3, resetsAt: WEEK_END }); | |
| 206 | expect(cut.unsure).toBeNull(); | |
| 207 | expect(cut.fits.length).toBeGreaterThan(0); | |
| 208 | expect(cut.fits.length).toBeLessThan(rows.length); | |
| 209 | expect(projection(cut, rows.length, { usd: 3, resetsAt: WEEK_END })).toContain("of the"); | |
| 210 | }); | |
| 211 | ||
| 212 | test("the retrospective is written once, frozen, with proposals that change nothing until taken", () => { | |
| 213 | app.db | |
| 214 | .query("INSERT INTO week_projection (repo_id, week_end, body, at) VALUES (?1, ?2, ?3, ?4)") | |
| 215 | .run( | |
| 216 | repo.id, | |
| 217 | WEEK_END, | |
| 218 | JSON.stringify({ sentence: "s", fits: [n.a], shipLow: 1, shipHigh: 2, confidence: "medium", ranges: { [String(n.a!)]: { low: 1, high: 3 } } }), | |
| 219 | "2026-11-02T00:00:00.000Z", | |
| 220 | ); | |
| 221 | // Alpha shipped at $2 (inside its range); Stuck one spent $9 over three failed runs and is still open. | |
| 222 | spend(n.a!, 2); | |
| 223 | close(n.a!); | |
| 224 | app.db.query("UPDATE issue SET closed_at = '2026-11-05T00:00:00.000Z' WHERE repo_id = ?1 AND number = ?2").run(repo.id, n.a!); | |
| 225 | for (let i = 0; i < 3; i++) spend(n.stuck!, 3, "failed", "2026-11-06T10:00:00.000Z", "the check failed"); | |
| 226 | const now = new Date("2026-11-09T01:00:00.000Z"); | |
| 227 | expect(tickWeeks(app.db, [repo], now)).toHaveLength(1); | |
| 228 | expect(tickWeeks(app.db, [repo], now)).toHaveLength(0); | |
| 229 | expect(writeRetro(app.db, repo, WEEK_END, now)).toBe(false); | |
| 230 | const retro = latestRetro(app.db, repo.id)!; | |
| 231 | expect(retro.shipped.map((s) => s.number)).toContain(n.a!); | |
| 232 | expect(retro.accuracy).toEqual({ compared: 1, inside: 1 }); | |
| 233 | expect(retro.stuck.map((s) => s.number)).toEqual([n.stuck!]); | |
| 234 | expect(retro.stuck[0]!.why).toContain("its check failed"); | |
| 235 | expect(retro.proposals.length).toBeLessThanOrEqual(3); | |
| 236 | expect(retro.proposals[0]).toMatchObject({ kind: "not_now", number: n.stuck! }); | |
| 237 | // Writing it changed nothing: the issue is still on the list. | |
| 238 | expect(nextUp(app.db, repo).rows.map((r) => r.issue.number)).toContain(n.stuck!); | |
| 239 | }); | |
| 240 | ||
| 241 | test("the screen is reachable and a tap takes a proposal once", async () => { | |
| 242 | const page = await (await fetch(`${servers.url}/alice/plan/week`, { headers: { cookie } })).text(); | |
| 243 | expect(page).toContain("What shipped"); | |
| 244 | expect(page).toContain("Take it"); | |
| 245 | const retro = latestRetro(app.db, repo.id)!; | |
| 246 | const taken = takeProposal(app, alice, repo, retro.weekEnd, 0); | |
| 247 | expect(taken.ok).toBe(true); | |
| 248 | expect(takenOf(app.db, repo.id, retro.weekEnd).has(0)).toBe(true); | |
| 249 | expect(takeProposal(app, alice, repo, retro.weekEnd, 0).ok).toBe(false); | |
| 250 | expect(nextUp(app.db, repo).rows.map((r) => r.issue.number)).not.toContain(n.stuck!); | |
| 251 | }); | |
| 252 | ||
| 253 | test("a proposal is refused for a reader and for one that does not exist", () => { | |
| 254 | const retro = latestRetro(app.db, repo.id)!; | |
| 255 | expect(takeProposal(app, alice, repo, retro.weekEnd, 7).ok).toBe(false); | |
| 256 | expect(takeProposal(app, alice, repo, "2020-01-01T00:00:00.000Z", 0).ok).toBe(false); | |
| 257 | }); | |
| 258 | }); | |
| 259 | ||
| 260 | describe("the reads walk an index", () => { | |
| 261 | test("history, labels and the window reads show no scan and no sort in their plans", async () => { | |
| 262 | const dir = await mkdtemp(join(tmpdir(), "forge-week-plan-")); | |
| 263 | const app = createApp({ dataDir: dir, port: 0 }); | |
| 264 | try { | |
| 265 | const db = app.db; | |
| 266 | const original = db.query.bind(db); | |
| 267 | const sqls: string[] = []; | |
| 268 | (db as unknown as { query: unknown }).query = (sql: string) => (sqls.push(sql), original(sql)); | |
| 269 | issueHistory(db, "x"); | |
| 270 | windowSpend(db, "x", "a", "b"); | |
| 271 | windowShipped(db, "x", "a", "b"); | |
| 272 | (await import("../src/flow/estimate.ts")).labelsOf(db, ["x"]); | |
| 273 | (db as unknown as { query: unknown }).query = original; | |
| 274 | const lines = sqls | |
| 275 | .filter((q) => /^\s*SELECT/i.test(q)) | |
| 276 | .flatMap((q) => { | |
| 277 | const n = Math.max(0, ...[...q.matchAll(/\?(\d+)/g)].map((m) => Number(m[1]))); | |
| 278 | return original(`EXPLAIN QUERY PLAN ${q}`).all(...Array.from({ length: n }, () => "x")).map((r) => (r as { detail: string }).detail); | |
| 279 | }); | |
| 280 | expect(lines.length).toBeGreaterThan(3); | |
| 281 | for (const line of lines) { | |
| 282 | expect(line).not.toMatch(/^SCAN (?!.*USING (COVERING )?INDEX)/); | |
| 283 | expect(line).not.toContain("TEMP B-TREE"); | |
| 284 | } | |
| 285 | } finally { | |
| 286 | app.close(); | |
| 287 | await rm(dir, { recursive: true, force: true }); | |
| 288 | } | |
| 289 | }); | |
| 290 | }); |
new above
offline
No check that
useris a person (not an agent) before taking a proposal. Move the person-and-writer check intotakeProposalso page, MCP and terminal all share it, and test it.