Public: anyone can read this; the crew can change it.
Supersession and repeats: a later thought wins, a repeated one raises what it repeats #1760
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/654-supersession-and-repeats-a-later-thought → main 7 commits fork 6 threads · 6 unresolved
README.md +9
| @@ -609,6 +609,15 @@ thoughts are web-only there; `POST /api/v1/thoughts` is the other way in. | ||
| 609 | 609 | Within minutes the planner places each thought on the map and answers under it |
| 610 | 610 | in the feed, saying where it went; the MCP `thought` tool returns that |
| 611 | 611 | placement to the thought's author. |
| 612 | A thought that says an earlier one again does not open a duplicate: it links to | |
| 613 | the earlier thought (`repeats`), comments on the issue it placed and raises it | |
| 614 | to the front of the ready order, unless that issue is deferred or closed, which | |
| 615 | the reply says. A close restatement counts only when the planner reads it as | |
| 616 | plain work and nothing in it negates or flips the earlier one; "don't make X" | |
| 617 | after "make X" is a reversal, never a repeat. A reversal links the thoughts it | |
| 618 | takes back (`supersedes`). `/asked` shows "said again, first on …", "takes back | |
| 619 | …" and "superseded by …" under each thought, and the MCP `thought` tool returns | |
| 620 | `repeats`, `supersedes`, `repeatedBy` and `supersededBy` thought ids. | |
| 612 | 621 | |
| 613 | 622 | ## Link-preview cards |
| 614 | 623 |
src/core/dependencies.ts +14 −3
| @@ -14,6 +14,7 @@ import { getIssuesByIds, type CloseReason, type Issue } from "./issues.ts"; | ||
| 14 | 14 | import { pillarsOf, structuralIds } from "./pillars.ts"; |
| 15 | 15 | import { getRepoById } from "./repos.ts"; |
| 16 | 16 | import { unadoptedAmong } from "../crew/direct.ts"; |
| 17 | import { raisedAt } from "./thought_links.ts"; | |
| 17 | 18 | import { limbRanks, limbTree, readPlaces } from "./limb_order.ts"; |
| 18 | 19 | |
| 19 | 20 | export type DependencyError = "self" | "cross_repo" | "cycle" | "not_an_issue"; |
| @@ -222,6 +223,11 @@ export interface Graph { | ||
| 222 | 223 | * here; absent, the ready order is the one that never knew limbs. |
| 223 | 224 | */ |
| 224 | 225 | readonly limbRank?: ReadonlyMap<string, number>; |
| 226 | /** | |
| 227 | * When a repeated thought last raised an issue (#654). A raised issue is | |
| 228 | * ready ahead of the rest, the latest raise first. | |
| 229 | */ | |
| 230 | readonly raised?: ReadonlyMap<string, string>; | |
| 225 | 231 | } |
| 226 | 232 | |
| 227 | 233 | /** |
| @@ -335,6 +341,7 @@ export function repoGraph( | ||
| 335 | 341 | places, |
| 336 | 342 | ), |
| 337 | 343 | }), |
| 344 | raised: raisedAt(db, issues.map((i) => i.id)), | |
| 338 | 345 | issues, |
| 339 | 346 | unadopted: |
| 340 | 347 | repo === undefined ? new Set() : unadoptedAmong(db, repo, issues), |
| @@ -538,9 +545,13 @@ export function readyNow(graph: Graph): Ranked[] { | ||
| 538 | 545 | !graph.deferred.has(r.issue.id), |
| 539 | 546 | ); |
| 540 | 547 | const rank = graph.limbRank; |
| 541 | if (rank === undefined) return ready; | |
| 542 | const of = (r: Ranked) => rank.get(r.issue.id) ?? Number.MAX_SAFE_INTEGER; | |
| 543 | return ready.sort((a, b) => of(a) - of(b)); | |
| 548 | const of = (r: Ranked) => rank?.get(r.issue.id) ?? Number.MAX_SAFE_INTEGER; | |
| 549 | if (rank !== undefined) ready.sort((a, b) => of(a) - of(b)); | |
| 550 | const raised = graph.raised; | |
| 551 | if (raised === undefined || raised.size === 0) return ready; | |
| 552 | // What the owner said again comes first (stable: the rest keep their order). | |
| 553 | const at = (r: Ranked) => raised.get(r.issue.id) ?? ""; | |
| 554 | return ready.sort((a, b) => at(b).localeCompare(at(a))); | |
| 544 | 555 | } |
| 545 | 556 | |
| 546 | 557 | /** |
src/core/schema.ts +17
| @@ -1754,6 +1754,23 @@ export const migrations: readonly string[] = [ | ||
| 1754 | 1754 | ); |
| 1755 | 1755 | CREATE INDEX deploy_pull_issue ON deploy_pull(issue_id); |
| 1756 | 1756 | `, |
| 1757 | // 87 — supersession and repeats (#654): a thought that repeats or takes | |
| 1758 | // back an earlier one links to it, and an issue a repeat raised is read | |
| 1759 | // ahead of the rest in the ready order. | |
| 1760 | ` | |
| 1761 | CREATE TABLE thought_link ( | |
| 1762 | thought_id TEXT NOT NULL, | |
| 1763 | other_id TEXT NOT NULL, | |
| 1764 | relation TEXT NOT NULL CHECK (relation IN ('repeats', 'supersedes')), | |
| 1765 | PRIMARY KEY (thought_id, other_id, relation) | |
| 1766 | ); | |
| 1767 | CREATE INDEX thought_link_other ON thought_link(other_id); | |
| 1768 | CREATE TABLE issue_raise ( | |
| 1769 | issue_id TEXT PRIMARY KEY, | |
| 1770 | raised_at TEXT NOT NULL, | |
| 1771 | times INTEGER NOT NULL DEFAULT 1 | |
Nit: |
||
| 1772 | ); | |
| 1773 | `, | |
| 1757 | 1774 | ]; |
| 1758 | 1775 | |
| 1759 | 1776 | /** |
src/core/thought_links.ts added +57
| @@ -0,0 +1,57 @@ | ||
| 1 | /** | |
| 2 | * How thoughts relate (#654). A thought that says an earlier one again | |
| 3 | * `repeats` it; one that takes it back `supersedes` it. A repeat also raises | |
| 4 | * the issue the earlier thought placed: it is read ahead of the rest in the | |
| 5 | * ready order (`readyNow`), the most recently raised first. Links are | |
| 6 | * written once and never removed; the thoughts themselves are untouched. | |
| 7 | */ | |
| 8 | ||
| 9 | import type { Database } from "bun:sqlite"; | |
| 10 | ||
| 11 | export type Relation = "repeats" | "supersedes"; | |
| 12 | ||
| 13 | export function linkThoughts(db: Database, thoughtId: string, otherId: string, relation: Relation): void { | |
| 14 | db.query("INSERT OR IGNORE INTO thought_link (thought_id, other_id, relation) VALUES (?1, ?2, ?3)").run(thoughtId, otherId, relation); | |
| 15 | } | |
| 16 | ||
| 17 | /** The earlier thoughts this one repeats or supersedes. */ | |
| 18 | export function linksFrom(db: Database, thoughtId: string): Record<Relation, string[]> { | |
| 19 | const out: Record<Relation, string[]> = { repeats: [], supersedes: [] }; | |
| 20 | const rows = db | |
| 21 | .query<{ other_id: string; relation: Relation }, [string]>("SELECT other_id, relation FROM thought_link WHERE thought_id = ?1 ORDER BY other_id") | |
| 22 | .all(thoughtId); | |
| 23 | for (const r of rows) out[r.relation].push(r.other_id); | |
| 24 | return out; | |
| 25 | } | |
| 26 | ||
| 27 | /** The first later thought that superseded this one, and the first that repeated it. */ | |
| 28 | export function linksTo(db: Database, thoughtId: string): { supersededBy: string | null; repeatedBy: string[] } { | |
| 29 | const rows = db | |
| 30 | .query<{ thought_id: string; relation: Relation }, [string]>( | |
| 31 | "SELECT thought_id, relation FROM thought_link WHERE other_id = ?1 ORDER BY thought_id", | |
| 32 | ) | |
| 33 | .all(thoughtId); | |
| 34 | return { | |
| 35 | supersededBy: rows.find((r) => r.relation === "supersedes")?.thought_id ?? null, | |
| 36 | repeatedBy: rows.filter((r) => r.relation === "repeats").map((r) => r.thought_id), | |
| 37 | }; | |
| 38 | } | |
| 39 | ||
| 40 | /** Move an issue up the ready order, now. */ | |
| 41 | export function raiseIssue(db: Database, issueId: string, now = new Date()): void { | |
| 42 | db.query( | |
| 43 | `INSERT INTO issue_raise (issue_id, raised_at, times) VALUES (?1, ?2, 1) | |
| 44 | ON CONFLICT(issue_id) DO UPDATE SET raised_at = excluded.raised_at, times = times + 1`, | |
| 45 | ).run(issueId, now.toISOString()); | |
| 46 | } | |
| 47 | ||
| 48 | /** When each of these issues was last raised. */ | |
| 49 | export function raisedAt(db: Database, issueIds: readonly string[]): Map<string, string> { | |
| 50 | const out = new Map<string, string>(); | |
| 51 | const get = db.query<{ raised_at: string }, [string]>("SELECT raised_at FROM issue_raise WHERE issue_id = ?1"); | |
| 52 | for (const id of issueIds) { | |
| 53 | const r = get.get(id); | |
| 54 | if (r) out.set(id, r.raised_at); | |
| 55 | } | |
| 56 | return out; | |
| 57 | } |
src/core/thought_stream.ts +27 −1
| @@ -22,7 +22,8 @@ import { placementsOf, type Kind, type Placed } from "./placements.ts"; | ||
| 22 | 22 | import { canRead, getRepo, type Repo } from "./repos.ts"; |
| 23 | 23 | import { listActiveRuns } from "./runs.ts"; |
| 24 | 24 | import { getUserSetting } from "./settings.ts"; |
| 25 | import { recentThoughts } from "./thoughts.ts"; | |
| 25 | import { linksFrom, linksTo } from "./thought_links.ts"; | |
| 26 | import { recentThoughts, thoughtOf } from "./thoughts.ts"; | |
| 26 | 27 | import type { User } from "./users.ts"; |
| 27 | 28 | |
| 28 | 29 | export type ThoughtState = "unplaced" | "placed" | "building" | "shipped" | "superseded"; |
| @@ -54,6 +55,16 @@ export interface StreamThought { | ||
| 54 | 55 | readonly flagged: boolean; |
| 55 | 56 | /** The later thought that superseded this one. */ |
| 56 | 57 | readonly supersededBy: { readonly id: string; readonly text: string; readonly receivedAt: string } | null; |
| 58 | /** The earlier thought this one says again (#654). */ | |
| 59 | readonly repeats: ThoughtRef | null; | |
| 60 | /** The earlier thoughts this one takes back. */ | |
| 61 | readonly supersedes: readonly ThoughtRef[]; | |
| 62 | } | |
| 63 | ||
| 64 | export interface ThoughtRef { | |
| 65 | readonly id: string; | |
| 66 | readonly text: string; | |
| 67 | readonly receivedAt: string; | |
| 57 | 68 | } |
| 58 | 69 | |
| 59 | 70 | /** A person's flag window in days: their setting when it is a whole number of 1–90, else three. */ |
| @@ -108,8 +119,17 @@ export function thoughtStream( | ||
| 108 | 119 | if (x.action === "superseded" && repoOf(x.repo) !== undefined && !supersedes.has(keyOf(x))) supersedes.set(keyOf(x), t); |
| 109 | 120 | } |
| 110 | 121 | |
| 122 | // Links the planner wrote: the later thought wins, whatever the issues did. | |
| 123 | const byId = new Map(thoughts.map((t) => [t.id, t])); | |
| 124 | const ref = (id: string): ThoughtRef | null => { | |
| 125 | const t = byId.get(id) ?? thoughtOf(db, viewer.id, id); | |
| 126 | return t ? { id: t.id, text: t.text, receivedAt: t.receivedAt } : null; | |
| 127 | }; | |
| 128 | ||
| 111 | 129 | return thoughts.map((t) => { |
| 112 | 130 | const p = placements.get(t.id); |
| 131 | const from = linksFrom(db, t.id); | |
| 132 | const to = linksTo(db, t.id); | |
| 113 | 133 | const links: PlacedLink[] = (p?.placed ?? []).map((x) => { |
| 114 | 134 | const repo = repoOf(x.repo); |
| 115 | 135 | const issue = repo ? getIssue(db, repo.id, x.number) : undefined; |
| @@ -131,6 +151,8 @@ export function thoughtStream( | ||
| 131 | 151 | const s = supersedes.get(keyOf(x)); |
| 132 | 152 | if (s !== undefined && s.id !== t.id && s.receivedAt >= t.receivedAt) by = s; |
| 133 | 153 | } |
| 154 | const linked = to.supersededBy === null ? null : ref(to.supersededBy); | |
| 155 | if (by === undefined && linked) by = { ...t, id: linked.id, text: linked.text, receivedAt: linked.receivedAt, source: "" }; | |
| 134 | 156 | const state: ThoughtState = |
| 135 | 157 | p === undefined ? "unplaced" |
| 136 | 158 | : by !== undefined && !shipped ? "superseded" |
| @@ -153,6 +175,8 @@ export function thoughtStream( | ||
| 153 | 175 | flagged, |
| 154 | 176 | supersededBy: |
| 155 | 177 | state === "superseded" && by ? { id: by.id, text: by.text, receivedAt: by.receivedAt } : null, |
| 178 | repeats: from.repeats[0] ? ref(from.repeats[0]) : null, | |
| 179 | supersedes: from.supersedes.flatMap((id) => ref(id) ?? []), | |
| 156 | 180 | }; |
| 157 | 181 | }); |
| 158 | 182 | } |
| @@ -175,5 +199,7 @@ export function streamJson(t: StreamThought) { | ||
| 175 | 199 | state: t.state, |
| 176 | 200 | flagged: t.flagged, |
| 177 | 201 | supersededBy: t.supersededBy, |
| 202 | repeats: t.repeats, | |
| 203 | supersedes: t.supersedes, | |
| 178 | 204 | }; |
| 179 | 205 | } |
src/core/thoughts.ts +10
| @@ -25,3 +25,13 @@ export function recentThoughts(db: Database, authorId: string, limit: number, si | ||
| 25 | 25 | .all(authorId, since, limit) |
| 26 | 26 | .map((r) => ({ id: r.id, text: r.text, source: r.source, receivedAt: r.received_at })); |
| 27 | 27 | } |
| 28 | ||
| 29 | /** One of the author's thoughts by id, or nothing (a thought is its author's alone). */ | |
| 30 | export function thoughtOf(db: Database, authorId: string, id: string): Thought | undefined { | |
| 31 | const r = db | |
| 32 | .query<{ id: string; text: string; source: string; received_at: string }, [string, string]>( | |
| 33 | "SELECT id, text, source, received_at FROM thought WHERE author = ?1 AND id = ?2", | |
| 34 | ) | |
| 35 | .get(authorId, id); | |
| 36 | return r ? { id: r.id, text: r.text, source: r.source, receivedAt: r.received_at } : undefined; | |
| 37 | } |
src/crew/triage.ts +96 −15
| @@ -15,7 +15,7 @@ | ||
| 15 | 15 | import type { App } from "../app.ts"; |
| 16 | 16 | import { addComment } from "../actions/issues.ts"; |
| 17 | 17 | import { openPlan } from "../actions/plan.ts"; |
| 18 | import { addDependency } from "../core/dependencies.ts"; | |
| 18 | import { addDependency, repoGraph } from "../core/dependencies.ts"; | |
| 19 | 19 | import { getIssue, MAX_TITLE, type Issue } from "../core/issues.ts"; |
| 20 | 20 | import { |
| 21 | 21 | authorsWithUnplaced, |
| @@ -33,6 +33,7 @@ import { getUserById, type User } from "../core/users.ts"; | ||
| 33 | 33 | import { createLog } from "../kernel/log.ts"; |
| 34 | 34 | import { autonomyStatus, ensureDirectionIssue } from "./autonomy.ts"; |
| 35 | 35 | import { certain, decideOr, type Answers, type Questions } from "./decide.ts"; |
| 36 | import { linkThoughts, raiseIssue } from "../core/thought_links.ts"; | |
| 36 | 37 | import { crewOffInPreview } from "./preview.ts"; |
| 37 | 38 | |
| 38 | 39 | const log = createLog("triage"); |
| @@ -106,6 +107,74 @@ const clip = (text: string, max: number): string => { | ||
| 106 | 107 | }; |
| 107 | 108 | const quote = (text: string): string => text.split("\n").map((l) => `> ${l}`).join("\n"); |
| 108 | 109 | |
| 110 | /** Under this share of shared words two thoughts are not the same ask. */ | |
| 111 | const REPEAT_SHARE = 0.8; | |
| 112 | const EARLIER = 100; | |
| 113 | ||
| 114 | /** Words that turn a sentence around: a text that has one the other lacks says something else. */ | |
| 115 | const NEGATION = /\b(not|no|never|without|cancel|remove|stop)\b|n't\b/g; | |
| 116 | const OPPOSITES: readonly (readonly [string, string])[] = [ | |
| 117 | ["visible", "hidden"], ["show", "hide"], ["add", "remove"], ["enable", "disable"], ["on", "off"], | |
| 118 | ["more", "less"], ["increase", "decrease"], ["open", "close"], ["allow", "block"], ["include", "exclude"], | |
| 119 | ]; | |
| 120 | ||
| 121 | /** True when one text takes back, negates or flips a word of the other. */ | |
| 122 | function opposed(a: string, b: string): boolean { | |
| 123 | const la = a.toLowerCase(); | |
outdated
High: these are the shared words only, and |
||
| 124 | const lb = b.toLowerCase(); | |
| 125 | if ((la.match(NEGATION)?.length ?? 0) !== (lb.match(NEGATION)?.length ?? 0)) return true; | |
| 126 | const wa = new Set(la.match(/[a-z']+/g)); | |
| 127 | const wb = new Set(lb.match(/[a-z']+/g)); | |
| 128 | return OPPOSITES.some(([x, y]) => (wa.has(x) && wb.has(y) && !wa.has(y) && !wb.has(x)) || (wa.has(y) && wb.has(x) && !wa.has(x) && !wb.has(y))); | |
| 129 | } | |
| 130 | ||
| 131 | /** | |
| 132 | * A thought the author already sent that placed an issue of `repo`, and says | |
| 133 | * the same. The same words always repeat; a close restatement only does when | |
| 134 | * the planner reads this one as a plain ask (`work`/`amendment`) and nothing | |
| 135 | * in it turns the earlier one around: the later thought wins, so a reversal | |
| 136 | * must never raise what it takes back. | |
| 137 | */ | |
| 138 | function repeatOf(app: App, repo: Repo, thought: Thought, kind: Kind): { before: Earlier; issue: Issue | undefined; number: number } | undefined { | |
| 139 | const mine = words(thought.text); | |
| 140 | const slug = `${repo.ownerName}/${repo.name}`; | |
| 141 | for (const before of earlierPlaced(app, thought)) { | |
| 142 | const theirs = words(before.text); | |
| 143 | const same = | |
| 144 | before.text === thought.text || | |
| 145 | ((kind === "work" || kind === "amendment") && | |
| 146 | mine.size >= 3 && | |
| 147 | overlap(mine, theirs) / Math.max(mine.size, theirs.size) >= REPEAT_SHARE && | |
| 148 | !opposed(thought.text, before.text)); | |
| 149 | if (!same) continue; | |
| 150 | const at = before.placed.find((p) => p.repo === slug && (p.action === "opened" || p.action === "linked" || p.action === "amended")); | |
| 151 | if (!at) continue; | |
| 152 | return { before, issue: getIssue(app.db, repo.id, at.number), number: at.number }; | |
| 153 | } | |
| 154 | return undefined; | |
| 155 | } | |
| 156 | ||
| 157 | interface Earlier { | |
| 158 | readonly id: string; | |
| 159 | readonly text: string; | |
| 160 | readonly receivedAt: string; | |
| 161 | readonly kind: Kind; | |
| 162 | readonly placed: Placed[]; | |
| 163 | } | |
| 164 | ||
| 165 | /** The author's placed thoughts received before this one, newest first. */ | |
| 166 | function earlierPlaced(app: App, thought: Thought): Earlier[] { | |
| 167 | return app.db | |
| 168 | .query<{ id: string; text: string; received_at: string; kind: string; placed: string }, [string, number]>( | |
| 169 | `SELECT t.id, t.text, t.received_at, p.kind, p.placed FROM thought t JOIN placement p ON p.thought_id = t.id | |
| 170 | WHERE t.id != ?1 AND t.author = (SELECT author FROM thought WHERE id = ?1) | |
| 171 | AND t.received_at <= (SELECT received_at FROM thought WHERE id = ?1) | |
| 172 | ORDER BY t.received_at DESC, t.id DESC LIMIT ?2`, | |
| 173 | ) | |
| 174 | .all(thought.id, EARLIER) | |
| 175 | .map((r) => ({ id: r.id, text: r.text, receivedAt: r.received_at, kind: r.kind as Kind, placed: JSON.parse(r.placed) as Placed[] })); | |
| 176 | } | |
| 177 | ||
| 109 | 178 | function openIssues(app: App, repo: Repo, set: Pillars, direction: Issue | undefined): Open[] { |
| 110 | 179 | return app.db |
| 111 | 180 | .query<{ id: string; number: number; title: string }, [string, number]>( |
| @@ -144,19 +213,6 @@ export async function placeThought( | ||
| 144 | 213 | const text = thought.text; |
| 145 | 214 | const ref = `From thought \`${thought.id}\`:`; |
| 146 | 215 | |
| 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 | 216 | const ruled = ruleKind(text, open.length > 0); |
| 161 | 217 | const ruledTarget = targetOf(text, open); |
| 162 | 218 | const ruledPillar = pillarOf(text, set); |
| @@ -205,6 +261,25 @@ export async function placeThought( | ||
| 205 | 261 | const target = open.find((o) => `#${o.number}` === pickedTarget); |
| 206 | 262 | const pillar = decided.answers["pillar"]?.choice; |
| 207 | 263 | |
| 264 | // The same thing said again is the same thought: link and raise, never duplicate. | |
| 265 | const again = repeatOf(app, repo, thought, kind); | |
| 266 | if (again) { | |
| 267 | const { before, issue, number } = again; | |
| 268 | const said = before.receivedAt.slice(0, 10); | |
| 269 | linkThoughts(app.db, thought.id, before.id, "repeats"); | |
| 270 | let reply = `You asked this on ${said}; #${number} is ${issue ? (issue.state === "open" ? "still open" : "closed") : "gone"}.`; | |
| 271 | if (issue?.state === "open" && repoGraph(app.db, repo.id).deferred.has(issue.id)) { | |
| 272 | reply = `You asked this on ${said}; #${number} is open but deferred behind the Later gate, so it is not raised.`; | |
| 273 | } else if (issue?.state === "open") { | |
| 274 | raiseIssue(app.db, issue.id, now); | |
| 275 | const done = addComment(app, by, repo, issue, `Asked again; raised in the ready order.\n\n${ref}\n\n${quote(text)}`); | |
| 276 | if (!done.ok) log.warn("could not comment", { issue: issue.number, error: String(done.error) }); | |
| 277 | reply = `You asked this on ${said}; #${number} is open${issue.assigneeId ? " and being built" : ""}. Raised it.`; | |
| 278 | } | |
| 279 | return finish(app, thought, before.kind, "rule", false, [{ action: "linked", repo: slug, number }], reply, now); | |
| 280 | } | |
| 281 | ||
| 282 | ||
Nit: there is a doubled blank line here. |
||
| 208 | 283 | const placed: Placed[] = []; |
| 209 | 284 | const say: string[] = []; |
| 210 | 285 | let question = sure ? "" : `I read this as ${kind === "amendment" ? "an amendment" : `a ${kind}`}; is that right?`; |
| @@ -256,7 +331,13 @@ export async function placeThought( | ||
| 256 | 331 | if (deferred && gate) |
| 257 | 332 | app.bus.append({ kind: "issue.blocked", repoId: repo.id, actorId: by.id, payload: { number: issue.number, blockedBy: gate.number } }); |
| 258 | 333 | 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.`); | |
| 334 | // The earlier thoughts that placed this issue are the ones taken back. | |
| 335 | const taken = earlierPlaced(app, thought).filter((e) => | |
| 336 | e.placed.some((p) => p.repo === slug && p.number === issue.number && p.action !== "superseded" && p.action !== "deferred"), | |
| 337 | ); | |
| 338 | for (const e of taken) linkThoughts(app.db, thought.id, e.id, "supersedes"); | |
| 339 | const when = taken[0] ? ` (you said it on ${taken[0].receivedAt.slice(0, 10)})` : ""; | |
| 340 | say.push(deferred ? `Superseded #${issue.number}${when} and put it behind the Later gate.` : `Marked #${issue.number}${when} superseded.`); | |
| 260 | 341 | } |
| 261 | 342 | } else if (direction) { |
| 262 | 343 | comment(direction, kind === "vision" ? "The direction changes:" : "A decision:"); |
src/http/mcp.ts +13 −2
| @@ -127,6 +127,7 @@ import { | ||
| 127 | 127 | isAskedOrder, |
| 128 | 128 | isAskedShow, |
| 129 | 129 | } from "../core/asked.ts"; |
| 130 | import { linksFrom, linksTo } from "../core/thought_links.ts"; | |
| 130 | 131 | import { streamJson, thoughtStream } from "../core/thought_stream.ts"; |
| 131 | 132 | import { |
| 132 | 133 | readAppMembers, |
| @@ -1166,7 +1167,7 @@ const TOOLS: readonly Tool[] = [ | ||
| 1166 | 1167 | { |
| 1167 | 1168 | name: "thought", |
| 1168 | 1169 | description: |
| 1169 | "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).", | |
| 1170 | "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). `repeats` and `supersedes` list the earlier thought ids this one says again or takes back; `repeatedBy` and `supersededBy` the later ones that did the same to it.", | |
| 1170 | 1171 | inputSchema: { |
| 1171 | 1172 | type: "object", |
| 1172 | 1173 | properties: { id: { type: "string", maxLength: 64 } }, |
| @@ -1180,7 +1181,17 @@ const TOOLS: readonly Tool[] = [ | ||
| 1180 | 1181 | // Not yours and not there answer alike (a thought belongs to its author). |
| 1181 | 1182 | if (thoughtAuthor(ctx.app.db, thoughtId) !== user.id) |
| 1182 | 1183 | return bad("not_found", "no such thought"); |
| 1183 | return good({ id: thoughtId, placement: placementJson(placementOf(ctx.app.db, thoughtId)) }); | |
| 1184 | const links = linksFrom(ctx.app.db, thoughtId); | |
| 1185 | const back = linksTo(ctx.app.db, thoughtId); | |
| 1186 | return good({ | |
| 1187 | id: thoughtId, | |
| 1188 | placement: placementJson(placementOf(ctx.app.db, thoughtId)), | |
| 1189 | // The earlier thoughts this one repeats or takes back, and the later one that took it back. | |
| 1190 | repeats: links.repeats, | |
| 1191 | supersedes: links.supersedes, | |
| 1192 | supersededBy: back.supersededBy, | |
| 1193 | repeatedBy: back.repeatedBy, | |
| 1194 | }); | |
| 1184 | 1195 | }, |
| 1185 | 1196 | }, |
| 1186 | 1197 | { |
src/web/studio/asked_page.ts +3 −1
| @@ -70,7 +70,7 @@ function placedLinks(t: StreamThought): Html { | ||
| 70 | 70 | : html`${links.map((l, i) => html`${i > 0 ? " · " : ""}${l}`)}`; |
| 71 | 71 | } |
| 72 | 72 | |
| 73 | function thoughtRow(t: StreamThought, days: number): Html { | |
| 73 | export function thoughtRow(t: StreamThought, days: number): Html { | |
| 74 | 74 | const word = t.flagged |
| 75 | 75 | ? { text: "Nothing built", tone: "danger" as Tone } |
| 76 | 76 | : THOUGHT_WORD[t.state]; |
| @@ -83,6 +83,8 @@ function thoughtRow(t: StreamThought, days: number): Html { | ||
| 83 | 83 | : t.text, |
| 84 | 84 | meta: html`${time(t.receivedAt)} · ${t.source} · ${placedLinks(t)}${t.supersededBy |
| 85 | 85 | ? html` · superseded by “${t.supersededBy.text.slice(0, 80)}” ${time(t.supersededBy.receivedAt)}` |
| 86 | : null}${t.repeats ? html` · said again, first on ${time(t.repeats.receivedAt)}` : null}${t.supersedes.length > 0 | |
| 87 | ? html` · takes back “${t.supersedes[0]!.text.slice(0, 80)}” ${time(t.supersedes[0]!.receivedAt)}` | |
| 86 | 88 | : null}`, |
| 87 | 89 | trail: badge(word.text, { tone: word.tone }), |
| 88 | 90 | }); |
test/mcp.test.ts +1 −1
| @@ -274,7 +274,7 @@ describe("the forge over MCP", () => { | ||
| 274 | 274 | test("thought returns its placement to its author only (#652)", async () => { |
| 275 | 275 | const kept = await tool(rw, "tell", { text: "build a thing" }); |
| 276 | 276 | const id = kept.value.thought; |
| 277 | expect((await tool(rw, "thought", { id })).value).toEqual({ id, placement: null }); | |
| 277 | expect((await tool(rw, "thought", { id })).value).toEqual({ id, placement: null, repeats: [], supersedes: [], supersededBy: null, repeatedBy: [] }); | |
| 278 | 278 | savePlacement(app.db, { |
| 279 | 279 | thoughtId: id, kind: "work", placed: [{ action: "opened", repo: "alice/game", number: 3 }], |
| 280 | 280 | reply: "Opened #3.", asked: false, decidedBy: "rule", placedAt: new Date().toISOString(), |
test/triage.test.ts +99
| @@ -12,12 +12,16 @@ import { openIssue } from "../src/actions/issues.ts"; | ||
| 12 | 12 | import { createRepo, updateRepoSettings } from "../src/actions/repos.ts"; |
| 13 | 13 | import { keepThought } from "../src/actions/thought.ts"; |
| 14 | 14 | import { listComments, getIssue, getIssueById } from "../src/core/issues.ts"; |
| 15 | import { readyNow, repoGraph } from "../src/core/dependencies.ts"; | |
| 16 | import { linksFrom } from "../src/core/thought_links.ts"; | |
| 17 | import { thoughtStream } from "../src/core/thought_stream.ts"; | |
| 15 | 18 | import { placementOf } from "../src/core/placements.ts"; |
| 16 | 19 | import { getRepoById, type Repo } from "../src/core/repos.ts"; |
| 17 | 20 | import { createUser, type User } from "../src/core/users.ts"; |
| 18 | 21 | import { BATCH_MS, placeThought, ruleKind, triageTick } from "../src/crew/triage.ts"; |
| 19 | 22 | import type { Decider } from "../src/crew/decide.ts"; |
| 20 | 23 | import { replyUnder } from "../src/web/studio/feed.ts"; |
| 24 | import { thoughtRow } from "../src/web/studio/asked_page.ts"; | |
| 21 | 25 | |
| 22 | 26 | /** Answers by the first word of the thought: `kind:p:#target:pillar`. */ |
| 23 | 27 | const stub = (script: Record<string, [string, number, string?, string?]>): Decider => ({ |
| @@ -192,6 +196,101 @@ describe("triage", () => { | ||
| 192 | 196 | expect(placementOf(app.db, "stale-0")).toBeUndefined(); |
| 193 | 197 | }); |
| 194 | 198 | |
| 199 | test("a repeat raises what it repeats instead of opening a duplicate, and says when and where it stands", async () => { | |
| 200 | app.config.decider = stub({ build: ["work", 0.9, "none", "P7"] }); | |
| 201 | const first = await placeThought(app, fresh(), alice, think("make the export button visible on phones")); | |
| 202 | const target = getIssue(app.db, repo.id, first.placed[0]!.number)!; | |
| 203 | const other = openIssue(app, alice, fresh(), { title: "Something older and unrelated to anything" }); | |
| 204 | if (!other.ok) throw new Error(String(other.error)); | |
| 205 | const count = () => app.db.query<{ c: number }, []>("SELECT COUNT(*) c FROM issue").get()!.c; | |
| 206 | const before = count(); | |
| 207 | const again = think("Make the export button visible on phones!"); | |
| 208 | const p = await placeThought(app, fresh(), alice, again); | |
| 209 | expect(count()).toBe(before); | |
| 210 | expect(p.placed[0]).toMatchObject({ action: "linked", number: target.number }); | |
| 211 | expect(p.reply).toContain("You asked this on"); | |
| 212 | expect(p.reply).toContain(`#${target.number} is open`); | |
| 213 | expect(linksFrom(app.db, again.id).repeats).toHaveLength(1); | |
| 214 | const ready = readyNow(repoGraph(app.db, repo.id)).map((r) => r.issue.number); | |
| 215 | expect(ready.indexOf(target.number)).toBe(0); | |
| 216 | expect(listComments(app.db, target.id).some((c) => c.body.includes(again.id))).toBe(true); | |
| 217 | }); | |
| 218 | ||
| 219 | test("a reversal links the thought it takes back, which then reads as superseded", async () => { | |
| 220 | app.config.decider = stub({ build: ["work", 0.9, "none", "P7"], scrap: ["reversal", 0.9, "#0"] }); | |
| 221 | const early = think("build a sparkline for spend per day"); | |
| 222 | const placed = await placeThought(app, fresh(), alice, early); | |
| 223 | const num = placed.placed[0]!.number; | |
| 224 | app.config.decider = stub({ scrap: ["reversal", 0.9, `#${num}`] }); | |
| 225 | const late = think(`scrap #${num}, no sparkline`); | |
| 226 | const p = await placeThought(app, fresh(), alice, late); | |
| 227 | expect(p.reply).toContain("you said it on"); | |
| 228 | expect(linksFrom(app.db, late.id).supersedes).toEqual([early.id]); | |
| 229 | const row = thoughtStream(app.db, alice).find((t) => t.id === early.id)!; | |
| 230 | expect(row.state).toBe("superseded"); | |
| 231 | expect(row.supersededBy?.id).toBe(late.id); | |
| 232 | expect(thoughtStream(app.db, alice).find((t) => t.id === late.id)!.supersedes[0]!.id).toBe(early.id); | |
| 233 | }); | |
| 234 | ||
| 235 | test("a reversal in the same words is not a repeat and raises nothing", async () => { | |
| 236 | app.config.decider = stub({ build: ["work", 0.9, "none", "P7"] }); | |
| 237 | const origin = think("make the search box visible on tablets"); | |
| 238 | const first = await placeThought(app, fresh(), alice, origin); | |
| 239 | const target = getIssue(app.db, repo.id, first.placed[0]!.number)!; | |
| 240 | const raised = () => app.db.query<{ c: number }, [string]>("SELECT COUNT(*) c FROM issue_raise WHERE issue_id = ?1").get(target.id)!.c; | |
| 241 | for (const said of [ | |
| 242 | "do not make the search box visible on tablets", | |
| 243 | "don't make the search box visible on tablets", | |
| 244 | "make the search box hidden on tablets", | |
| 245 | ]) { | |
| 246 | const later = think(said); | |
| 247 | const p = await placeThought(app, fresh(), alice, later); | |
| 248 | expect(linksFrom(app.db, later.id).repeats).not.toContain(origin.id); | |
| 249 | expect(p.reply).not.toContain(`#${target.number} is open`); | |
| 250 | expect(raised()).toBe(0); | |
| 251 | } | |
| 252 | }); | |
| 253 | ||
| 254 | test("a repeat of an ask that was deferred says so and does not claim to raise it", async () => { | |
| 255 | app.config.decider = stub({ build: ["work", 0.9, "none", "P7"], scrap: ["reversal", 0.9, "#0"] }); | |
| 256 | const text = "build a heatmap of commits per hour"; | |
| 257 | const placed = await placeThought(app, fresh(), alice, think(text)); | |
| 258 | const num = placed.placed[0]!.number; | |
| 259 | app.config.decider = stub({ scrap: ["reversal", 0.9, `#${num}`], build: ["work", 0.9, "none", "P7"] }); | |
| 260 | await placeThought(app, fresh(), alice, think(`scrap #${num}, no heatmap`)); | |
| 261 | const issue = getIssue(app.db, repo.id, num)!; | |
| 262 | const deferred = repoGraph(app.db, repo.id).deferred.has(issue.id); | |
| 263 | const again = think(text); | |
| 264 | const p = await placeThought(app, fresh(), alice, again); | |
| 265 | if (deferred) { | |
| 266 | expect(p.reply).toContain("deferred"); | |
| 267 | expect(p.reply).not.toContain("Raised it"); | |
| 268 | expect(app.db.query("SELECT 1 FROM issue_raise WHERE issue_id = ?1").get(issue.id)).toBeNull(); | |
| 269 | } else { | |
| 270 | expect(p.reply).not.toContain("deferred"); | |
| 271 | } | |
| 272 | }); | |
| 273 | ||
| 274 | test("the asked page says a thought was said again or takes one back, and escapes the earlier text", async () => { | |
| 275 | app.config.decider = stub({ build: ["work", 0.9, "none", "P7"], scrap: ["reversal", 0.9, "#0"] }); | |
| 276 | const early = think("build a <b>bold</b> gauge for queue depth"); | |
| 277 | const placed = await placeThought(app, fresh(), alice, early); | |
| 278 | const num = placed.placed[0]!.number; | |
| 279 | const again = think("build a <b>bold</b> gauge for queue depth"); | |
| 280 | await placeThought(app, fresh(), alice, again); | |
| 281 | app.config.decider = stub({ scrap: ["reversal", 0.9, `#${num}`] }); | |
| 282 | const late = think(`scrap #${num}, no <i>gauge</i>`); | |
| 283 | await placeThought(app, fresh(), alice, late); | |
| 284 | const rows = thoughtStream(app.db, alice); | |
| 285 | const repeatRow = String(thoughtRow(rows.find((t) => t.id === again.id)!, 7)); | |
| 286 | expect(repeatRow).toContain("said again, first on"); | |
| 287 | const reverseRow = String(thoughtRow(rows.find((t) => t.id === late.id)!, 7)); | |
| 288 | expect(reverseRow).toContain("takes back"); | |
| 289 | expect(reverseRow).toContain("<b>bold</b>"); | |
| 290 | expect(reverseRow).not.toContain("<b>bold</b>"); | |
| 291 | expect(String(thoughtRow(rows.find((t) => t.id === early.id)!, 7))).toContain("superseded by"); | |
| 292 | }); | |
| 293 | ||
| 195 | 294 | test("a reply links its issue numbers and escapes everything else", () => { |
| 196 | 295 | const html = replyUnder({ |
| 197 | 296 | thoughtId: "x", kind: "work", placed: [{ action: "opened", repo: "alice/forge", number: 12 }], |
Nit, not blocking: a raise never expires. Any issue that was ever raised sorts ahead of every limb-ordered issue for as long as it stays open. That is fine for now, but a later pull may want raises to decay, for example dropping them after N days.