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

Merged opened this in answer to

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

Merged

mason/forge:mason/8704-the-week-is-the-sprint-a-cut-line-on-nex → main 3 commits fork 1 thread · 1 unresolved

  • Merged. mason/8704-the-week-is-the-sprint-a-cut-line-on-nex was merged into main as 33964ac.

Review

Sign in to review this change.
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 @@
1111 - A rank is cleared when the issue closes. Dependencies order within the list and are shown, never edited here.
1212
1313 **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([
3838 "issue.labeled",
3939 "issue.first",
4040 "issue.ranked",
41 "week.retro",
4142 "issue.closed",
4243 "issue.gate_reviewed",
4344 "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";
174174 export { tool as wait_for } from "./wait_for.ts";
175175 export { tool as watch } from "./watch.ts";
176176 export { tool as waves } from "./waves.ts";
177 export { tool as week } from "./week.ts";
177178 export { tool as whoami_can } from "./whoami_can.ts";
178179 export { tool as write_file } from "./write_file.ts";
src/http/mcp/tools/next_up.ts +13 −4
@@ -1,9 +1,16 @@
11 import { placeOf, rankIssue } from "../../../actions/next.ts";
22 import { nextUp, TOP, type NextRow } from "../../../core/next_up.ts";
3 import { weekView, type WeekView } from "../../../flow/week.ts";
34 import { sealedScope } from "../../../core/sealed.ts";
45 import { bad, good, type Tool, APP_ARG, planRepo, isAnswer, issueArg } from "../shared.ts";
56
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 }),
714 issue: r.issue.number,
815 title: r.issue.title,
916 place: r.place,
@@ -15,7 +22,7 @@ const brief = (r: NextRow) => ({
1522
1623 export const tool: Tool = {
1724 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)",
1926 description: "The ranked list the crew builds from; `issue` + `to` (top, not-now, after:N) moves one.",
2027 inputSchema: {
2128 type: "object",
@@ -43,11 +50,13 @@ export const tool: Tool = {
4350 }
4451 const next = nextUp(ctx.app.db, repo, sealedScope(ctx.app.db, repo, user));
4552 const all = args["all"] === true;
53 const week = weekView(ctx.app.db, repo.id, next.rows, new Date());
4654 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)),
4857 more: all ? 0 : Math.max(0, next.rows.length - TOP),
4958 notNow: next.notNowTotal,
50 ...(all ? { notNowRows: next.notNow.map(brief) } : {}),
59 ...(all ? { notNowRows: next.notNow.map((r) => brief(r)) } : {}),
5160 });
5261 },
5362 };
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);

No check that user is a person (not an agent) before taking a proposal. Move the person-and-writer check into takeProposal so page, MCP and terminal all share it, and test it.

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";
201201 import { mapPage } from "../web/pages/map.ts";
202202 import { firstSubmit } from "../web/pages/first.ts";
203203 import { nextPage, nextSubmit } from "../web/pages/next.ts";
204 import { weekPage, weekSubmit } from "../web/pages/week.ts";
204205 import {
205206 milestonePage,
206207 milestonesPage,
@@ -1033,6 +1034,8 @@ export function buildRouter(): Router<Handler> {
10331034 router.add("GET", "/:owner/:name/map", mapPage);
10341035 router.add("GET", "/:owner/:name/next", nextPage);
10351036 router.add("POST", "/:owner/:name/next", nextSubmit);
1037 router.add("GET", "/:owner/:name/week", weekPage);
1038 router.add("POST", "/:owner/:name/week", weekSubmit);
10361039 router.add("POST", "/:owner/:name/issues/:number/first", firstSubmit);
10371040 router.add("GET", "/:owner/:name/bets", betsPage);
10381041 router.add("POST", "/:owner/:name/bets/propose", betPropose);
src/main.ts +13
@@ -5,6 +5,8 @@
55 */
66
77 import { runModelReview } from "./actions/model_review.ts";
8 import { getRepoById } from "./core/repos.ts";
9 import { plannedRepoIds, tickWeeks } from "./flow/retro.ts";
810 import { BOOT_ACTOR, setSignup, signupFromEnv } from "./settings/signup.ts";
911 import { startMemorySampler } from "./boards/memory.ts";
1012 import { startSampler } from "./boards/sampler.ts";
@@ -640,6 +642,16 @@ const modelReviewTimer = setInterval(() => {
640642 app.log.warn("model review failed", { error: String(error) });
641643 }
642644 }, 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);
643655 const processTimer = setInterval(processTick, 60_000);
644656 processTick();
645657 // 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> {
791803 clearInterval(processTimer);
792804 clearInterval(holdTimer);
793805 clearInterval(modelReviewTimer);
806 clearInterval(weekTimer);
794807 clearInterval(leaseTimer);
795808 // The platform's SIGTERM: the listener stays open while routers learn
796809 // 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";
7878 import { issuesScreen, newIssueScreen } from "./screens/issues.ts";
7979 import { limbsScreen } from "./screens/limbs.ts";
8080 import { nextScreen } from "./screens/next.ts";
81 import { weekScreen } from "./screens/week.ts";
8182 import { newPullScreen } from "./screens/newpull.ts";
8283 import { newForgeScreen } from "./screens/newforge.ts";
8384 import { newOrgScreen } from "./screens/neworg.ts";
@@ -133,6 +134,12 @@ const REPO_VERBS: readonly {
133134 title: "Next up in a repository",
134135 open: (f, ctx, repo) => nextScreen(f, ctx, repo),
135136 },
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 },
136143 {
137144 // What the crew's tokens bought on one app, as MCP `returns` with `repo` (#7271).
138145 id: "repo-usage",
src/tui/screens/next.ts +19 −6
@@ -15,6 +15,7 @@ import { rankedWords, rankIssue, stepTo, type Place, placeOf } from "../../actio
1515 import { nextUp, type NextRow } from "../../core/next_up.ts";
1616 import { canRead, canWrite, type Repo } from "../../core/repos.ts";
1717 import { sealedScope } from "../../core/sealed.ts";
18 import { rangeWords, weekView, type WeekView } from "../../flow/week.ts";
1819 import type { Ctx, Screen } from "../app/app.ts";
1920 import { binding } from "../app/keys.ts";
2021 import { fill, len, split } from "../ui/layout.ts";
@@ -32,6 +33,7 @@ export function nextScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
3233 const state = new ListState();
3334 let rows: readonly NextRow[] = [];
3435 let away = 0;
36 let week: WeekView | undefined;
3537 let loaded = false;
3638 // Read at use, not at open: access can change under a session.
3739 const may = (): boolean =>
@@ -41,10 +43,12 @@ export function nextScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
4143 if (!canRead(f.db, repo, f.user)) {
4244 rows = [];
4345 away = 0;
46 week = undefined;
4447 } else {
4548 const next = nextUp(f.db, repo, sealedScope(f.db, repo, f.user));
4649 rows = next.rows;
4750 away = next.notNowTotal;
51 week = weekView(f.db, repo.id, rows, new Date());
4852 }
4953 loaded = true;
5054 if (follow !== undefined) {
@@ -78,12 +82,21 @@ export function nextScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
7882 if (row !== undefined) ctx.push(issueScreen(f, ctx, local(f, repo), row.issue.number));
7983 };
8084
85 // The cut line is a muted line under the last row that fits, as on the page.
8186 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 });
87100
88101 const screen: Screen = {
89102 title: "Next up",
@@ -110,7 +123,7 @@ export function nextScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
110123 ? "Loading…"
111124 : rows.length === 0
112125 ? "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` : ""}`;
114127 s.text(head!.x, head!.y, truncate(note, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w);
115128 drawList(s, list!, c, state, listRows());
116129 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";
1111 import { canWrite, type Repo } from "../../core/repos.ts";
1212 import { notePhase, takeSessionTime } from "../../core/route_timing.ts";
1313 import { sealedScope } from "../../core/sealed.ts";
14 import { rangeWords, weekView, type Cut, type Priced } from "../../flow/week.ts";
1415 import type { Ctx } from "../../http/context.ts";
1516 import { crumbsFor, loadRepo, notFoundPage, repoBase, repoHeader, repoPalette, signInFirst } from "../chrome.ts";
1617 import { backTo, field, readForm } from "../form.ts";
@@ -75,7 +76,15 @@ function move(base: string, number: number, to: string, label: string, key: stri
7576 </form>`;
7677 }
7778
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 {
7988 const r = rows[at]!;
8089 const n = r.issue.number;
8190 const numbers = rows.map((x) => x.issue.number);
@@ -94,13 +103,20 @@ function line(base: string, rows: readonly NextRow[], at: number, writer: boolea
94103 titleAttr: r.issue.title,
95104 lead: html`${r.place === null ? "·" : String(r.place)}`,
96105 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}`}`,
98107 nav: true,
99108 data: { number: String(n) },
100109 ...(controls === null ? {} : { after: controls }),
101110 });
102111 }
103112
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
104120 export function nextPage(ctx: Ctx): Response {
105121 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
106122 const repo = loadRepo(ctx);
@@ -113,6 +129,15 @@ export function nextPage(ctx: Ctx): Response {
113129 const rest = next.rows.length - shown.length;
114130 const back = all ? `${base}/next?all=1` : `${base}/next`;
115131 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 };
116141 const body = html`${repoHeader(ctx, repo, "issues")}
117142 <div class="page-head mt">
118143 <h1>Next up</h1>
@@ -124,12 +149,13 @@ export function nextPage(ctx: Ctx): Response {
124149 Dependencies order work within the list; they are shown, not edited.
125150 </p>
126151 </div>
152 <p class="muted" id="next-week">${sentence} <a href="${base}/week">The week</a></p>
127153 <p class="error" id="next-flash" role="alert" hidden></p>
128154 ${next.rows.length === 0
129155 ? html`<p class="muted">Nothing is ready: everything open waits for something. <a href="${base}/map">See the map</a>.</p>`
130156 : card(
131157 html`<ul class="list setting-list" id="next-list">
132 ${shown.map((_, at) => line(base, next.rows, at, writer, back, false))}
158 ${rowsHtml()}
133159 </ul>`,
134160 { pad: false },
135161 )}
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; }
862862 .pipe-repo { font-size: 12.5px; fill: var(--muted-fg); }
863863 .valve-form { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 4px 0; }
864864 .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; }
865868 /* The config form (#7336): nested objects as quiet fieldsets, scalar
866869 arrays as a wrapping row of chips, object arrays as cards. */
867870 .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", () => {
124124 "assign", "attach_look", "bet", "board_pins", "board_write", "branches", "capacity", "valves", "cloud_accounts", "close", "confirm_wait",
125125 "crew", "decisions_by", "detach", "edit", "email_verify", "feed_chips", "feed_dismiss", "flag_set", "flag_winner",
126126 "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",
128128 "restore_drill", "review", "say", "signup", "snapshot_now", "spend", "steer", "sync", "take_back", "take_updates",
129129 "teams", "tell", "threads", "trail", "upstream", "variant", "wait_for", "watch", "whoami_can", "write_file",
130130 ]);
test/mcp.test.ts +1
@@ -333,6 +333,7 @@ describe("the forge over MCP", () => {
333333 "repo_labels",
334334 "wait_for",
335335 "next_up",
336 "week",
336337 "lift",
337338 "held",
338339 "confirm_wait",
test/palette-roster.test.ts +4
@@ -54,6 +54,7 @@ test("a site-admin person is offered the whole roster", () => {
5454 ["pulls", "Pull requests in a repository", "owner/name"],
5555 ["milestones", "Milestones in a repository", "owner/name"],
5656 ["next-up", "Next up in a repository", "owner/name"],
57 ["week", "The week in a repository", "owner/name"],
5758 ["repo-usage", "Usage of a repository", "owner/name"],
5859 ["new-issue", "New issue", "owner/name"],
5960 ["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",
141142 ["pulls", "Pull requests in a repository", "owner/name"],
142143 ["milestones", "Milestones in a repository", "owner/name"],
143144 ["next-up", "Next up in a repository", "owner/name"],
145 ["week", "The week in a repository", "owner/name"],
144146 ["repo-usage", "Usage of a repository", "owner/name"],
145147 ["new-issue", "New issue", "owner/name"],
146148 ["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"
199201 ["pulls", "Pull requests in a repository", "owner/name"],
200202 ["milestones", "Milestones in a repository", "owner/name"],
201203 ["next-up", "Next up in a repository", "owner/name"],
204 ["week", "The week in a repository", "owner/name"],
202205 ["repo-usage", "Usage of a repository", "owner/name"],
203206 ["new-issue", "New issue", "owner/name"],
204207 ["new-pull", "New pull request", "owner/name"],
@@ -252,6 +255,7 @@ test("signed out, the palette offers sign-in and the public entries", () => {
252255 ["pulls", "Pull requests in a repository", "owner/name"],
253256 ["milestones", "Milestones in a repository", "owner/name"],
254257 ["next-up", "Next up in a repository", "owner/name"],
258 ["week", "The week in a repository", "owner/name"],
255259 ["repo-usage", "Usage of a repository", "owner/name"],
256260 ["new-issue", "New issue", "owner/name"],
257261 ["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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs