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

On /asked, each thought says where it went, linked: the issue it became, kept as a note, or not placed yet, as MCP `thought` answers #7547

Merged opened this

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

Merged

torque/forge:torque/7509-on-asked-each-thought-says-where-it-went → main 2 commits fork 2 threads · 2 unresolved

  • Merged. torque/7509-on-asked-each-thought-says-where-it-went was merged into main as d5454e0.

Review

Sign in to review this change.
8 files changed +275 −33
src/actions/ask_state_words.ts +87
@@ -7,5 +7,92 @@
77 /** An ask the planner has not yet put on any issue, and nobody has replied to. */
88 export const NOT_PLACED_YET = "the planner has not placed it yet";
99
10 /** A thought the planner has not placed yet, as its row on /asked ends. */
11 export const NOT_PLACED = "not placed yet";

These words belong to the terminal too. Use them in src/tui/screens/asked.ts, where NOT_PLACED_YET is still used, so both doors say the same thing.

12
13 /** A thought the planner placed on no issue: a vision, a decision, a note. */
14 export const KEPT_AS_NOTE = "kept as a note";
15
1016 /** An ask whose issue has a run going and no answer yet. */
1117 export const CREW_WORKING = "The crew is working on it.";
18
19 /** Text on one line, cut at the last space before `n` characters (a hard cut when it has none). */
20 export const cut = (raw: string, n: number): string => {
21 const text = raw.replace(/\s+/g, " ").trim();
22 if (text.length <= n) return text;
23 const space = text.lastIndexOf(" ", n - 1);
24 return `${text.slice(0, space > 0 ? space : n - 1)}…`;
25 };
26
27 /** One piece of where a thought went: `lead` plain, `text` linked to `href` where the surface links. */
28 export interface WherePart {
29 readonly lead: string;
30 readonly text: string;
31 readonly href?: string;
32 }
33
34 /** What `whereItWent` reads of a thought, so the words need nothing but plain data. */
35 export interface ThoughtWhere {
36 readonly reply: string | null;
37 readonly asked: boolean;
38 readonly placedAt: string | null;
39 readonly placed: readonly {
40 readonly action: string;
41 readonly repo: string;
42 readonly number: number;
43 readonly pull?: number;
44 readonly readable: boolean;
45 readonly title: string | null;
46 }[];
47 }
48
49 const ACTED = new Set(["opened", "linked"]);
50
51 /**
52 * Where a thought went, as MCP `thought` answers it (#7509), in the same words
53 * on /asked and in the terminal: `→` each issue it placed; the planner's
54 * question when it answered with one; `kept as a note` when it placed it on
55 * none; `not placed yet` until it has.
56 */
57 export function whereItWent(t: ThoughtWhere): WherePart[] {
58 if (t.placed.length === 0) {
59 if (t.placedAt === null) return [{ lead: NOT_PLACED, text: "" }];
60 if (t.asked && t.reply) return [{ lead: t.reply, text: "" }];
61 return [{ lead: KEPT_AS_NOTE, text: "" }];
62 }
63 return t.placed.map((x) => {
64 const act = ACTED.has(x.action) ? "" : `${x.action} `;
65 if (!x.readable) return { lead: `→ ${act}in a repository you cannot read`, text: "" };
66 if (x.title === null) return { lead: `→ ${act}${x.repo}#${x.number}`, text: "" };
67 return {
68 lead: `→ ${act}`,
69 text: `${x.repo}#${x.number} ${cut(x.title, 60)}`,
70 href: `/${x.repo}/${x.pull === x.number ? "pulls" : "issues"}/${x.number}`,
71 };
72 });
73 }
74
75 /** What a thought repeats, takes back, or was taken back by: the words, and the other thought's time. */
76 export interface ThoughtNote {
77 readonly text: string;
78 readonly at: string;
79 }
80
81 interface Ref {
82 readonly text: string;
83 readonly receivedAt: string;
84 }
85
86 export function thoughtNotes(t: {
87 readonly supersededBy: Ref | null;
88 readonly repeats: Ref | null;
89 readonly supersedes: readonly Ref[];
90 }): ThoughtNote[] {
91 const notes: ThoughtNote[] = [];
92 if (t.supersededBy)
93 notes.push({ text: `superseded by “${cut(t.supersededBy.text, 60)}”`, at: t.supersededBy.receivedAt });
94 if (t.repeats) notes.push({ text: `repeats “${cut(t.repeats.text, 60)}”`, at: t.repeats.receivedAt });
95 const back = t.supersedes[0];
96 if (back) notes.push({ text: `takes back “${cut(back.text, 60)}”`, at: back.receivedAt });
97 return notes;
98 }
src/core/placements.ts +9 −3
@@ -73,11 +73,17 @@ export function isVisionPull(db: Database, repo: string, number: number): boolea
7373 return rows.some((r) => toPlacement(r).placed.some((p) => p.action === "proposed" && p.repo === repo && p.pull === number));
7474 }
7575
76 /** The placements of these thoughts: one query per chunk, never one per thought (#7509). */
7677 export function placementsOf(db: Database, thoughtIds: readonly string[]): Map<string, Placement> {
7778 const out = new Map<string, Placement>();
78 for (const id of thoughtIds) {
79 const p = placementOf(db, id);
80 if (p) out.set(id, p);
79 const unique = [...new Set(thoughtIds)];
80 const CHUNK = 400;
81 for (let i = 0; i < unique.length; i += CHUNK) {
82 const batch = unique.slice(i, i + CHUNK);
83 const rows = db
84 .query<Row, string[]>(`SELECT * FROM placement WHERE thought_id IN (${batch.map(() => "?").join(",")})`)
85 .all(...batch);
86 for (const r of rows) out.set(r.thought_id, toPlacement(r));
8187 }
8288 return out;
8389 }
src/core/thought_stream.ts +3
@@ -49,6 +49,8 @@ export interface StreamThought {
4949 readonly receivedAt: string;
5050 readonly kind: Kind | null;
5151 readonly reply: string | null;
52 /** The planner's reply is a question back to the person, not a placement. */
53 readonly asked: boolean;
5254 readonly placedAt: string | null;
5355 readonly placed: readonly PlacedLink[];
5456 readonly state: ThoughtState;
@@ -169,6 +171,7 @@ export function thoughtStream(
169171 receivedAt: t.receivedAt,
170172 kind: p?.kind ?? null,
171173 reply: p?.reply ?? null,
174 asked: p?.asked ?? false,
172175 placedAt: p?.placedAt ?? null,
173176 placed: links,
174177 state,
src/tui/screens/asked.ts +5 −5
@@ -11,7 +11,7 @@
1111 */
1212
1313 import type { Database } from "bun:sqlite";
14 import { NOT_PLACED_YET } from "../../actions/ask_state_words.ts";
14 import { thoughtNotes, whereItWent } from "../../actions/ask_state_words.ts";
1515 import { askedIn, askedRepos, outcomeText, type Asked, type AskedOrder, type AskedShow } from "../../core/asked.ts";
1616 import { thoughtStream, type StreamThought } from "../../core/thought_stream.ts";
1717 import type { User } from "../../core/users.ts";
@@ -49,15 +49,15 @@ function toneOf(flagged: boolean, state: string): { tone?: Tone } {
4949 return state === "building" ? { tone: "attn" } : {};
5050 }
5151
52 /** Where it went and what it repeats or takes back, in /asked's words (`whereItWent`, `thoughtNotes`). */
5253 function thoughtLine(t: StreamThought): AskedLine {
53 const where = t.placed.map((x) =>
54 x.readable ? `${x.action} ${x.repo} #${x.number}` : `${x.action} in a repository you cannot read`,
55 );
54 const where = whereItWent(t).map((x) => `${x.lead}${x.text}`);
55 const notes = thoughtNotes(t).map((n) => `${n.text} ${relTime(n.at)}`);
5656 return {
5757 title: plain(t.text),
5858 ...toneOf(t.flagged, t.state),
5959 hollow: t.state === "unplaced" || t.state === "superseded",
60 secondary: plain(where.length > 0 ? where.join(" · ") : (t.reply ?? NOT_PLACED_YET)),
60 secondary: plain([...where, ...notes].join(" · ")),
6161 meta: metaOf(t.flagged ? "nothing built" : THOUGHT_WORD[t.state], relTime(t.receivedAt)),
6262 };
6363 }
src/web/studio/asked_page.ts +10 −23
@@ -27,7 +27,7 @@ import {
2727 type StreamThought,
2828 type ThoughtState,
2929 } from "../../core/thought_stream.ts";
30 import { NOT_PLACED_YET } from "../../actions/ask_state_words.ts";
30 import { cut, thoughtNotes, whereItWent } from "../../actions/ask_state_words.ts";
3131 import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts";
3232 import type { Ctx } from "../../http/context.ts";
3333 import { repoBase, signInFirst } from "../chrome.ts";
@@ -53,18 +53,12 @@ const THOUGHT_WORD: Record<ThoughtState, { text: string; tone: Tone }> = {
5353 superseded: { text: "Superseded", tone: "default" },
5454 };
5555
56 /** Where a thought went: each issue it touched, linked when the viewer may read it. */
56 /** Where a thought went (`whereItWent`), each issue linked when the viewer may read it. */
5757 function placedLinks(t: StreamThought): Html {
58 const links = t.placed.map((x) =>
59 !x.readable
60 ? html`${x.action} in a repository you cannot read`
61 : x.title === null
62 ? html`${x.action} ${x.repo} #${x.number}`
63 : html`${x.action} <a href="/${x.repo}/${x.pull === x.number ? "pulls" : "issues"}/${x.number}">${x.repo} #${x.number}</a>`,
58 const parts = whereItWent(t).map((x) =>
59 x.href === undefined ? html`${x.lead}${x.text}` : html`${x.lead}<a href="${x.href}">${x.text}</a>`,
6460 );
65 return links.length === 0
66 ? html`${t.reply ?? NOT_PLACED_YET}`
67 : html`${links.map((l, i) => html`${i > 0 ? " · " : ""}${l}`)}`;
61 return html`${parts.map((l, i) => html`${i > 0 ? " · " : ""}${l}`)}`;
6862 }
6963
7064 export function thoughtRow(t: StreamThought, days: number): Html {
@@ -78,11 +72,9 @@ export function thoughtRow(t: StreamThought, days: number): Html {
7872 ? `Not placed ${days} days after it was sent`
7973 : `Nothing merged ${days} days after it was placed`
8074 : t.text,
81 meta: html`${time(t.receivedAt)} · ${t.source} · ${placedLinks(t)}${t.supersededBy
82 ? html` · superseded by “${t.supersededBy.text.slice(0, 80)}” ${time(t.supersededBy.receivedAt)}`
83 : null}${t.repeats ? html` · said again, first on ${time(t.repeats.receivedAt)}` : null}${t.supersedes.length > 0
84 ? html` · takes back “${t.supersedes[0]!.text.slice(0, 80)}” ${time(t.supersedes[0]!.receivedAt)}`
85 : null}`,
75 meta: html`${time(t.receivedAt)} · ${t.source} · ${placedLinks(t)}${thoughtNotes(t).map(
76 (n) => html` · ${n.text} ${time(n.at)}`,
77 )}`,
8678 trail: badge(word.text, { tone: word.tone }),
8779 });
8880 }
@@ -98,13 +90,8 @@ function oldestFlaggedHref(stream: readonly StreamThought[]): string | null {
9890 return null;
9991 }
10092
101 /** The ask, cut at the last space before 80 characters (a hard cut when it has none). */
102 const asking = (a: Asked): string => {
103 const text = a.issue.title.replace(/\s+/g, " ").trim();
104 if (text.length <= 80) return text;
105 const space = text.lastIndexOf(" ", 79);
106 return `${text.slice(0, space > 0 ? space : 79)}…`;
107 };
93 /** The ask, its first 80 characters. */
94 const asking = (a: Asked): string => cut(a.issue.title, 80);
10895
10996 /**
11097 * What became of the ask, as the end of its sentence, linked to the pull or
test/asked-placement.test.ts added +130
@@ -0,0 +1,130 @@
1 /** On /asked each thought says where it went (#7509): the issue, a note, or not yet, and what it repeats. */
2
3 import { afterAll, beforeAll, expect, test } from "bun:test";
4 import { mkdtemp, rm } from "node:fs/promises";
5 import { tmpdir } from "node:os";
6 import { join } from "node:path";
7 import { createApp, type App } from "../src/app.ts";
8 import { openIssue } from "../src/actions/issues.ts";
9 import { createIssue } from "../src/core/issues.ts";
10 import { createRepo } from "../src/actions/repos.ts";
11 import { placementsOf, savePlacement, type Placed } from "../src/core/placements.ts";
12 import { linkThoughts } from "../src/core/thought_links.ts";
13 import { serve } from "../src/http/server.ts";
14 import { createUser, type User } from "../src/core/users.ts";
15 import { quietLogs } from "./helpers/globals.ts";
16 import { promote } from "./helpers/members.ts";
17
18 quietLogs();
19
20 let dir = "";
21 let app: App;
22 let alice: User;
23 let bob: User;
24
25 beforeAll(async () => {
26 dir = await mkdtemp(join(tmpdir(), "asked-placement-"));
27 app = createApp({ dataDir: dir, port: 0 });
28 const mk = async (username: string, first = false) => {
29 const u = await createUser(app.db, {
30 origin: "seed", trusted: true, username, email: `${username}@forge.local`,
31 password: "a-good-passphrase", adminIfFirst: first,
32 });
33 if (!u.ok) throw new Error(u.error);
34 promote(app.db, u.value);
35 return u.value;
36 };
37 bob = await mk("bob", true); // the forge's admin, first
38 alice = await mk("alice");
39 });
40 afterAll(async () => {
41 app.db.close();
42 await rm(dir, { recursive: true, force: true });
43 });
44
45 const think = (id: string, who: User, text: string, minutesAgo: number) =>
46 app.db
47 .query("INSERT INTO thought (id, author, text, source, received_at) VALUES (?1, ?2, ?3, 'composer', ?4)")
48 .run(id, who.id, text, new Date(Date.now() - minutesAgo * 60_000).toISOString());
49 const place = (thoughtId: string, placed: Placed[], reply = { text: "ok", asked: false }) =>
50 savePlacement(app.db, {
51 thoughtId, kind: placed.length > 0 ? "work" : "decision", placed, reply: reply.text, asked: reply.asked,
52 decidedBy: "rule", placedAt: new Date().toISOString(),
53 });
54
55 async function askedAs(user: string): Promise<string> {
56 const servers = await serve(app);
57 try {
58 const signin = await fetch(`${servers.url}/signin`, {
59 method: "POST",
60 body: new URLSearchParams({ username: user, password: "a-good-passphrase" }),
61 redirect: "manual",
62 });
63 const cookie = (signin.headers.get("set-cookie") ?? "").split(";")[0]!;
64 const res = await fetch(`${servers.url}/asked`, { headers: { cookie } });
65 expect(res.status).toBe(200);
66 return (await res.text()).replace(/\s+/g, " ");
67 } finally {
68 servers.stop();
69 }
70 }
71
72 test("a placed thought links its issue, a repeat says what it repeats, a note and a waiting one say so", async () => {
73 const made = await createRepo(app, alice, { name: "gauges" });
74 if (!made.ok) throw new Error(String(made.error));
75 const issue = openIssue(app, alice, made.value, { title: "Queue <depth> gauge" });
76 if (!issue.ok) throw new Error(String(issue.error));
77 const n = issue.value.number;
78
79 think("t-first", alice, "build a gauge for queue depth on the board please", 30);
80 think("t-again", alice, "a queue depth gauge, again", 20);
81 think("t-note", alice, "we prefer plain words", 10);
82 think("t-wait", alice, "and a dark theme", 5);
83 place("t-first", [{ action: "opened", repo: "alice/gauges", number: n }]);
84 place("t-again", []);
85 linkThoughts(app.db, "t-again", "t-first", "repeats");
86 place("t-note", []);
87
88 const page = await askedAs("alice");
89 expect(page).toContain(`→ <a href="/alice/gauges/issues/${n}">alice/gauges#${n} Queue &lt;depth&gt; gauge</a>`);
90 expect(page).toContain("repeats “build a gauge for queue depth on the board please”");
91 expect(page).toContain("kept as a note");
92 expect(page).toContain("not placed yet");
93
94 // Only the author's own thoughts carry this.
95 const other = await askedAs("bob");
96 expect(other).not.toContain("alice/gauges#");
97 expect(other).not.toContain("kept as a note");
98 });
99
100 test("a question back stays visible, a proposed pull links to /pulls, an unreadable repository names nothing", async () => {
101 const vault = await createRepo(app, bob, { name: "vault", visibility: "private" });
102 if (!vault.ok) throw new Error(String(vault.error));
103 const sealed = openIssue(app, bob, vault.value, { title: "Bob's sealed plan" });
104 if (!sealed.ok) throw new Error(String(sealed.error));
105 const repo = await createRepo(app, alice, { name: "visions" });
106 if (!repo.ok) throw new Error(String(repo.error));
107 const pull = createIssue(app.db, { repoId: repo.value.id, authorId: alice.id, kind: "pull", title: "A vision for gauges" });
108 if (!pull.ok) throw new Error(String(pull.error));
109 const p = pull.value.number;
110
111 think("t-question", alice, "the thing from before", 4);
112 think("t-vision", alice, "gauges everywhere", 3);
113 think("t-hidden", alice, "the vault, too", 2);
114 place("t-question", [], { text: "Which thing do you mean — the gauge or the theme?", asked: true });
115 place("t-vision", [{ action: "proposed", repo: "alice/visions", number: p, pull: p }]);
116 place("t-hidden", [{ action: "opened", repo: "bob/vault", number: sealed.value.number }]);
117
118 const page = await askedAs("alice");
119 expect(page).toContain("Which thing do you mean — the gauge or the theme?");
120 expect(page).toContain(`→ proposed <a href="/alice/visions/pulls/${p}">alice/visions#${p} A vision for gauges</a>`);
121 expect(page).toContain("→ in a repository you cannot read");
122 expect(page).not.toContain("bob/vault");
123 expect(page).not.toContain("sealed plan");
124 });
125
126 test("the page's placements are read in one batch", () => {
127 const got = placementsOf(app.db, ["t-first", "t-note", "t-wait", "t-first"]);
128 expect([...got.keys()].sort()).toEqual(["t-first", "t-note"]);
129 expect(got.get("t-first")!.placed[0]!.repo).toBe("alice/gauges");
130 });
test/triage.test.ts +1 −1
@@ -299,7 +299,7 @@ describe("triage", () => {
299299 await placeThought(app, fresh(), alice, late);
300300 const rows = thoughtStream(app.db, alice);
301301 const repeatRow = String(thoughtRow(rows.find((t) => t.id === again.id)!, 7));
302 expect(repeatRow).toContain("said again, first on");
302 expect(repeatRow).toContain("repeats “build a &lt;b&gt;bold&lt;/b&gt; gauge for queue depth”");
303303 const reverseRow = String(thoughtRow(rows.find((t) => t.id === late.id)!, 7));
304304 expect(reverseRow).toContain("takes back");
305305 expect(reverseRow).toContain("&lt;b&gt;bold&lt;/b&gt;");
test/tui-asked.test.ts +30 −1
@@ -11,6 +11,7 @@ import { createRepo } from "../src/actions/repos.ts";
1111 import { createApp, type App } from "../src/app.ts";
1212 import { createIssue, setIssueState } from "../src/core/issues.ts";
1313 import { savePlacement } from "../src/core/placements.ts";
14 import { linkThoughts } from "../src/core/thought_links.ts";
1415 import { createUser, type User } from "../src/core/users.ts";
1516 import { createForgeApp, forgeCommands, Session } from "../src/tui/forge.ts";
1617 import { askedLines, askedScreen } from "../src/tui/screens/asked.ts";
@@ -111,7 +112,7 @@ describe("the asked screen", () => {
111112
112113 test("a placement in an unreadable repository shows no slug or state", async () => {
113114 const text = await screenText(alice);
114 expect(text).toContain("opened in a repository you cannot read");
115 expect(text).toContain("→ in a repository you cannot read");
115116 expect(text).not.toContain("bob/vault");
116117 });
117118
@@ -174,4 +175,32 @@ describe("the asked screen", () => {
174175 expect(ids(alice)).toContain("asked");
175176 expect(ids()).not.toContain("asked");
176177 });
178 test("a thought says where it went in /asked's words: the issue, the question back, a note, or not yet (#7509)", () => {
179 const lobby = app.db.query<{ id: string }, []>("SELECT id FROM repo WHERE name = 'lobby'").get()!;
180 const issue = createIssue(app.db, { repoId: lobby.id, authorId: alice.id, title: "Louder lobby" });
181 if (!issue.ok) throw new Error(issue.error);
182 const n = issue.value.number;
183 const placed = (thoughtId: string, reply: string, asked: boolean, number?: number) =>
184 savePlacement(app.db, {
185 thoughtId, kind: number === undefined ? "decision" : "work", asked, decidedBy: "rule", reply,
186 placed: number === undefined ? [] : [{ action: "opened", repo: "alice/lobby", number }],
187 placedAt: new Date().toISOString(),
188 });
189 thought(alice, "t4", "a louder lobby, again");
190 thought(alice, "t5", "which one was it");
191 thought(alice, "t6", "we like plain words");
192 placed("t1", "ok", false, n);
193 placed("t4", "ok", false, n);
194 linkThoughts(app.db, "t4", "t1", "repeats");
195 placed("t5", "Do you mean the lobby or the vault?", true);
196 placed("t6", "ok", false);
197 thought(alice, "t7", "and a dark theme");
198
199 const by = new Map(askedLines(app.db, alice).thoughts.map((t) => [t.title, t.secondary]));
200 expect(by.get("make the lobby louder")).toBe(`→ alice/lobby#${n} Louder lobby`);
201 expect(by.get("a louder lobby, again")).toStartWith(`→ alice/lobby#${n} Louder lobby · repeats “make the lobby louder”`);
202 expect(by.get("which one was it")).toBe("Do you mean the lobby or the vault?");
203 expect(by.get("we like plain words")).toBe("kept as a note");
204 expect(by.get("and a dark theme")).toBe("not placed yet");
205 });
177206 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs