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

Merged opened this in answer to

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

Merged

scout/forge:scout/654-supersession-and-repeats-a-later-thought → main 7 commits fork 6 threads · 6 unresolved

  • Merged. scout/654-supersession-and-repeats-a-later-thought was merged into main as 617f70a.

Review

Sign in to review this change.
11 files changed +346 −23
README.md +9
@@ -609,6 +609,15 @@ thoughts are web-only there; `POST /api/v1/thoughts` is the other way in.
609609 Within minutes the planner places each thought on the map and answers under it
610610 in the feed, saying where it went; the MCP `thought` tool returns that
611611 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.
612621
613622 ## Link-preview cards
614623
src/core/dependencies.ts +14 −3
@@ -14,6 +14,7 @@ import { getIssuesByIds, type CloseReason, type Issue } from "./issues.ts";
1414 import { pillarsOf, structuralIds } from "./pillars.ts";
1515 import { getRepoById } from "./repos.ts";
1616 import { unadoptedAmong } from "../crew/direct.ts";
17 import { raisedAt } from "./thought_links.ts";
1718 import { limbRanks, limbTree, readPlaces } from "./limb_order.ts";
1819
1920 export type DependencyError = "self" | "cross_repo" | "cycle" | "not_an_issue";
@@ -222,6 +223,11 @@ export interface Graph {
222223 * here; absent, the ready order is the one that never knew limbs.
223224 */
224225 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>;
225231 }
226232
227233 /**
@@ -335,6 +341,7 @@ export function repoGraph(
335341 places,
336342 ),
337343 }),
344 raised: raisedAt(db, issues.map((i) => i.id)),
338345 issues,
339346 unadopted:
340347 repo === undefined ? new Set() : unadoptedAmong(db, repo, issues),
@@ -538,9 +545,13 @@ export function readyNow(graph: Graph): Ranked[] {
538545 !graph.deferred.has(r.issue.id),
539546 );
540547 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) ?? "";

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.

554 return ready.sort((a, b) => at(b).localeCompare(at(a)));
544555 }
545556
546557 /**
src/core/schema.ts +17
@@ -1754,6 +1754,23 @@ export const migrations: readonly string[] = [
17541754 );
17551755 CREATE INDEX deploy_pull_issue ON deploy_pull(issue_id);
17561756 `,
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: times is written but nothing reads it. Either use it (for example "asked 3 times" on /asked) or remove it in a later migration.

1772 );
1773 `,
17571774 ];
17581775
17591776 /**
src/core/thought_stream.ts +27 −1
@@ -22,7 +22,8 @@ import { placementsOf, type Kind, type Placed } from "./placements.ts";
2222 import { canRead, getRepo, type Repo } from "./repos.ts";
2323 import { listActiveRuns } from "./runs.ts";
2424 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";
2627 import type { User } from "./users.ts";
2728
2829 export type ThoughtState = "unplaced" | "placed" | "building" | "shipped" | "superseded";
@@ -54,6 +55,16 @@ export interface StreamThought {
5455 readonly flagged: boolean;
5556 /** The later thought that superseded this one. */
5657 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;
5768 }
5869
5970 /** 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(
108119 if (x.action === "superseded" && repoOf(x.repo) !== undefined && !supersedes.has(keyOf(x))) supersedes.set(keyOf(x), t);
109120 }
110121
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
111129 return thoughts.map((t) => {
112130 const p = placements.get(t.id);
131 const from = linksFrom(db, t.id);
132 const to = linksTo(db, t.id);
113133 const links: PlacedLink[] = (p?.placed ?? []).map((x) => {
114134 const repo = repoOf(x.repo);
115135 const issue = repo ? getIssue(db, repo.id, x.number) : undefined;
@@ -131,6 +151,8 @@ export function thoughtStream(
131151 const s = supersedes.get(keyOf(x));
132152 if (s !== undefined && s.id !== t.id && s.receivedAt >= t.receivedAt) by = s;
133153 }
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: "" };
134156 const state: ThoughtState =
135157 p === undefined ? "unplaced"
136158 : by !== undefined && !shipped ? "superseded"
@@ -153,6 +175,8 @@ export function thoughtStream(
153175 flagged,
154176 supersededBy:
155177 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) ?? []),
156180 };
157181 });
158182 }
@@ -175,5 +199,7 @@ export function streamJson(t: StreamThought) {
175199 state: t.state,
176200 flagged: t.flagged,
177201 supersededBy: t.supersededBy,
202 repeats: t.repeats,
203 supersedes: t.supersedes,
178204 };
179205 }
src/core/thoughts.ts +10
@@ -25,3 +25,13 @@ export function recentThoughts(db: Database, authorId: string, limit: number, si
2525 .all(authorId, since, limit)
2626 .map((r) => ({ id: r.id, text: r.text, source: r.source, receivedAt: r.received_at }));
2727 }
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 @@
1515 import type { App } from "../app.ts";
1616 import { addComment } from "../actions/issues.ts";
1717 import { openPlan } from "../actions/plan.ts";
18 import { addDependency } from "../core/dependencies.ts";
18 import { addDependency, repoGraph } from "../core/dependencies.ts";
1919 import { getIssue, MAX_TITLE, type Issue } from "../core/issues.ts";
2020 import {
2121 authorsWithUnplaced,
@@ -33,6 +33,7 @@ import { getUserById, type User } from "../core/users.ts";
3333 import { createLog } from "../kernel/log.ts";
3434 import { autonomyStatus, ensureDirectionIssue } from "./autonomy.ts";
3535 import { certain, decideOr, type Answers, type Questions } from "./decide.ts";
36 import { linkThoughts, raiseIssue } from "../core/thought_links.ts";
3637 import { crewOffInPreview } from "./preview.ts";
3738
3839 const log = createLog("triage");
@@ -106,6 +107,74 @@ const clip = (text: string, max: number): string => {
106107 };
107108 const quote = (text: string): string => text.split("\n").map((l) => `> ${l}`).join("\n");
108109
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 not/do are stop words. "do not make X" scores 100% against "make X", and "make X hidden" against "make X visible" scores 80%. Both become repeats that raise the issue the owner just took back. Gate this on the kind (only work/amendment can repeat), or reject a match that differs by a negation, and add a test for it.

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
109178 function openIssues(app: App, repo: Repo, set: Pillars, direction: Issue | undefined): Open[] {
110179 return app.db
111180 .query<{ id: string; number: number; title: string }, [string, number]>(
@@ -144,19 +213,6 @@ export async function placeThought(
144213 const text = thought.text;
145214 const ref = `From thought \`${thought.id}\`:`;
146215
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
160216 const ruled = ruleKind(text, open.length > 0);
161217 const ruledTarget = targetOf(text, open);
162218 const ruledPillar = pillarOf(text, set);
@@ -205,6 +261,25 @@ export async function placeThought(
205261 const target = open.find((o) => `#${o.number}` === pickedTarget);
206262 const pillar = decided.answers["pillar"]?.choice;
207263
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.

208283 const placed: Placed[] = [];
209284 const say: string[] = [];
210285 let question = sure ? "" : `I read this as ${kind === "amendment" ? "an amendment" : `a ${kind}`}; is that right?`;
@@ -256,7 +331,13 @@ export async function placeThought(
256331 if (deferred && gate)
257332 app.bus.append({ kind: "issue.blocked", repoId: repo.id, actorId: by.id, payload: { number: issue.number, blockedBy: gate.number } });
258333 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.`);
260341 }
261342 } else if (direction) {
262343 comment(direction, kind === "vision" ? "The direction changes:" : "A decision:");
src/http/mcp.ts +13 −2
@@ -127,6 +127,7 @@ import {
127127 isAskedOrder,
128128 isAskedShow,
129129 } from "../core/asked.ts";
130 import { linksFrom, linksTo } from "../core/thought_links.ts";
130131 import { streamJson, thoughtStream } from "../core/thought_stream.ts";
131132 import {
132133 readAppMembers,
@@ -1166,7 +1167,7 @@ const TOOLS: readonly Tool[] = [
11661167 {
11671168 name: "thought",
11681169 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.",
11701171 inputSchema: {
11711172 type: "object",
11721173 properties: { id: { type: "string", maxLength: 64 } },
@@ -1180,7 +1181,17 @@ const TOOLS: readonly Tool[] = [
11801181 // Not yours and not there answer alike (a thought belongs to its author).
11811182 if (thoughtAuthor(ctx.app.db, thoughtId) !== user.id)
11821183 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 });
11841195 },
11851196 },
11861197 {
src/web/studio/asked_page.ts +3 −1
@@ -70,7 +70,7 @@ function placedLinks(t: StreamThought): Html {
7070 : html`${links.map((l, i) => html`${i > 0 ? " · " : ""}${l}`)}`;
7171 }
7272
73 function thoughtRow(t: StreamThought, days: number): Html {
73 export function thoughtRow(t: StreamThought, days: number): Html {
7474 const word = t.flagged
7575 ? { text: "Nothing built", tone: "danger" as Tone }
7676 : THOUGHT_WORD[t.state];
@@ -83,6 +83,8 @@ function thoughtRow(t: StreamThought, days: number): Html {
8383 : t.text,
8484 meta: html`${time(t.receivedAt)} · ${t.source} · ${placedLinks(t)}${t.supersededBy
8585 ? 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)}`
8688 : null}`,
8789 trail: badge(word.text, { tone: word.tone }),
8890 });
test/mcp.test.ts +1 −1
@@ -274,7 +274,7 @@ describe("the forge over MCP", () => {
274274 test("thought returns its placement to its author only (#652)", async () => {
275275 const kept = await tool(rw, "tell", { text: "build a thing" });
276276 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: [] });
278278 savePlacement(app.db, {
279279 thoughtId: id, kind: "work", placed: [{ action: "opened", repo: "alice/game", number: 3 }],
280280 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";
1212 import { createRepo, updateRepoSettings } from "../src/actions/repos.ts";
1313 import { keepThought } from "../src/actions/thought.ts";
1414 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";
1518 import { placementOf } from "../src/core/placements.ts";
1619 import { getRepoById, type Repo } from "../src/core/repos.ts";
1720 import { createUser, type User } from "../src/core/users.ts";
1821 import { BATCH_MS, placeThought, ruleKind, triageTick } from "../src/crew/triage.ts";
1922 import type { Decider } from "../src/crew/decide.ts";
2023 import { replyUnder } from "../src/web/studio/feed.ts";
24 import { thoughtRow } from "../src/web/studio/asked_page.ts";
2125
2226 /** Answers by the first word of the thought: `kind:p:#target:pillar`. */
2327 const stub = (script: Record<string, [string, number, string?, string?]>): Decider => ({
@@ -192,6 +196,101 @@ describe("triage", () => {
192196 expect(placementOf(app.db, "stale-0")).toBeUndefined();
193197 });
194198
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("&lt;b&gt;bold&lt;/b&gt;");
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
195294 test("a reply links its issue numbers and escapes everything else", () => {
196295 const html = replyUnder({
197296 thoughtId: "x", kind: "work", placed: [{ action: "opened", repo: "alice/forge", number: 12 }],

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs