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

The planner proposes bets from unlabelled open issues and the owner's notes, grouped by theme #8870

Merged opened this

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

Merged

torque/forge:torque/8850-the-planner-proposes-bets-from-unlabelle → main 5 commits fork 4 threads · 4 unresolved

  • Merged. torque/8850-the-planner-proposes-bets-from-unlabelle was merged into main as 78d7db1.

Review

Sign in to review this change.
5 files changed +236 −14
docs/features/bets.md +3 −2
@@ -3,7 +3,8 @@
33 A bet is a name, an appetite (a share of the week), what done looks like in a few journeys, and scopes — existing issues — that each finish on their own. One crew member leads. Open `/<owner>/<repo>/bets`; the MCP has `bets` (read) and `bet` (act); the terminal has **Bets** in the palette (`p` propose, `b` back, `s` stop, `c` cut, `e` end, `l` what is left, `d` feels done, `n` not yet). All three go through `actions/bets.ts`, and every write emits a `bet.*` event, which the web pages follow with their "new activity" pill.
44 - **Who sees what.** The plan (shares, journeys, left lines, a verdict's reason) is for a person who administers the repo; any other reader sees names, states and scopes. Agents never act on a bet. `bet.*` events are admin-only to match.
55
6 - **Propose.** "Ask the planner to propose bets" groups open issues in no live bet by their first label; a theme with two or more issues becomes a proposal at 20% of the week. At most five proposals a pass.
6 - **Propose.** "Ask the planner to propose bets" groups open issues in no live bet: by first label; else by the pillar an unlabelled issue holds up (later and structural issues are skipped); else by the title word most unlabelled issues share. A group of two or more becomes a proposal at 20% of the week. At most five proposals a pass; no model, so the same state gives the same proposals.
7 - **Notes.** Only notes written by the repo's owner or an admin count (a visitor's never join or appear). Such a note joins a proposal when its issue is held there, it names one of the group's issues as `#N`, or it shares two words with the group's name and its issues' titles; it is quoted in the journeys as "The owner's note".
78 - **Back.** The owner (a person who administers the repo, never an agent) backs one with a share. At most 5 run, and their shares stay within 100%.
89 - **Picks.** Once a repository has ever had a bet backed, builders pick only scopes of a running bet, issues the owner asked for or raised, and small fixes (held to `slots.smallShare`, #7571). An issue in no bet does not start by itself. A repository with no bet picks as before.
910 - **Appetite.** The budget is share × builder slots × one week of builder time, counted from the runs on the bet's scopes since it was backed. At the budget the bet stops: no extension. Its left line defaults to the open scopes; the lead rewrites it in one line; the owner re-backs, cuts or ends it (ending as done is for a stopped bet only; a running bet finishes when every scope closes).
@@ -11,4 +12,4 @@ A bet is a name, an appetite (a share of the week), what done looks like in a fe
1112 - **Finishing.** When every scope is closed the bet is finished and its page says "This is done. Try it." with a link; the owner taps *Feels done* or *Not yet, because…* (the bet returns to stopped with that reason). `/changelog` marks each landing with its bet and filters by it (`?bet=`).
1213 - **Idle forge.** If nothing was ever backed and the oldest proposal is a day old, the top two start at 10%, marked "not backed by the owner". One tap on *Stop it* ends any of them, and the planner does not restart a stopped bet.
1314
14 Left out: proposing from notes (only open issues, by label; tracked as a follow-up), a per-week reset of the appetite window.
15 Left out: a per-week reset of the appetite window.
src/actions/bets.ts +68 −9
@@ -11,9 +11,12 @@ import { listCrewRoles } from "../core/agents.ts";
1111 import {
1212 type Bet, betRuns, betScopes, getBet, insertBet, listBets, markBacked, markEnded, setLeftLine, setTryUrl, setVerdict,
1313 } from "../core/bets.ts";
14 import { labelsForIssues } from "../core/issues.ts";
14 import { notesOfRepo } from "../core/app_notes.ts";
15 import { themeWords, groupByWords } from "../core/bet_themes.ts";
16 import { getIssuesByNumbers, labelsForIssues, numberKey } from "../core/issues.ts";
17 import { isStructural, LATER, pillarsOf, servedBy } from "../core/pillars.ts";
1518 import { canAdmin, type Repo } from "../core/repos.ts";
16 import type { User } from "../core/users.ts";
19 import { getUserById, type User } from "../core/users.ts";
1720 import { AUTO_SHARE, autoStarts, budgetSeconds, mayBack, spentSeconds, spentUp } from "../crew/bets.ts";
1821 import { err, ok, type Result } from "../kernel/result.ts";
1922
@@ -39,9 +42,13 @@ const backed = (app: App, repo: Repo) => listBets(app.db, repo.id).filter((b) =>
3942
4043 /**
4144 * The planner's proposals: open issues in no live bet, grouped by their first
42 * label (what they share, the theme), a bet each with a modest share and the
43 * issues as scopes, led by the crew's first builder. Run again it adds only
44 * what is new. At most five.
45 * label, else the pillar they hold up, else the title words they share (the
46 * theme; no model, the same state gives the same proposals). A note written by
47 * the repo's owner or an admin joins a group when its issue is held there, it
48 * names one of the group's issues as #N, or it shares two words with the
49 * group's name and titles; it is quoted in the bet. Visitors' notes never join. Each group becomes a bet with
50 * a modest share and the issues as scopes, led by the crew's first builder.
51 * Run again it adds only what is new. At most five.
4552 */
4653 export function proposeBets(app: App, repo: Repo, now = new Date()): Bet[] {
4754 const live = new Set(
@@ -60,10 +67,58 @@ export function proposeBets(app: App, repo: Repo, now = new Date()): Bet[] {
6067 .all(repo.id)
6168 .filter((i) => !live.has(i.id));
6269 const labels = labelsForIssues(app.db, open.map((i) => i.id));
63 const themes = new Map<string, typeof open>();
70 type Item = (typeof open)[number];
71 const themes = new Map<string, Item[]>();
72 const add = (theme: string, i: Item) => themes.set(theme, [...(themes.get(theme) ?? []), i]);
73 // 1. The first label; 2. the pillar an unlabelled issue holds up; 3. title words the rest share.
74 const loose: Item[] = [];
75 const set = pillarsOf(app.db, repo);
76 const served = servedBy(app.db, set);
6477 for (const i of open) {
65 const theme = (labels.get(i.id) ?? [])[0]?.name ?? "";
66 if (theme !== "") themes.set(theme, [...(themes.get(theme) ?? []), i]);
78 const label = (labels.get(i.id) ?? [])[0]?.name ?? "";
79 const pillar = served.get(i.id);
80 if (label !== "") add(label, i);
81 else if (pillar === LATER || isStructural(set, i.id)) continue;
82 else if (pillar !== undefined) add(pillar, i);
83 else loose.push(i);
84 }
85 for (const [word, items] of groupByWords(loose)) for (const i of items) add(word, i);
86 // The owner's and admins' notes (a visitor's never): each is the issue its words became. A note joins the theme
87 // that holds its issue, names one of its issues as #N, or shares two words with its name and its issues' titles;
outdated

HIGH: this reads every visitor's note, not the owner's, and quotes it as 'The owner's note' in journeys. Filter to the owner or an admin, and add a test for a non-owner note.

88 // its issue joins too when no theme holds it.
89 const byNumber = new Map(open.map((i) => [i.number, i]));
90 const trusted = new Map<string, boolean>();
91 const mayNote = (visitor: string): boolean => {
92 let ok = trusted.get(visitor);
93 if (ok === undefined) {
94 const user = visitor.startsWith("u:") ? getUserById(app.db, visitor.slice(2)) : undefined;
95 ok = user !== undefined && (user.isAdmin || canAdmin(app.db, repo, user));
96 trusted.set(visitor, ok);
97 }
98 return ok;
99 };
100 const written = notesOfRepo(app.db, repo.id).filter((n) => mayNote(n.visitor));
101 const found = getIssuesByNumbers(app.db, written.map((n) => ({ repoId: repo.id, number: n.issueNumber })));
102 const notes = written.flatMap((n) => {
103 const issue = found.get(numberKey(repo.id, n.issueNumber));
104 return issue === undefined ? [] : [{ issue, text: `${issue.title}\n${issue.body}` }];
105 });
outdated

themeWords(n.text).filter(w => wanted.has(w)).length >= 2 needs a theme with 2+ words, but wanted is built from the theme key alone (one label/word/pillar). Match against the union of themeWords of the group issue titles so this branch is reachable.

106 const quoted = new Map<string, string[]>();
107 const held = new Set([...themes.values()].flat().map((i) => i.id));
108 for (const [theme, issues] of [...themes].sort((a, b) => a[0].localeCompare(b[0]))) {
109 const own = new Set(issues.map((i) => i.id));
110 const wanted = new Set([theme, ...issues.map((i) => i.title)].flatMap(themeWords));
111 for (const n of notes) {
112 const named = [...n.text.matchAll(/#(\d+)/g)].some((m) => own.has(byNumber.get(Number(m[1]))?.id ?? ""));
113 const shares = themeWords(n.text).filter((w) => wanted.has(w)).length >= 2;
114 if (!own.has(n.issue.id) && !named && !shares) continue;
115 quoted.set(theme, [...(quoted.get(theme) ?? []), n.issue.title]);
116 const mine = byNumber.get(n.issue.number);
117 if (mine !== undefined && !held.has(mine.id)) {
118 held.add(mine.id);
119 add(theme, mine);
120 }
121 }
67122 }
68123 const named = new Set(listBets(app.db, repo.id).filter((b) => b.state !== "cut" && b.state !== "done").map((b) => b.name));
69124 const lead = listCrewRoles(app.db, repo.id).find((c) => c.role === "builder")?.agent.user.id ?? null;
@@ -71,7 +126,11 @@ export function proposeBets(app: App, repo: Repo, now = new Date()): Bet[] {
71126 for (const [theme, issues] of [...themes].sort((a, b) => b[1].length - a[1].length || a[0].localeCompare(b[0]))) {
72127 const name = oneLine(theme[0]!.toUpperCase() + theme.slice(1), NAME_MAX);
73128 if (made.length >= 5 || issues.length < 2 || named.has(name)) continue;
74 const journeys = issues.slice(0, 3).map((i) => `Someone can ${oneLine(i.title, 100)}`).join("\n");
129 named.add(name);
130 const journeys = [
131 ...issues.slice(0, 3).map((i) => `Someone can ${oneLine(i.title, 100)}`),
132 ...(quoted.get(theme) ?? []).slice(0, 2).map((t) => `The owner's note: "${oneLine(t, 100)}"`),
133 ].join("\n");
75134 const bet = insertBet(app.db, { repoId: repo.id, name, journeys, appetite: 20, leadId: lead, issueIds: issues.map((i) => i.id) }, now);
76135 app.bus.append({ kind: "bet.proposed", repoId: repo.id, payload: { bet: bet.id, name } });
77136 made.push(bet);
src/core/app_notes.ts +10
@@ -137,3 +137,13 @@ export function isBanned(db: Database, appId: string, visitor: string): boolean
137137 export function markSender(db: Database, issueId: string, name: string): void {
138138 db.query("UPDATE issue SET origin_author = ?1 WHERE id = ?2").run(name, issueId);
139139 }
140
141 /** Every visible note of a repository's apps, oldest first: what the planner reads. */
142 export function notesOfRepo(db: Database, repoId: string, limit = 200): AppNote[] {

Takes no sender filter. Return only notes the owner or an admin wrote, or add a parameter for it.

143 return db
144 .query<Row, [string, number]>(
145 "SELECT * FROM app_note WHERE repo_id = ?1 AND hidden = 0 ORDER BY created_at, id LIMIT ?2",
146 )
147 .all(repoId, limit)
148 .map(fromRow);
149 }
src/core/bet_themes.ts added +37
@@ -0,0 +1,37 @@
1 /**
2 * Themes for the planner (#8850): the words a title is about, and issues
3 * grouped by the word they share most. Plain data in, plain data out; no
4 * model, so the same issues always fall into the same groups.
5 */
6
7 const STOP = new Set([
8 "about", "add", "after", "allow", "also", "and", "are", "can", "does", "each", "for", "from", "have", "into", "make",
9 "more", "not", "one", "should", "show", "that", "the", "their", "them", "then", "there", "this", "use", "when",
10 "with", "without", "your",
11 ]);
12
13 /** The words of a text worth grouping by: lowercase, 4+ letters, not filler. */
14 export function themeWords(text: string): string[] {
15 const words = text.toLowerCase().match(/[a-z][a-z0-9]{3,}/g) ?? [];
16 return [...new Set(words.filter((w) => !STOP.has(w)))];
17 }
18
19 /**
20 * Group items by shared title words: repeatedly take the word in the most
21 * ungrouped titles (ties: alphabetical) while at least two share it. Items
22 * sharing no word with another are left out. Order of the input is kept
23 * inside each group.
24 */
25 export function groupByWords<T extends { title: string }>(items: readonly T[]): Map<string, T[]> {
26 const words = items.map((i) => ({ item: i, words: new Set(themeWords(i.title)) }));
27 let rest = words;
28 const out = new Map<string, T[]>();
29 for (;;) {
30 const count = new Map<string, number>();
31 for (const r of rest) for (const w of r.words) count.set(w, (count.get(w) ?? 0) + 1);
32 const best = [...count].filter(([, n]) => n >= 2).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))[0];
33 if (best === undefined) return out;
34 out.set(best[0], rest.filter((r) => r.words.has(best[0])).map((r) => r.item));
35 rest = rest.filter((r) => !r.words.has(best[0]));
36 }
37 }
test/bets.test.ts +118 −3
@@ -12,7 +12,9 @@ import { backBet, endBet, giveVerdict, proposeBets, stopBet, tickBets, writeLeft
1212 import { addToCrew, createAgent } from "../src/actions/crew.ts";
1313 import { openIssue } from "../src/actions/issues.ts";
1414 import { commitToBranch, createRepo, updateRepoSettings } from "../src/actions/repos.ts";
15 import { getBet, insertBet, listBets, markBacked } from "../src/core/bets.ts";
15 import { insertNote } from "../src/core/app_notes.ts";
16 import { addDependency } from "../src/core/dependencies.ts";
17 import { betScopes, getBet, insertBet, listBets, markBacked } from "../src/core/bets.ts";
1618 import { createLabel, getIssueById, setIssueLabels, type Issue } from "../src/core/issues.ts";
1719 import { getRepoById, type Repo } from "../src/core/repos.ts";
1820 import { createUser, type User } from "../src/core/users.ts";
@@ -75,12 +77,44 @@ let servers: Servers;
7577 const fresh = () => getRepoById(app.db, repo.id)!;
7678 const NOW = new Date("2026-11-03T12:00:00Z");
7779
78 function open(title: string): Issue {
79 const made = openIssue(app, alice, fresh(), { title });
80 function open(title: string, into?: Repo): Issue {
81 const made = openIssue(app, alice, into === undefined ? fresh() : getRepoById(app.db, into.id)!, { title });
8082 if (!made.ok) throw new Error(String(made.error));
8183 return made.value;
8284 }
8385
86 /** A repository of its own, so a test's issues and bets touch no other test's; named to sort after "notes", whose bets the terminal lists first. */
87 async function blank(name: string): Promise<Repo> {
88 const made = await createRepo(app, alice, { name, visibility: "public" });
89 if (!made.ok) throw new Error(String(made.error));
90 return made.value;
91 }
92
93 function label(into: Repo, name: string, issues: readonly Issue[]): void {
94 const made = createLabel(app.db, into.id, name, "aabbcc");
95 if (!made.ok) throw new Error(String(made.error));
96 for (const i of issues) setIssueLabels(app.db, i.id, [made.value.id]);
97 }
98
99 /** The same state, built anew in a fresh repository: a pillar pair with a note naming it, a word pair, a label pair. */
100 async function sameState(name: string): Promise<{ name: string; journeys: string; scopes: string[] }[]> {
101 const into = await blank(name);
102 const umbrella = open("P7 Speed: be quick", into);
103 const a = open("Cache the rendered page", into);
104 const b = open("Trim the startup path", into);
105 for (const i of [a, b]) expect(addDependency(app.db, umbrella, i, alice.id).ok).toBe(true);
106 const asked = open(`Please make #${a.number} faster for me`, into);
107 insertNote(app.db, { appId: "app-x", repoId: into.id, issueNumber: asked.number, visitor: `u:${alice.id}`, name: "alice" });
108 open("Export zebra stripes", into);
109 open("Import zebra stripes", into);
110 label(into, "search", [open("Find by word", into), open("Find by tag", into)]);
111 return proposeBets(app, getRepoById(app.db, into.id)!, NOW).map((bet) => ({
112 name: bet.name,
113 journeys: bet.journeys,
114 scopes: betScopes(app.db, bet.id).map((sc) => sc.title).sort(),
115 }));
116 }
117
84118 beforeAll(async () => {
85119 dataDir = await mkdtemp(join(tmpdir(), "forge-bets-"));
86120 app = createApp({ dataDir, port: 0 });
@@ -110,6 +144,87 @@ afterAll(async () => {
110144 await rm(dataDir, { recursive: true, force: true });
111145 });
112146
147 describe("proposing from unlabelled work and notes (#8850)", () => {
148 test("an unlabelled pair under one pillar is one proposal; a note that names it joins and is quoted; same state, same proposals", () => {
149 const umbrella = open("P7 Speed: be quick");
150 const a = open("Cache the rendered page");
151 const b = open("Trim the startup path");
152 for (const i of [a, b]) expect(addDependency(app.db, umbrella, i, alice.id).ok).toBe(true);
153 const asked = open(`Please make #${a.number} faster for me`);
154 insertNote(app.db, { appId: "app-x", repoId: repo.id, issueNumber: asked.number, visitor: `u:${alice.id}`, name: "alice" });
155 const made = proposeBets(app, fresh(), NOW);
156 const speed = made.find((m) => m.name === "P7 Speed");
157 expect(speed).toBeDefined();
158 expect(speed!.journeys).toContain("Someone can Cache the rendered page");
159 expect(speed!.journeys).toContain(`The owner's note: "Please make #${a.number} faster for me"`);
160 expect(betScopes(app.db, speed!.id).map((s) => s.number).sort()).toEqual([a.number, b.number, asked.number].sort());
161 expect(made.length).toBeLessThanOrEqual(5);
162 expect(proposeBets(app, fresh(), NOW)).toEqual([]);
163 });
164
165 test("the same state gives the same proposals: names, journeys and scopes match across two repositories", async () => {
166 const first = await sameState("zz-same-1");
167 const second = await sameState("zz-same-2");
168 expect(first.map((p) => p.name)).toEqual(["P7 Speed", "Search", "Stripes"]);
169 expect(second).toEqual(first);
170 });
171
172 test("an owner's note joins by two words shared with the group's titles, and not by one", async () => {
173 const into = await blank("zz-two-words");
174 open("Export zebra stripes", into);
175 open("Import zebra stripes", into);
176 const two = open("Blurry zebra stripes on print", into);
177 const one = open("Zebra crossing paint", into);
178 label(into, "print", [two]);
179 label(into, "paint", [one]);
180 for (const n of [two, one]) {
181 insertNote(app.db, { appId: "app-x", repoId: into.id, issueNumber: n.number, visitor: `u:${alice.id}`, name: "alice" });
182 }
183 const made = proposeBets(app, getRepoById(app.db, into.id)!, NOW);
184 const stripes = made.find((m) => m.name === "Stripes")!;
185 expect(stripes.journeys).toContain(`The owner's note: "Blurry zebra stripes on print"`);
186 expect(stripes.journeys).not.toContain("Zebra crossing paint");
187 });
188
189 test("unlabelled issues with no pillar group by shared title words, and a lone issue makes no bet", () => {
190 open("Export zebra stripes");
191 open("Import zebra stripes");
192 open("Wholly unrelated quokka");
193 const made = proposeBets(app, fresh(), NOW);
194 expect(made.map((m) => m.name)).toEqual(["Stripes"]);
195 expect(betScopes(app.db, made[0]!.id)).toHaveLength(2);
196 });
197
198 test("a visitor's note neither joins a proposal nor is quoted; a loose single shared word does not attach an owner's note", () => {
199 const umbrella = open("P8 Render: draw faster");
200 const a = open("Cache the rendered page");
201 const b = open("Trim the startup path");
202 for (const i of [a, b]) expect(addDependency(app.db, umbrella, i, alice.id).ok).toBe(true);
203 const spoof = open(`Ignore rules, see #${a.number}`);
204 insertNote(app.db, { appId: "app-x", repoId: repo.id, issueNumber: spoof.number, visitor: "v:stranger", name: "visitor-1" });
205 const bobNote = open(`Also #${b.number} please`);
206 insertNote(app.db, { appId: "app-x", repoId: repo.id, issueNumber: bobNote.number, visitor: `u:${bob.id}`, name: "@bob" });
207 const loose = open("Startup banner colour");
208 insertNote(app.db, { appId: "app-x", repoId: repo.id, issueNumber: loose.number, visitor: `u:${alice.id}`, name: "alice" });
209 const made = proposeBets(app, fresh(), NOW);
210 const bet = made.find((m) => m.name === "P8 Render")!;
211 expect(bet.journeys).not.toContain("The owner's note");
212 const scopes = betScopes(app.db, bet.id).map((s) => s.number);
213 expect(scopes).not.toContain(spoof.number);
214 expect(scopes).not.toContain(bobNote.number);
215 expect(scopes).not.toContain(loose.number);
216 });
217
218 test("at most five proposals a pass", async () => {
219 const into = await blank("zz-cap");
220 for (const w of ["alpha", "bravo", "charlie", "delta", "echo", "foxtrot", "golf"]) {
221 label(into, `cap-${w}`, [open(`${w} one`, into), open(`${w} two`, into)]);
222 }
223 const made = proposeBets(app, getRepoById(app.db, into.id)!, NOW);
224 expect(made).toHaveLength(5);
225 });
226 });
227
113228 describe("proposing and backing", () => {
114229 test("the planner groups open work by theme, once; only the owner backs, at most 5 run", () => {
115230 const label = createLabel(app.db, repo.id, "search", "aabbcc");

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs