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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/652-triage-the-planner-places-every-thought → main 4 commits fork 5 threads · 5 unresolved
README.md +3
| @@ -556,6 +556,9 @@ an app, it keeps what you typed as a thought, exactly as typed (works with no | ||
| 556 | 556 | apps too). Your thoughts from the last day stand at the top of the feed as |
| 557 | 557 | your own posts, marked "Received". The terminal has no feed or composer, so |
| 558 | 558 | 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. | |
| 559 | 562 | |
| 560 | 563 | ## Link-preview cards |
| 561 | 564 |
scripts/gate.ts +1
| @@ -36,6 +36,7 @@ export const GATE: readonly string[] = [ | ||
| 36 | 36 | "test/boards-query.test.ts", |
| 37 | 37 | "test/turnstile.test.ts", |
| 38 | 38 | "test/thoughts.test.ts", |
| 39 | "test/triage.test.ts", | |
| 39 | 40 | "test/passkeys.test.ts", |
| 40 | 41 | "test/webauthn.test.ts", |
| 41 | 42 | "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) | |
| 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 |
||
| 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[] = [ | ||
| 1721 | 1721 | ); |
| 1722 | 1722 | CREATE INDEX release_created ON release(created_at); |
| 1723 | 1723 | `, |
| 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 | `, | |
| 1724 | 1738 | ]; |
| 1725 | 1739 | |
| 1726 | 1740 | /** |
src/crew/autonomy.ts +7
| @@ -43,6 +43,7 @@ import { getHold } from "../core/holds.ts"; | ||
| 43 | 43 | import { certain } from "./decide.ts"; |
| 44 | 44 | import { decideAndRecord } from "./ledger.ts"; |
| 45 | 45 | import { sentinelTick } from "./sentinel.ts"; |
| 46 | import { triageTick } from "./triage.ts"; | |
| 46 | 47 | import { wallOf, wallSentence } from "../contract/policy.ts"; |
| 47 | 48 | import { |
| 48 | 49 | getIssue, |
| @@ -1069,6 +1070,12 @@ export async function autonomyTick(app: App, now = new Date()): Promise<void> { | ||
| 1069 | 1070 | }); |
| 1070 | 1071 | } |
| 1071 | 1072 | } |
| 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 | } | |
| 1072 | 1079 | // The forge hunts its own faults on the same clock (`crew/sentinel.ts`). |
| 1073 | 1080 | try { |
| 1074 | 1081 | 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 |
||
| 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 |
||
| 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 { | ||
| 49 | 49 | cardFor, |
| 50 | 50 | } from "../web/studio/cards.ts"; |
| 51 | 51 | import { keepThought } from "../actions/thought.ts"; |
| 52 | import { placementJson, placementOf, thoughtAuthor } from "../core/placements.ts"; | |
| 52 | 53 | import { tell, toldWords, UNVERIFIED_WORDS } from "../actions/tell.ts"; |
| 53 | 54 | import { readReturns, reportSpend } from "../actions/returns.ts"; |
| 54 | 55 | import { |
| @@ -1059,6 +1060,26 @@ const TOOLS: readonly Tool[] = [ | ||
| 1059 | 1060 | return good({ steps: steps ?? [] }); |
| 1060 | 1061 | }, |
| 1061 | 1062 | }, |
| 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 | }, | |
| 1062 | 1083 | { |
| 1063 | 1084 | name: "asked", |
| 1064 | 1085 | description: |
src/web/studio/feed.ts +16 −3
| @@ -11,6 +11,7 @@ | ||
| 11 | 11 | import { marksByName, type Mark } from "../../core/marks.ts"; |
| 12 | 12 | import type { User } from "../../core/users.ts"; |
| 13 | 13 | import type { Ctx } from "../../http/context.ts"; |
| 14 | import { placementsOf, type Placement } from "../../core/placements.ts"; | |
| 14 | 15 | import { recentThoughts, type Thought } from "../../core/thoughts.ts"; |
| 15 | 16 | import { readFeed } from "../../feed/feed.ts"; |
| 16 | 17 | import type { FeedItem } from "../../feed/rank.ts"; |
| @@ -22,7 +23,7 @@ import { writableAppsOf } from "./asks.ts"; | ||
| 22 | 23 | |
| 23 | 24 | /** Events that can change what the feed says. */ |
| 24 | 25 | 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"; | |
| 26 | 27 | |
| 27 | 28 | export const FEED_FIRST_PAGE = 20; |
| 28 | 29 | |
| @@ -106,7 +107,7 @@ const THOUGHT_AGE_MS = 24 * 60 * 60 * 1000; | ||
| 106 | 107 | * A thought as the owner's post: their own words, verbatim (escaped here, |
| 107 | 108 | * once; line breaks kept by CSS), marked "received". It has no controls. |
| 108 | 109 | */ |
| 109 | export function thoughtCard(thought: Thought, who: string): Html { | |
| 110 | export function thoughtCard(thought: Thought, who: string, placement?: Placement): Html { | |
| 110 | 111 | return html`<article class="fcard fcard-thought" data-kind="thought" data-thought="${thought.id}"> |
| 111 | 112 | <div class="fcard-head"> |
| 112 | 113 | ${avatar(who)} |
| @@ -116,9 +117,20 @@ export function thoughtCard(thought: Thought, who: string): Html { | ||
| 116 | 117 | <span class="badge">Received</span> |
| 117 | 118 | </div> |
| 118 | 119 | <p class="fcard-reason fcard-text">${thought.text}</p> |
| 120 | ${placement ? replyUnder(placement) : ""} | |
| 119 | 121 | </article>`; |
| 120 | 122 | } |
| 121 | 123 | |
| 124 | /** The planner's answer under a thought: one or two lines, `` 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 | ||
| 122 | 134 | /** The longest tell the composer takes (its textarea's `maxlength`). */ |
| 123 | 135 | const TELL_MAX = 2000; |
| 124 | 136 | |
| @@ -195,6 +207,7 @@ export function feedRegion(user: User, ctx: Ctx): Html { | ||
| 195 | 207 | (item) => item.kind !== "ask", |
| 196 | 208 | ); |
| 197 | 209 | 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)); | |
| 198 | 211 | // Who each poster is, asked once for the page (`core/marks.ts`). |
| 199 | 212 | const marks = marksByName( |
| 200 | 213 | ctx.app.db, |
| @@ -207,7 +220,7 @@ export function feedRegion(user: User, ctx: Ctx): Html { | ||
| 207 | 220 | data-live="${FEED_LIVE}" |
| 208 | 221 | data-live-src="/live/feed" |
| 209 | 222 | > |
| 210 | ${thoughts.map((t) => thoughtCard(t, user.username))} | |
| 223 | ${thoughts.map((t) => thoughtCard(t, user.username, placed.get(t.id)))} | |
| 211 | 224 | ${items.length === 0 && thoughts.length === 0 |
| 212 | 225 | ? empty({ |
| 213 | 226 | icon: "activity", |
src/web/studio/feed_style.ts +2
| @@ -9,6 +9,8 @@ export const feedCss = /* css */ ` | ||
| 9 | 9 | .fcard-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } |
| 10 | 10 | .fcard-reason { margin: 8px 0 4px; font-size: 1.05em; overflow-wrap: anywhere; } |
| 11 | 11 | .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; } | |
| 12 | 14 | .fcard-who { font-weight: 600; } |
| 13 | 15 | /* One quiet control under the post: say something, dismiss, mute. */ |
| 14 | 16 | .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"; | ||
| 17 | 17 | import { createRun, listRuns } from "../src/core/runs.ts"; |
| 18 | 18 | import { createToken } from "../src/core/tokens.ts"; |
| 19 | 19 | import { createUser, type User } from "../src/core/users.ts"; |
| 20 | import { savePlacement } from "../src/core/placements.ts"; | |
| 20 | 21 | import { serve, type Servers } from "../src/http/server.ts"; |
| 21 | 22 | import { limitChanges, limitValue } from "../src/settings/limits.ts"; |
| 22 | 23 | |
| @@ -181,6 +182,7 @@ describe("the forge over MCP", () => { | ||
| 181 | 182 | "take_updates", |
| 182 | 183 | "converge", |
| 183 | 184 | "next_steps", |
| 185 | "thought", | |
| 184 | 186 | "asked", |
| 185 | 187 | "app_members", |
| 186 | 188 | "explore", |
| @@ -265,6 +267,20 @@ describe("the forge over MCP", () => { | ||
| 265 | 267 | ).toContain("not_found"); |
| 266 | 268 | }); |
| 267 | 269 | |
| 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 | ||
| 268 | 284 | test("tell is the studio's tell: an issue, a builder, a run", async () => { |
| 269 | 285 | const empty = await tool(rw, "tell", { repo: "alice/game", text: " " }); |
| 270 | 286 | 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 |
||
| 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("<b>"); | |
| 202 | expect(html).not.toContain("<b>"); | |
| 203 | }); | |
| 204 | }); |
High: this returns the oldest 30 regardless of age, and
triageTickdrops 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. Filtert.received_at >= ?sincehere.