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

Triage: the planner places every thought within minutes and answers in the feed where it went and what it changed #1739

Merged opened this in answer to

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

Merged

scout/forge:scout/652-triage-the-planner-places-every-thought → main 4 commits fork 5 threads · 5 unresolved

  • Merged. scout/652-triage-the-planner-places-every-thought was merged into main as 7b08b63.

Review

Sign in to review this change.
11 files changed +718 −3
README.md +3
@@ -556,6 +556,9 @@ an app, it keeps what you typed as a thought, exactly as typed (works with no
556556 apps too). Your thoughts from the last day stand at the top of the feed as
557557 your own posts, marked "Received". The terminal has no feed or composer, so
558558 thoughts are web-only there; `POST /api/v1/thoughts` is the other way in.
559 Within minutes the planner places each thought on the map and answers under it
560 in the feed, saying where it went; the MCP `thought` tool returns that
561 placement to the thought's author.
559562
560563 ## Link-preview cards
561564
scripts/gate.ts +1
@@ -36,6 +36,7 @@ export const GATE: readonly string[] = [
3636 "test/boards-query.test.ts",
3737 "test/turnstile.test.ts",
3838 "test/thoughts.test.ts",
39 "test/triage.test.ts",
3940 "test/passkeys.test.ts",
4041 "test/webauthn.test.ts",
4142 "test/chaos.test.ts",
src/core/placements.ts added +109
@@ -0,0 +1,109 @@
1 /**
2 * Where a thought went (#652). The planner writes one placement per
3 * thought, once: its kind, what it did on the map, and the reply that
4 * shows under the thought in the feed. The thought itself is never touched.
5 */
6
7 import type { Database } from "bun:sqlite";
8
9 export const KINDS = ["work", "amendment", "vision", "decision", "reversal"] as const;
10 export type Kind = (typeof KINDS)[number];
11
12 export type Action = "opened" | "amended" | "deferred" | "superseded" | "linked";
13
14 /** One thing done on the map: an issue of `repo` (owner/name), and the pillar it serves. */
15 export interface Placed {
16 action: Action;
17 repo: string;
18 number: number;
19 pillar?: string;
20 }
21
22 export interface Placement {
23 thoughtId: string;
24 kind: Kind;
25 placed: Placed[];
26 /** One or two lines; with a short question when the thought was ambiguous. */
27 reply: string;
28 asked: boolean;
29 decidedBy: string;
30 placedAt: string;
31 }
32
33 interface Row {
34 thought_id: string;
35 kind: string;
36 placed: string;
37 reply: string;
38 question: number;
39 decided_by: string;
40 placed_at: string;
41 }
42
43 export function savePlacement(db: Database, p: Placement): void {
44 db.query(
45 "INSERT OR IGNORE INTO placement (thought_id, kind, placed, reply, question, decided_by, placed_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
46 ).run(p.thoughtId, p.kind, JSON.stringify(p.placed), p.reply, p.asked ? 1 : 0, p.decidedBy, p.placedAt);
47 }
48
49 function toPlacement(r: Row): Placement {
50 return {
51 thoughtId: r.thought_id,
52 kind: r.kind as Kind,
53 placed: JSON.parse(r.placed) as Placed[],
54 reply: r.reply,
55 asked: r.question === 1,
56 decidedBy: r.decided_by,
57 placedAt: r.placed_at,
58 };
59 }
60
61 export function placementOf(db: Database, thoughtId: string): Placement | undefined {
62 const row = db.query<Row, [string]>("SELECT * FROM placement WHERE thought_id = ?1").get(thoughtId);
63 return row ? toPlacement(row) : undefined;
64 }
65
66 export function placementsOf(db: Database, thoughtIds: readonly string[]): Map<string, Placement> {
67 const out = new Map<string, Placement>();
68 for (const id of thoughtIds) {
69 const p = placementOf(db, id);
70 if (p) out.set(id, p);
71 }
72 return out;
73 }
74
75 /** The thought as MCP and the API show it: the placement, or `null` while it waits. */
76 export function placementJson(p: Placement | undefined): Record<string, unknown> | null {
77 return p
78 ? { kind: p.kind, placed: p.placed, reply: p.reply, asked: p.asked, decidedBy: p.decidedBy, placedAt: p.placedAt }
79 : null;
80 }
81
82 /** Thoughts of one author with no placement yet, received at or after `since`, oldest first. */
83 export function unplaced(db: Database, authorId: string, since: string, limit: number): { id: string; text: string; receivedAt: string }[] {
84 return db
85 .query<{ id: string; text: string; received_at: string }, [string, string, number]>(
86 `SELECT t.id, t.text, t.received_at FROM thought t
87 WHERE t.author = ?1 AND t.received_at >= ?2
88 AND NOT EXISTS (SELECT 1 FROM placement p WHERE p.thought_id = t.id)
outdated

High: this returns the oldest 30 regardless of age, and triageTick drops the stale ones afterwards. With 30 or more stale unplaced thoughts (they are never placed, and they are kept forever) the author is starved permanently. Filter t.received_at >= ?since here.

89 ORDER BY t.received_at, t.id LIMIT ?3`,
90 )
91 .all(authorId, since, limit)
92 .map((r) => ({ id: r.id, text: r.text, receivedAt: r.received_at }));
93 }
94
outdated

This scans every thought ever written, each tick, and stale ones never leave the set. Bound it with the same since cutoff. The thought_author(author, received_at) index helps once the cutoff is in the query.

95 /** Authors with a thought at or after `since` that has no placement yet. */
96 export function authorsWithUnplaced(db: Database, since: string): string[] {
97 return db
98 .query<{ author: string }, [string]>(
99 `SELECT DISTINCT t.author FROM thought t
100 WHERE t.received_at >= ?1 AND NOT EXISTS (SELECT 1 FROM placement p WHERE p.thought_id = t.id)`,
101 )
102 .all(since)
103 .map((r) => r.author);
104 }
105
106 /** A thought's author, when it exists. */
107 export function thoughtAuthor(db: Database, thoughtId: string): string | undefined {
108 return db.query<{ author: string }, [string]>("SELECT author FROM thought WHERE id = ?1").get(thoughtId)?.author;
109 }
src/core/schema.ts +14
@@ -1721,6 +1721,20 @@ export const migrations: readonly string[] = [
17211721 );
17221722 CREATE INDEX release_created ON release(created_at);
17231723 `,
1724 // 84 — placements (#652): where the planner put each thought. One row per
1725 // thought, written once; the thought itself stays as typed. `placed` is a
1726 // JSON list of what was done (opened, amended, deferred, superseded).
1727 `
1728 CREATE TABLE placement (
1729 thought_id TEXT PRIMARY KEY,
1730 kind TEXT NOT NULL CHECK (kind IN ('work', 'amendment', 'vision', 'decision', 'reversal')),
1731 placed TEXT NOT NULL,
1732 reply TEXT NOT NULL,
1733 question INTEGER NOT NULL DEFAULT 0,
1734 decided_by TEXT NOT NULL,
1735 placed_at TEXT NOT NULL
1736 );
1737 `,
17241738 ];
17251739
17261740 /**
src/crew/autonomy.ts +7
@@ -43,6 +43,7 @@ import { getHold } from "../core/holds.ts";
4343 import { certain } from "./decide.ts";
4444 import { decideAndRecord } from "./ledger.ts";
4545 import { sentinelTick } from "./sentinel.ts";
46 import { triageTick } from "./triage.ts";
4647 import { wallOf, wallSentence } from "../contract/policy.ts";
4748 import {
4849 getIssue,
@@ -1069,6 +1070,12 @@ export async function autonomyTick(app: App, now = new Date()): Promise<void> {
10691070 });
10701071 }
10711072 }
1073 // Every thought is placed within minutes (`crew/triage.ts`).
1074 try {
1075 await triageTick(app, now);
1076 } catch (error) {
1077 log.error("triage failed", { error: String(error) });
1078 }
10721079 // The forge hunts its own faults on the same clock (`crew/sentinel.ts`).
10731080 try {
10741081 sentinelTick(app, now.getTime());
src/crew/triage.ts added +325
@@ -0,0 +1,325 @@
1 /**
2 * Triage (#652): the planner places every thought. A person streams
3 * thoughts; within minutes each is read as one of five kinds (work, an
4 * amendment to an open issue, a vision, a decision, a reversal), acted on
5 * in the map, and answered in a line or two under the thought in the feed.
6 *
7 * The kind, the issue it concerns and the pillar are chosen by the decider
8 * among fixed choices (`decide.ts`); a rule answers when it is absent, slow
9 * or wrong. Nothing here is a run: it spends no builder slot, and stops
10 * where the day's ceiling stops the repository. Thoughts that arrive within
11 * `BATCH_MS` of each other are placed together, in the order they came, so
12 * a later one that reverses an earlier one sees what the earlier did.
13 */
14
15 import type { App } from "../app.ts";
16 import { addComment } from "../actions/issues.ts";
17 import { openPlan } from "../actions/plan.ts";
18 import { addDependency } from "../core/dependencies.ts";
19 import { getIssue, MAX_TITLE, type Issue } from "../core/issues.ts";
20 import {
21 authorsWithUnplaced,
22 KINDS,
23 savePlacement,
24 thoughtAuthor,
25 unplaced,
26 type Kind,
27 type Placed,
28 type Placement,
29 } from "../core/placements.ts";
30 import { isStructural, pillarsOf, type Pillars } from "../core/pillars.ts";
31 import { listAutonomousRepos, type Repo } from "../core/repos.ts";
32 import { getUserById, type User } from "../core/users.ts";
33 import { createLog } from "../kernel/log.ts";
34 import { autonomyStatus, ensureDirectionIssue } from "./autonomy.ts";
35 import { certain, decideOr, type Answers, type Questions } from "./decide.ts";
36 import { crewOffInPreview } from "./preview.ts";
37
38 const log = createLog("triage");
39
40 /** Thoughts this close together are placed in one pass. */
41 export const BATCH_MS = 2 * 60_000;
42 /** The most thoughts one pass places. */
43 export const BATCH_MAX = 10;
44 /** Under this probability the kind is a guess and the reply asks. */
45 export const SURE = 0.6;
46 /** A thought older than this is no longer the planner's to place. */
47 const STALE_MS = 24 * 60 * 60_000;
48 const OPEN_SHOWN = 40;
49 const TEXT_SHOWN = 2_000;
50
51 const STOP = new Set("the a an and or to of in on for with is are be it this that we i you should would could can will not do so as at by from have has just also".split(" "));
52 const words = (text: string): Set<string> =>
53 new Set((text.toLowerCase().match(/[a-z][a-z0-9-]{2,}/g) ?? []).filter((w) => !STOP.has(w)));
54 const overlap = (a: Set<string>, b: Set<string>): number => [...a].filter((w) => b.has(w)).length;
55
56 interface Open {
57 readonly number: number;
58 readonly title: string;
59 }
60
61 /** What the rule reads a thought as, with how sure it is. */
62 export function ruleKind(text: string, hasOpen: boolean): { kind: Kind; p: number } {
63 const t = text.toLowerCase();
64 if (/\b(actually|never ?mind|scrap (that|this)|forget (that|this|what)|instead of|revert|undo|reverse|stop (doing|building|working)|changed my mind|no longer)\b/.test(t))

Medium, not blocking. When no decider is configured, this regex alone decides that a thought is a reversal, at p=0.7, which counts as sure. It matches undo, reverse, instead of and no longer, and those words show up in ordinary feature requests: "add an undo button to the editor", "use a list instead of a grid". targetOf then picks any open issue that shares two title words, and that issue gets superseded and put behind the Later gate with no question asked. The damage can be undone, but it happens silently. A good follow-up: either keep the rule's reversal below SURE, so the reply always asks, or require an explicit #N before a rule-only reversal touches an issue.

65 return { kind: "reversal", p: 0.7 };
66 if (/\b(vision|direction|long[- ]term|from now on|north star|should become|the goal|where this is going)\b/.test(t))
67 return { kind: "vision", p: 0.7 };
68 if (/\b(decided|decision|we will|we'll go with|let's go with|go with|the answer is|use \S+ not)\b/.test(t))
69 return { kind: "decision", p: 0.7 };
70 if (hasOpen && /#\d+\b|\b(also|in addition|while you're at it|one more thing|add to)\b/.test(t))
71 return { kind: "amendment", p: 0.65 };
72 if (words(text).size < 3) return { kind: "work", p: 0.4 };
73 return { kind: "work", p: 0.7 };
74 }
75
76 function targetOf(text: string, open: readonly Open[]): Open | undefined {
77 for (const m of text.matchAll(/#(\d+)\b/g)) {
78 const hit = open.find((o) => o.number === Number(m[1]));
79 if (hit) return hit;
80 }
81 const mine = words(text);
82 let best: Open | undefined;
83 let most = 1;
84 for (const o of open) {
85 const n = overlap(mine, words(o.title));
86 if (n > most) (best = o), (most = n);
87 }
88 return best;
89 }
90
91 function pillarOf(text: string, set: Pillars): string | undefined {
92 if (set.pillars.length === 0) return undefined;
93 const mine = words(text);
94 let best = set.pillars[0]!;
95 let most = 0;
96 for (const p of set.pillars) {
97 const n = overlap(mine, words(`${p.name} ${p.umbrella.title}`));
98 if (n > most) (best = p), (most = n);
99 }
100 return best.short;
101 }
102
103 const clip = (text: string, max: number): string => {
104 const flat = text.replace(/\s+/g, " ").trim();
105 return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
106 };
107 const quote = (text: string): string => text.split("\n").map((l) => `> ${l}`).join("\n");
108
109 function openIssues(app: App, repo: Repo, set: Pillars, direction: Issue | undefined): Open[] {
110 return app.db
111 .query<{ id: string; number: number; title: string }, [string, number]>(
112 "SELECT id, number, title FROM issue WHERE repo_id = ?1 AND kind = 'issue' AND state = 'open' ORDER BY number DESC LIMIT ?2",
113 )
114 .all(repo.id, OPEN_SHOWN)
115 .filter((i) => i.id !== direction?.id && !isStructural(set, i.id))
116 .map((i) => ({ number: i.number, title: i.title }));
117 }
118
119 /** Where the author's thoughts go: their autonomous repository, "forge" first. */
120 export function homeOf(app: App, authorId: string): Repo | undefined {
121 const mine = listAutonomousRepos(app.db).filter(
122 (r) => r.ownerId === authorId && r.autonomyBy !== null,
123 );
124 return mine.find((r) => r.name === "forge") ?? mine[0];
125 }
126
127 export interface Thought {
128 readonly id: string;
129 readonly text: string;
130 }
131
132 /** Place one thought. Always answers: a best placement, and a question when unsure. */
133 export async function placeThought(
134 app: App,
135 repo: Repo,
136 by: User,
137 thought: Thought,
138 now = new Date(),
139 ): Promise<Placement> {
140 const slug = `${repo.ownerName}/${repo.name}`;
141 const set = pillarsOf(app.db, repo);
142 const direction = ensureDirectionIssue(app, by, repo);
143 const open = openIssues(app, repo, set, direction);
144 const text = thought.text;
145 const ref = `From thought \`${thought.id}\`:`;
146
147 // The same words again are the same thought: link, never duplicate.
148 const before = app.db
149 .query<{ thought_id: string; kind: string; placed: string }, [string, string]>(
150 `SELECT p.thought_id, p.kind, p.placed FROM placement p JOIN thought t ON t.id = p.thought_id
151 WHERE t.text = ?1 AND t.id != ?2 AND t.author = (SELECT author FROM thought WHERE id = ?2)
152 ORDER BY p.placed_at DESC LIMIT 1`,
153 )
154 .get(text, thought.id);
155 const earlier = before ? (JSON.parse(before.placed) as Placed[])[0] : undefined;
156 if (before && earlier)
157 return finish(app, thought, before.kind as Kind, "rule", false, [{ ...earlier, action: "linked" }],
158 `Same as an earlier thought; it is already at #${earlier.number}.`);
159
160 const ruled = ruleKind(text, open.length > 0);
161 const ruledTarget = targetOf(text, open);
162 const ruledPillar = pillarOf(text, set);
163 const questions: Record<string, { options: readonly string[]; about: string }> = {
164 kind: {
165 options: KINDS,
166 about:
167 "What is this thought? work: something new to build. amendment: a change to an open issue. vision: a change of direction. decision: a choice already made. reversal: it takes back an earlier thought or issue.",
168 },
169 };
170 if (open.length > 0)
171 questions["target"] = {
172 options: [...open.map((o) => `#${o.number}`), "none"],
173 about: "Which open issue does this thought concern, if any?",
174 };
175 if (set.pillars.length > 0)
176 questions["pillar"] = {
177 options: set.pillars.map((p) => p.short),
178 about: "Which pillar does work from this thought serve?",
179 };
180 const rule = (): Answers<Questions> => {
181 const out: Record<string, ReturnType<typeof certain>> = {
182 kind: {
183 choice: ruled.kind,
184 p: ruled.p,
185 probabilities: Object.fromEntries(KINDS.map((k) => [k, k === ruled.kind ? ruled.p : (1 - ruled.p) / 4])),
186 },
187 };
188 if (questions["target"]) out["target"] = certain(questions["target"].options, ruledTarget ? `#${ruledTarget.number}` : "none");
189 if (questions["pillar"]) out["pillar"] = certain(questions["pillar"].options, ruledPillar!);
190 return out;
191 };
192 const decided = await decideOr(
193 app.config.decider,
194 {
195 thought: clip(text, TEXT_SHOWN),
196 pillars: set.pillars.map((p) => p.name),
197 open: open.map((o) => `#${o.number} ${clip(o.title, 120)}`),
198 },
199 questions,
200 rule,
201 );
202 let kind = decided.answers["kind"]!.choice as Kind;
203 const sure = decided.answers["kind"]!.p >= SURE;
204 const pickedTarget = decided.answers["target"]?.choice;
205 const target = open.find((o) => `#${o.number}` === pickedTarget);
206 const pillar = decided.answers["pillar"]?.choice;
207
208 const placed: Placed[] = [];
209 const say: string[] = [];
210 let question = sure ? "" : `I read this as ${kind === "amendment" ? "an amendment" : `a ${kind}`}; is that right?`;
211 const comment = (issue: Issue, lead: string): void => {
212 const done = addComment(app, by, repo, issue, `${lead}\n\n${ref}\n\n${quote(text)}`);
213 if (!done.ok) log.warn("could not comment", { issue: issue.number, error: String(done.error) });
214 };
215 const here = (n: number): Issue | undefined => getIssue(app.db, repo.id, n);
216
217 if ((kind === "amendment" || kind === "reversal") && !target) {
218 // Nothing to amend or take back was named: keep the words where they will be seen.
219 question = kind === "reversal" ? "Which issue does this take back?" : "Is this an amendment to an open issue? Which one?";
220 kind = kind === "reversal" ? "decision" : "work";
221 }
222 if (kind === "work") {
223 const first = text.trim().split("\n")[0] ?? "";
224 const opened = openPlan(app, by, repo, [
225 {
226 key: "thought",
227 title: clip(first, Math.min(MAX_TITLE, 120)) || "Thought",
228 body: `${ref}\n\n${quote(text)}${pillar ? `\n\nServes ${set.pillars.find((p) => p.short === pillar)?.name}.` : ""}`,
229 blockedBy: [],
230 ...(pillar ? { pillar } : {}),
231 },
232 ]);
233 if (opened.ok) {
234 const number = opened.value.opened[0]!.number;
235 placed.push({ action: "opened", repo: slug, number, ...(pillar ? { pillar } : {}) });
236 const name = set.pillars.find((p) => p.short === pillar)?.name;
237 say.push(`Opened #${number}${name ? ` under ${name}` : ""}.`);
238 } else if (direction) {
239 comment(direction, "A thought that could not be opened as an issue:");
240 placed.push({ action: "amended", repo: slug, number: direction.number });
241 say.push(`Could not open it as an issue (${opened.error.message}); kept on #${direction.number}.`);
242 }
243 } else if (kind === "amendment" && target) {
244 const issue = here(target.number);
245 if (issue) {
246 comment(issue, "Amended by a new thought:");
247 placed.push({ action: "amended", repo: slug, number: issue.number });
248 say.push(`Added to #${issue.number}.`);
249 }
250 } else if (kind === "reversal" && target) {
251 const issue = here(target.number);
252 if (issue) {
253 comment(issue, "Superseded by a later thought; this is not to be built as written:");
254 const gate = set.gate;
255 const deferred = gate && issue.id !== gate.id && addDependency(app.db, issue, gate, by.id).ok;
256 if (deferred && gate)
257 app.bus.append({ kind: "issue.blocked", repoId: repo.id, actorId: by.id, payload: { number: issue.number, blockedBy: gate.number } });
258 placed.push({ action: deferred ? "deferred" : "superseded", repo: slug, number: issue.number });
259 say.push(deferred ? `Superseded #${issue.number} and put it behind the Later gate.` : `Marked #${issue.number} superseded.`);
260 }
261 } else if (direction) {
262 comment(direction, kind === "vision" ? "The direction changes:" : "A decision:");
263 placed.push({ action: "amended", repo: slug, number: direction.number });
264 say.push(`${kind === "vision" ? "Noted as direction" : "Recorded as a decision"} on #${direction.number}.`);
265 }
266 if (say.length === 0) say.push("Kept; nothing on the map changed.");
267 return finish(app, thought, kind, decided.by, question !== "", placed, [say.join(" "), question].filter(Boolean).join(" "), now);
268 }
269
270 function finish(
271 app: App,
272 thought: Thought,
273 kind: Kind,
274 decidedBy: string,
275 asked: boolean,
276 placed: Placed[],
277 reply: string,
278 now = new Date(),
279 ): Placement {
280 const placement: Placement = {
281 thoughtId: thought.id,
282 kind,
283 placed,
284 reply,
285 asked,
286 decidedBy,
287 placedAt: now.toISOString(),
288 };
289 savePlacement(app.db, placement);
290 const author = thoughtAuthor(app.db, thought.id);
291 app.bus.append({ kind: "thought.placed", ...(author ? { actorId: author } : {}), payload: { id: thought.id, kind } });
292 return placement;
293 }
294
295 /** One turn of triage: every author's quiet batch of thoughts, placed. Returns how many. */
296 export async function triageTick(app: App, now = new Date()): Promise<number> {
297 if (crewOffInPreview()) return 0;
298 let placedCount = 0;
299 // Thoughts older than a day are never placed; the cutoff is in the query so they cannot crowd out fresh ones.
300 const since = new Date(now.getTime() - STALE_MS).toISOString();
301 for (const authorId of authorsWithUnplaced(app.db, since)) {
302 try {
outdated

The stale filter comes after the LIMIT, so it can empty waiting forever. Pass the cutoff (now - STALE_MS) into unplaced instead, and add a test with 31 old thoughts plus 1 fresh one.

303 const repo = homeOf(app, authorId);
304 const by = repo?.autonomyBy ? getUserById(app.db, repo.autonomyBy) : undefined;
305 if (!repo || !by) continue;
306 const waiting = unplaced(app.db, authorId, since, BATCH_MAX * 3);
307 const oldest = waiting[0];
308 if (!oldest) continue;
309 const first = new Date(oldest.receivedAt).getTime();
310 // The batch closes once its first thought is two minutes old.
311 if (now.getTime() - first < BATCH_MS) continue;
312 // Over the day's ceiling the planner waits; so does the repository.
313 const paused = autonomyStatus(app, repo, now).paused;
314 if (paused === "ceiling" || paused === "forge ceiling") continue;
315 const batch = waiting.filter((t) => new Date(t.receivedAt).getTime() - first <= BATCH_MS).slice(0, BATCH_MAX);
316 for (const t of batch) {
317 await placeThought(app, repo, by, t, now);
318 placedCount++;
319 }
320 } catch (error) {
321 log.error("triage failed", { author: authorId, error: String(error) });
322 }
323 }
324 return placedCount;
325 }
src/http/mcp.ts +21
@@ -49,6 +49,7 @@ import {
4949 cardFor,
5050 } from "../web/studio/cards.ts";
5151 import { keepThought } from "../actions/thought.ts";
52 import { placementJson, placementOf, thoughtAuthor } from "../core/placements.ts";
5253 import { tell, toldWords, UNVERIFIED_WORDS } from "../actions/tell.ts";
5354 import { readReturns, reportSpend } from "../actions/returns.ts";
5455 import {
@@ -1059,6 +1060,26 @@ const TOOLS: readonly Tool[] = [
10591060 return good({ steps: steps ?? [] });
10601061 },
10611062 },
1063 {
1064 name: "thought",
1065 description:
1066 "Where one of your thoughts went: its id (from `tell` without a repo) answers with the planner's placement — the kind (work, amendment, vision, decision, reversal), the issues it opened, amended, deferred or marked superseded, and the one-or-two-line reply, with a question when it was unsure. `placement` is null while the planner has not reached it (within a few minutes).",
1067 inputSchema: {
1068 type: "object",
1069 properties: { id: { type: "string", maxLength: 64 } },
1070 required: ["id"],
1071 },
1072 writes: false,
1073 run(ctx, user, args) {
1074 const thoughtId = args["id"];
1075 if (typeof thoughtId !== "string" || thoughtId === "")
1076 return bad("invalid", "id is a thought id");
1077 // Not yours and not there answer alike (a thought belongs to its author).
1078 if (thoughtAuthor(ctx.app.db, thoughtId) !== user.id)
1079 return bad("not_found", "no such thought");
1080 return good({ id: thoughtId, placement: placementJson(placementOf(ctx.app.db, thoughtId)) });
1081 },
1082 },
10621083 {
10631084 name: "asked",
10641085 description:
src/web/studio/feed.ts +16 −3
@@ -11,6 +11,7 @@
1111 import { marksByName, type Mark } from "../../core/marks.ts";
1212 import type { User } from "../../core/users.ts";
1313 import type { Ctx } from "../../http/context.ts";
14 import { placementsOf, type Placement } from "../../core/placements.ts";
1415 import { recentThoughts, type Thought } from "../../core/thoughts.ts";
1516 import { readFeed } from "../../feed/feed.ts";
1617 import type { FeedItem } from "../../feed/rank.ts";
@@ -22,7 +23,7 @@ import { writableAppsOf } from "./asks.ts";
2223
2324 /** Events that can change what the feed says. */
2425 export const FEED_LIVE =
25 "run.finished,issue.opened,issue.commented,pull.commented,pull.opened,pull.merged,pull.reviewed,pull.closed,look.taken,app.serving,issue.blocked,issue.unblocked,issue.closed,decision.*,thought.received";
26 "run.finished,issue.opened,issue.commented,pull.commented,pull.opened,pull.merged,pull.reviewed,pull.closed,look.taken,app.serving,issue.blocked,issue.unblocked,issue.closed,decision.*,thought.received,thought.placed";
2627
2728 export const FEED_FIRST_PAGE = 20;
2829
@@ -106,7 +107,7 @@ const THOUGHT_AGE_MS = 24 * 60 * 60 * 1000;
106107 * A thought as the owner's post: their own words, verbatim (escaped here,
107108 * once; line breaks kept by CSS), marked "received". It has no controls.
108109 */
109 export function thoughtCard(thought: Thought, who: string): Html {
110 export function thoughtCard(thought: Thought, who: string, placement?: Placement): Html {
110111 return html`<article class="fcard fcard-thought" data-kind="thought" data-thought="${thought.id}">
111112 <div class="fcard-head">
112113 ${avatar(who)}
@@ -116,9 +117,20 @@ export function thoughtCard(thought: Thought, who: string): Html {
116117 <span class="badge">Received</span>
117118 </div>
118119 <p class="fcard-reason fcard-text">${thought.text}</p>
120 ${placement ? replyUnder(placement) : ""}
119121 </article>`;
120122 }
121123
124 /** The planner's answer under a thought: one or two lines, `#N` linking to the issue it names. */
125 export function replyUnder(placement: Placement): Html {
126 const where = new Map(placement.placed.map((p) => [p.number, p.repo]));
127 const parts = placement.reply.split(/(#\d+)/).map((part) => {
128 const repo = /^#(\d+)$/.test(part) ? where.get(Number(part.slice(1))) : undefined;
129 return repo === undefined ? part : html`<a href="/${repo}/issues/${part.slice(1)}">${part}</a>`;
130 });
131 return html`<p class="fcard-reply small" data-placement="${placement.kind}"><span class="muted">Planner · ${placement.kind}</span> ${parts}</p>`;
132 }
133
122134 /** The longest tell the composer takes (its textarea's `maxlength`). */
123135 const TELL_MAX = 2000;
124136
@@ -195,6 +207,7 @@ export function feedRegion(user: User, ctx: Ctx): Html {
195207 (item) => item.kind !== "ask",
196208 );
197209 const thoughts = recentThoughts(ctx.app.db, user.id, THOUGHTS_SHOWN, new Date(Date.now() - THOUGHT_AGE_MS).toISOString());
210 const placed = placementsOf(ctx.app.db, thoughts.map((t) => t.id));
198211 // Who each poster is, asked once for the page (`core/marks.ts`).
199212 const marks = marksByName(
200213 ctx.app.db,
@@ -207,7 +220,7 @@ export function feedRegion(user: User, ctx: Ctx): Html {
207220 data-live="${FEED_LIVE}"
208221 data-live-src="/live/feed"
209222 >
210 ${thoughts.map((t) => thoughtCard(t, user.username))}
223 ${thoughts.map((t) => thoughtCard(t, user.username, placed.get(t.id)))}
211224 ${items.length === 0 && thoughts.length === 0
212225 ? empty({
213226 icon: "activity",
src/web/studio/feed_style.ts +2
@@ -9,6 +9,8 @@ export const feedCss = /* css */ `
99 .fcard-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
1010 .fcard-reason { margin: 8px 0 4px; font-size: 1.05em; overflow-wrap: anywhere; }
1111 .fcard-text { white-space: pre-wrap; }
12 /* The planner's reply sits under the thought, indented, one or two lines. */
13 .fcard-reply { margin: 4px 0 0 12px; padding-left: 10px; border-left: 2px solid var(--border); overflow-wrap: anywhere; }
1214 .fcard-who { font-weight: 600; }
1315 /* One quiet control under the post: say something, dismiss, mute. */
1416 .fcard-more summary { cursor: pointer; list-style: none; padding: 2px 0; }
test/mcp.test.ts +16
@@ -17,6 +17,7 @@ import { getRepoById, type Repo } from "../src/core/repos.ts";
1717 import { createRun, listRuns } from "../src/core/runs.ts";
1818 import { createToken } from "../src/core/tokens.ts";
1919 import { createUser, type User } from "../src/core/users.ts";
20 import { savePlacement } from "../src/core/placements.ts";
2021 import { serve, type Servers } from "../src/http/server.ts";
2122 import { limitChanges, limitValue } from "../src/settings/limits.ts";
2223
@@ -181,6 +182,7 @@ describe("the forge over MCP", () => {
181182 "take_updates",
182183 "converge",
183184 "next_steps",
185 "thought",
184186 "asked",
185187 "app_members",
186188 "explore",
@@ -265,6 +267,20 @@ describe("the forge over MCP", () => {
265267 ).toContain("not_found");
266268 });
267269
270 test("thought returns its placement to its author only (#652)", async () => {
271 const kept = await tool(rw, "tell", { text: "build a thing" });
272 const id = kept.value.thought;
273 expect((await tool(rw, "thought", { id })).value).toEqual({ id, placement: null });
274 savePlacement(app.db, {
275 thoughtId: id, kind: "work", placed: [{ action: "opened", repo: "alice/game", number: 3 }],
276 reply: "Opened #3.", asked: false, decidedBy: "rule", placedAt: new Date().toISOString(),
277 });
278 const got = await tool(rw, "thought", { id });
279 expect(got.value.placement).toMatchObject({ kind: "work", reply: "Opened #3.", asked: false });
280 expect((await tool(bobRw, "thought", { id })).error).toContain("not_found");
281 expect((await tool(rw, "thought", { id: "nope" })).error).toContain("not_found");
282 });
283
268284 test("tell is the studio's tell: an issue, a builder, a run", async () => {
269285 const empty = await tool(rw, "tell", { repo: "alice/game", text: " " });
270286 expect(empty.error).toContain("empty");
test/triage.test.ts added +204
@@ -0,0 +1,204 @@
1 /**
2 * Triage (#652): each kind of thought is placed on the map and answered in
3 * a line, an unsure one gets a question, thoughts come in batches, and the
4 * placement is readable (MCP `thought`, the feed). The decider is a stub.
5 */
6 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
7 import { mkdtemp, rm } from "node:fs/promises";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import { createApp, type App } from "../src/app.ts";
11 import { openIssue } from "../src/actions/issues.ts";
12 import { createRepo, updateRepoSettings } from "../src/actions/repos.ts";
13 import { keepThought } from "../src/actions/thought.ts";
14 import { listComments, getIssue, getIssueById } from "../src/core/issues.ts";
15 import { placementOf } from "../src/core/placements.ts";
16 import { getRepoById, type Repo } from "../src/core/repos.ts";
17 import { createUser, type User } from "../src/core/users.ts";
18 import { BATCH_MS, placeThought, ruleKind, triageTick } from "../src/crew/triage.ts";
19 import type { Decider } from "../src/crew/decide.ts";
20 import { replyUnder } from "../src/web/studio/feed.ts";
21
22 /** Answers by the first word of the thought: `kind:p:#target:pillar`. */
23 const stub = (script: Record<string, [string, number, string?, string?]>): Decider => ({
24 name: "stub",
25 async decide(state, questions) {
26 const text = (state as { thought: string }).thought;
27 const [kind, p, target, pillar] = script[text.split(/\W/)[0]!] ?? ["work", 0.9];
28 const pick = (key: string, choice: string, prob: number) => {
29 const options = questions[key]!.options;
30 return { choice, p: prob, probabilities: Object.fromEntries(options.map((o) => [o, o === choice ? prob : 0])) };
31 };
32 const out: Record<string, ReturnType<typeof pick>> = { kind: pick("kind", kind, p) };
33 if (questions["target"]) out["target"] = pick("target", target ?? "none", 1);
34 if (questions["pillar"]) out["pillar"] = pick("pillar", pillar ?? "P7", 1);
35 return out as never;
36 },
37 });
38
39 describe("triage", () => {
40 let dataDir: string;
41 let app: App;
42 let alice: User;
43 let repo: Repo;
44 let n = 0;
45 const fresh = () => getRepoById(app.db, repo.id)!;
46 const think = (text: string) => {
47 const kept = keepThought(app, alice, text, "api");
48 if (!kept.ok) throw new Error(kept.error);
49 return { id: kept.value.id, text };
50 };
51
52 beforeAll(async () => {
53 dataDir = await mkdtemp(join(tmpdir(), "forge-triage-"));
54 app = createApp({ dataDir, port: 0 });
55 const made = await createUser(app.db, { trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true });
56 if (!made.ok) throw new Error(String(made.error));
57 alice = made.value;
58 const r = await createRepo(app, alice, { name: "forge", visibility: "private" });
59 if (!r.ok) throw new Error(String(r.error));
60 await updateRepoSettings(app, alice, r.value, {
61 autonomy: "autonomous",
62 autonomyDirection: "Steer by thought.",
63 autonomyCeilingUsd: 10,
64 autonomySlots: 1,
65 autonomyBy: alice.id,
66 });
67 repo = r.value;
68 repo = fresh();
69 for (const title of ["P0 Trust: safe", "P7 Steering: thoughts", "Later gate: wait here"]) {
70 const o = openIssue(app, alice, repo, { title });
71 if (!o.ok) throw new Error(String(o.error));
72 }
73 const o = openIssue(app, alice, repo, { title: "Planner answers thoughts in the feed" });
74 if (!o.ok) throw new Error(String(o.error));
75 n = o.value.number;
76 });
77
78 afterAll(async () => {
79 app.close();
80 await rm(dataDir, { recursive: true, force: true });
81 });
82
83 test("work opens an issue under its pillar, linked back to the thought, and the reply says so", async () => {
84 app.config.decider = stub({ build: ["work", 0.9, "none", "P7"] });
85 const t = think("build a dark mode toggle");
86 const p = await placeThought(app, fresh(), alice, t);
87 expect(p.kind).toBe("work");
88 expect(p.placed[0]!.action).toBe("opened");
89 expect(p.placed[0]!.pillar).toBe("P7");
90 const issue = getIssue(app.db, repo.id, p.placed[0]!.number)!;
91 expect(issue.body).toContain(t.id);
92 expect(p.reply).toBe(`Opened #${issue.number} under P7 Steering.`);
93 expect(p.asked).toBe(false);
94 expect(placementOf(app.db, t.id)?.reply).toBe(p.reply);
95 });
96
97 test("an amendment comments on the open issue it concerns", async () => {
98 app.config.decider = stub({ also: ["amendment", 0.9, `#${n}`] });
99 const t = think("also show it on the phone");
100 const p = await placeThought(app, fresh(), alice, t);
101 expect(p.kind).toBe("amendment");
102 expect(p.reply).toBe(`Added to #${n}.`);
103 const issue = getIssue(app.db, repo.id, n)!;
104 expect(listComments(app.db, issue.id).some((c) => c.body.includes(t.id))).toBe(true);
105 });
106
107 test("vision and decision land on the direction issue", async () => {
108 app.config.decider = stub({ vision: ["vision", 0.9], decided: ["decision", 0.9] });
109 const v = await placeThought(app, fresh(), alice, think("vision: the forge steers itself"));
110 const d = await placeThought(app, fresh(), alice, think("decided to keep one file of state"));
111 expect(v.kind).toBe("vision");
112 expect(d.kind).toBe("decision");
113 const dir = fresh().directionIssueId!;
114 const dirNumber = getIssueById(app.db, dir)!.number;
115 expect(v.placed[0]!.number).toBe(dirNumber);
outdated

This compares the number to itself. Assert v.placed[0].number and d.placed[0].number against getIssueById(dir).number (or the equivalent).

116 expect(d.placed[0]!.number).toBe(dirNumber);
117 const bodies = listComments(app.db, dir).map((c) => c.body);
118 expect(bodies.some((b) => b.includes("The direction changes"))).toBe(true);
119 expect(bodies.some((b) => b.includes("A decision"))).toBe(true);
120 });
121
122 test("a reversal marks the issue superseded and defers it behind the Later gate", async () => {
123 app.config.decider = stub({ scrap: ["reversal", 0.9, `#${n}`] });
124 const p = await placeThought(app, fresh(), alice, think(`scrap #${n}, no longer wanted`));
125 expect(p.kind).toBe("reversal");
126 expect(p.placed[0]).toMatchObject({ action: "deferred", number: n });
127 const gate = [...Array(10).keys()].map((i) => getIssue(app.db, repo.id, i + 1)).find((i) => i?.title.startsWith("Later gate"))!;
128 const blocked = app.db
129 .query<{ c: number }, [string, string]>("SELECT COUNT(*) c FROM issue_dependency WHERE issue_id = ?1 AND blocked_by_id = ?2")
130 .get(getIssue(app.db, repo.id, n)!.id, gate.id)!;
131 expect(blocked.c).toBe(1);
132 });
133
134 test("an ambiguous thought gets its best placement and one question", async () => {
135 app.config.decider = stub({ hmm: ["work", 0.4, "none", "P0"] });
136 const p = await placeThought(app, fresh(), alice, think("hmm maybe something about speed"));
137 expect(p.asked).toBe(true);
138 expect(p.placed[0]!.action).toBe("opened");
139 expect(p.reply).toContain("Opened #");
140 expect(p.reply.endsWith("is that right?")).toBe(true);
141 });
142
143 test("a reversal that names nothing is kept on the direction with a question", async () => {
144 app.config.decider = stub({ nope: ["reversal", 0.9, "none"] });
145 const p = await placeThought(app, fresh(), alice, think("nope, undo the thing"));
146 expect(p.asked).toBe(true);
147 expect(p.reply).toContain("Which issue does this take back?");
148 });
149
150 test("the same words twice are linked, never opened twice", async () => {
151 app.config.decider = stub({});
152 const first = await placeThought(app, fresh(), alice, think("write the changelog"));
153 const again = await placeThought(app, fresh(), alice, think("write the changelog"));
154 expect(again.placed[0]).toMatchObject({ action: "linked", number: first.placed[0]!.number });
155 });
156
157 test("without a decider the rule answers, and still never stays silent", async () => {
158 delete app.config.decider;
159 expect(ruleKind("actually, forget that", false).kind).toBe("reversal");
160 expect(ruleKind("from now on the vision is speed", false).kind).toBe("vision");
161 const p = await placeThought(app, fresh(), alice, think("add a search box to the home page"));
162 expect(p.reply.length).toBeGreaterThan(0);
163 expect(placementOf(app.db, p.thoughtId)).toBeDefined();
164 });
165
166 test("thoughts are placed together once the first has waited two minutes, and not before", async () => {
167 app.config.decider = stub({});
168 const a = think("batch one");
169 const b = think("batch two");
170 const start = new Date();
171 expect(await triageTick(app, new Date(start.getTime() + BATCH_MS - 5_000))).toBe(0);
172 expect(placementOf(app.db, a.id)).toBeUndefined();
173 const placed = await triageTick(app, new Date(start.getTime() + BATCH_MS + 5_000));
174 expect(placed).toBeGreaterThanOrEqual(2);
175 expect(placementOf(app.db, a.id)).toBeDefined();
176 expect(placementOf(app.db, b.id)).toBeDefined();
177 expect(await triageTick(app, new Date(start.getTime() + BATCH_MS + 10_000))).toBe(0);
178 });
179
180 test("thoughts older than a day never crowd out a fresh one", async () => {
181 app.config.decider = stub({});
182 const old = new Date(Date.now() - 3 * 24 * 60 * 60_000).toISOString();
183 for (let i = 0; i < 31; i++) {
184 app.db
185 .query("INSERT INTO thought (id, author, text, source, received_at) VALUES (?1, ?2, ?3, 'api', ?4)")
186 .run(`stale-${i}`, alice.id, `stale ${i}`, old);
187 }
188 const f = think("fresh after the stale ones");
189 const placed = await triageTick(app, new Date(Date.now() + BATCH_MS + 5_000));
190 expect(placed).toBeGreaterThanOrEqual(1);
191 expect(placementOf(app.db, f.id)).toBeDefined();
192 expect(placementOf(app.db, "stale-0")).toBeUndefined();
193 });
194
195 test("a reply links its issue numbers and escapes everything else", () => {
196 const html = replyUnder({
197 thoughtId: "x", kind: "work", placed: [{ action: "opened", repo: "alice/forge", number: 12 }],
198 reply: "Opened #12 <b>now</b>.", asked: false, decidedBy: "rule", placedAt: "",
199 }).toString();
200 expect(html).toContain('<a href="/alice/forge/issues/12">#12</a>');
201 expect(html).toContain("&lt;b&gt;");
202 expect(html).not.toContain("<b>");
203 });
204 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs