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

Notes: write an idea in one box and watch it become shipped work #8703

Merged opened this in answer to

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

Merged

scout/forge:scout/8696-notes-write-an-idea-in-one-box-and-watch → main 5 commits fork 3 threads · 3 unresolved

  • Merged. scout/8696-notes-write-an-idea-in-one-box-and-watch was merged into main as dc44b30.

Review

Sign in to review this change.
20 files changed +943 −18
docs/design/areas/notes.md added +3
@@ -0,0 +1,3 @@
1 # Notes
2
3 `/notes` (`src/web/pages/notes.ts`): the composer is `.note-box` (a textarea and a bar), and each row's one-line state is `.note-line` inside a §5 row's meta.
docs/design/audit.md +1
@@ -61,6 +61,7 @@ Machine routes are not pages and have no entry: `/audit.csv`,
6161 - `/review` — `studio/review_page.ts` · every ask waiting on me, one row each, newest first · `/asks` (same asks, with buttons) · none seen.
6262 - `/flow` — `pages/flow.ts` · the pipeline from ready issue to live deploy, and the bottleneck · `/system`, `/board`, `/` · none seen. A topbar button points here.
6363 - `/talk` — `pages/talk.ts` · the person's one conversation with the forge: history, composer, Confirm and Cancel; replies stream in over `/talk/stream` · `/settings/confirms` (the same yes, as a list), the home tell box (one line to an app's crew) · none seen; styles in `docs/design/areas/talk.md`.
64 - `/notes` — `pages/notes.ts` · what the person wrote, newest first, with the planner's verdict and live state in one line, and one tap to overrule it · the home tell box (Notes button), the user menu, the palette · none seen; styles in `docs/design/areas/notes.md`.
6465 - `/pipeline` — `pages/pipeline.ts` · the four steps a change follows (check, review, land, live), for a newcomer · a pull page's step line, `/flow` · none seen.
6566 - `/map` — `pages/map.ts` (1,806 lines) · the dependency map of work across the forge · `/board`, issue dependency lists, `/:owner/:name/map` · colours 3, sizes 12: the largest private vocabulary.
6667 - `/audit` — `pages/audit.ts` · every app's contract, who can do what · the app page's one-line policy · none seen. Names the audit of apps, not this document.
docs/features/notes.md added +26
@@ -0,0 +1,26 @@
1 # Notes
2
3 `/notes` is one box and a list. Type an idea, press Enter (Shift+Enter for a new line; the Save button when there is no script), and it is kept as a thought (`keepThought`, source `composer`, the same door the API and MCP use). Your notes are listed newest first, yours alone.
4
5 The planner (`crew/triage.ts`) places every note without asking, within a couple of minutes:
6
7 - a long dump becomes several issues: its bullets or numbered lines, else its blank-line paragraphs (`splitIdeas`, at most 8), each issue linked back to the note;
8 - an idea that clearly touches an open issue (most of its words shared, not a reversal) is added to that issue as a comment instead of a duplicate (`attachTarget`);
9 - a repeat, a reversal, a vision and a decision behave as before.
10
11 Each note carries one line (`core/note_line.ts`): *Waiting for the planner*, *Planned*, *Building*, *In review*, *Shipped in version N*, with links to the issues and pulls. A split dump says "3 issues: 1 shipped, 1 in review, 1 planned". The version is read from the release commits of the forge's own repository, so it is named only for work shipped there; elsewhere the line says *Shipped*.
12
13 Reached from the Notes button beside the tell box on home, the user menu, and the palette (`Ctrl/Cmd+K`). Writes need a session or a token with `rw` scope and the same origin.
14
15 ## The planner decides; you overrule in one tap
16
17 Nothing waits for you to accept a note. The planner gives each one a verdict and the line says which, and why:
18
19 - **Accepted**: it opened or amended issues; the line then follows them (planned, building, in review, shipped). Tap *Not this* while it is only planned and the issues it opened are closed.
20 - **Declined**: nothing to build (an empty note, a bare repository name). Tap *Build it anyway* and an issue is opened from it.
21 - **Duplicate**: the same ask as an earlier note; the line says when you asked and where that stands, and the earlier issue is raised. Tap *Build it anyway* for a new issue.
22 - **Snoozed**: it says "someday", "not now", "eventually", "low priority" and the like, and the repository has a Later gate: the issue is opened behind the gate. Tap *Build it anyway* to let it out.
23
24 An overrule is `POST /notes/:id/overrule` (`do=build|decline`), kept as the setting `note.<thought id>` on you; the planner's own placement is never rewritten, only added to.
25
26 The terminal has the same door: the palette's "Notes" (`src/tui/screens/thoughtnotes.ts`) writes with `n`, lists your own notes with the same one-line state, and `b` takes the tap the line offers. An overrule is once per note and only what the line offers; a repeat or a stale form does nothing. "Snoozed" is judged per idea in a dump, and "backlog" is not a snooze word.
forge.json +1 −1
@@ -3,7 +3,7 @@
33 "api.anthropic.com"
44 ],
55 "app": {
6 "looks": ["/", "/forges", "/family", "/front", "/explore", "/board", "/boards", "/asked", "/feed", "/review", "/search", "/new", "/audit", "/releases", "/changelog", "/apps", "/migrate", "/map", "/system", "/talk", "/contribute", "/pipeline", "/resilience", "/settings"],
6 "looks": ["/", "/forges", "/family", "/front", "/explore", "/board", "/boards", "/notes", "/feed", "/review", "/search", "/new", "/audit", "/releases", "/changelog", "/apps", "/migrate", "/map", "/system", "/talk", "/contribute", "/pipeline", "/resilience", "/settings"],
77 "run": "bun run src/main.ts",
88 "install": "",
99 "health": "/healthz",
src/actions/note_overrule.ts added +82
@@ -0,0 +1,82 @@
1 /**
2 * One tap against the planner's verdict on a note (#8696). The planner
3 * accepts, declines, snoozes or calls a note a duplicate without asking; the
4 * person's say is final and is one action: `build` (a declined, duplicate or
5 * snoozed note is built after all) or `decline` (an accepted note's own
6 * issues are closed). The note is the person's alone; the overrule is kept as
7 * a setting on them and, for `build`, as the issue it opened.
8 */
9
10 import type { App } from "../app.ts";
11 import { openPlan } from "./plan.ts";
12 import { setIssueState } from "./issues.ts";
13 import { removeDependency } from "../core/dependencies.ts";
14 import { getIssue, MAX_TITLE } from "../core/issues.ts";
15 import { appendPlaced, type Placed } from "../core/placements.ts";
16 import { noteLine } from "../core/note_line.ts";
17 import { pillarsOf } from "../core/pillars.ts";
18 import { getRepo } from "../core/repos.ts";
19 import { setUserSetting } from "../core/settings.ts";
20 import { overruleKey, thoughtStream } from "../core/thought_stream.ts";
21 import { thoughtOf } from "../core/thoughts.ts";
22 import type { User } from "../core/users.ts";
23 import { err, ok, type Result } from "../kernel/result.ts";
24 import { homeOf } from "../crew/triage.ts";
25
26 export type OverruleError = "unauthorized" | "not_found" | "nothing_to_do" | "failed";
27
28 export function overruleNote(app: App, actor: User | undefined, thoughtId: string, action: unknown): Result<"accepted" | "declined", OverruleError> {
29 if (!actor) return err("unauthorized");
30 if (action !== "build" && action !== "decline") return err("nothing_to_do");
31 const thought = thoughtOf(app.db, actor.id, thoughtId);
32 const note = thought && thoughtStream(app.db, actor, { limit: 200 }).find((n) => n.id === thought.id);
33 if (!thought || !note || note.placedAt === null) return err("not_found");
34 // Only what the line offers: one tap, once. A replay or a stale form finds nothing to do.
35 if (note.overruled !== null || noteLine(note).overrule?.do !== action) return err("nothing_to_do");
36 const slugRepo = (slug: string) => {
37 const [owner, name] = slug.split("/");
38 return owner && name ? getRepo(app.db, owner, name) : undefined;
39 };
40
41 if (action === "decline") {
42 for (const x of note.placed) {
43 if (x.action !== "opened") continue;
44 const repo = slugRepo(x.repo);
45 const issue = repo && getIssue(app.db, repo.id, x.number);
46 if (repo && issue && issue.state === "open") setIssueState(app, actor, repo, issue, "closed");
47 }
48 setUserSetting(app.db, actor.id, overruleKey(thought.id), "declined");
49 app.bus.append({ kind: "thought.overruled", actorId: actor.id, payload: { id: thought.id, to: "declined" } });
50 return ok("declined");
51 }
52
53 // build: a snoozed note is let out from behind the Later gate; any other is opened now.
54 const snoozed = note.placed.filter((x) => x.action === "deferred");
55 const opened: Placed[] = [];
outdated

Medium: build is not tied to the verdict. Any placed note that is not snoozed falls to the else branch and opens a new issue every call, including repeat taps and accepted notes. Refuse when note.overruled !== null or the note's line offers no build overrule.

56 if (note.kind === "work" && snoozed.length > 0 && note.overruled === null) {
57 for (const x of snoozed) {
58 const repo = slugRepo(x.repo);
59 const issue = repo && getIssue(app.db, repo.id, x.number);
60 const gate = repo && pillarsOf(app.db, repo).gate;
61 if (issue && gate) removeDependency(app.db, issue.id, gate.id);
62 }
63 } else {
64 const repo = homeOf(app, actor.id);
65 if (!repo) return err("failed");
66 const slug = `${repo.ownerName}/${repo.name}`;
67 const made = openPlan(app, actor, repo, [
68 {
69 key: "thought",
70 title: (thought.text.replace(/\s+/g, " ").trim().slice(0, Math.min(MAX_TITLE, 120)) || "Note"),
71 body: `From thought \`${thought.id}\`, built on its author's say:\n\n${thought.text.split("\n").map((l) => `> ${l}`).join("\n")}`,
72 blockedBy: [],
73 },
74 ]);
75 if (!made.ok) return err("failed");
76 for (const o of made.value.opened) opened.push({ action: "opened", repo: slug, number: o.number });
77 appendPlaced(app.db, thought.id, opened);
78 }
79 setUserSetting(app.db, actor.id, overruleKey(thought.id), "accepted");
80 app.bus.append({ kind: "thought.overruled", actorId: actor.id, payload: { id: thought.id, to: "accepted" } });
81 return ok("accepted");
82 }
src/core/note_line.ts added +90
@@ -0,0 +1,90 @@
1 /**
2 * A note's live state in one line (#8696). The planner decides each note on
3 * its own — accept (it opened or amended issues), decline (nothing to
4 * build), duplicate (the same ask as an earlier note) or snooze (opened, but
5 * behind the Later gate) — and the line says which and why, then, for an
6 * accepted note, how far it got: planned, building, in review, shipped (in
7 * version N when the release is known). Every verdict has one tap that
8 * overrules it (`actions/note_overrule.ts`). Plain data in, plain data out:
9 * the page draws the words and links, escaping both.
10 */
11
12 import { BUILDS, type StreamThought } from "./thought_stream.ts";
13
14 export type NotePhase = "waiting" | "planned" | "building" | "review" | "shipped" | "superseded" | "declined" | "duplicate" | "snoozed";
15
16 export interface NoteLink {
17 readonly label: string;
18 /** A path on this forge. */
19 readonly href: string;
20 }
21
22 /** What one tap does to the planner's verdict. */
23 export type Overrule = { readonly do: "build" | "decline"; readonly label: string };
24
25 export interface NoteLine {
26 /** The verdict while the planner's call stands, else where the accepted work is: the earliest phase among its issues. */
27 readonly phase: NotePhase;
28 readonly text: string;
29 readonly links: readonly NoteLink[];
30 readonly overrule: Overrule | null;
31 }
32
33 /** The version a merged pull of `repo` (owner/name) went out in, when known. */
34 export type VersionOf = (repo: string, pull: number) => string | undefined;
35
36 const ORDER: readonly NotePhase[] = ["planned", "building", "review", "shipped"];
37 const WORD: Record<NotePhase, string> = {
38 waiting: "Waiting for the planner",
39 planned: "Planned",
40 building: "Building",
41 review: "In review",
42 shipped: "Shipped",
43 superseded: "Superseded",
44 declined: "Declined",
45 duplicate: "Duplicate",
46 snoozed: "Snoozed",
47 };
48 const BUILD: Overrule = { do: "build", label: "Build it anyway" };
49 const DECLINE: Overrule = { do: "decline", label: "Not this" };
50
51 export function noteLine(t: StreamThought, versionOf: VersionOf = () => undefined): NoteLine {
52 if (t.placedAt === null) return { phase: "waiting", text: WORD.waiting, links: [], overrule: null };
53 if (t.state === "superseded") return { phase: "superseded", text: WORD.superseded, links: [], overrule: null };
54 const why = t.reply ?? "";
55 const issues = t.placed.filter((x) => x.readable && BUILDS.has(x.action));
56 const own = (x: (typeof issues)[number]) => ({ label: `#${x.number}`, href: `/${x.repo}/issues/${x.number}` });
57 if (t.overruled === "declined")
58 return { phase: "declined", text: "Declined by you", links: issues.map(own), overrule: null };
59 if (t.overruled === null && issues.length === 0 && t.placed.every((x) => x.action !== "deferred"))
60 return { phase: "declined", text: `Declined: ${why.replace(/^Declined:\s*/i, "") || "nothing to build"}`, links: [], overrule: BUILD };
61
62 const links: NoteLink[] = [];
63 const phases: NotePhase[] = [];
64 let version: string | undefined;
65 for (const x of issues) {
66 const merged = x.pulls.find((p) => p.state === "merged");
67 const open = x.pulls.find((p) => p.state === "open");
68 phases.push(merged ? "shipped" : open ? "review" : x.running ? "building" : "planned");
69 links.push(own(x));
70 const pull = merged ?? open;
71 if (pull) links.push({ label: `pull #${pull.number}`, href: `/${x.repo}/pulls/${pull.number}` });
72 if (merged) version ??= versionOf(x.repo, merged.number);
73 }
74 const phase = ORDER.find((p) => phases.includes(p)) ?? "planned";
75 const progress =
76 issues.length === 1
77 ? phase === "shipped" && version !== undefined
78 ? `Shipped in version ${version}`
79 : WORD[phase]
80 : `${issues.length} issues: ${[...ORDER].reverse().flatMap((p) => {
81 const n = phases.filter((q) => q === p).length;
82 return n === 0 ? [] : [`${n} ${WORD[p].toLowerCase()}`];
83 }).join(", ")}`;
84
85 if (t.overruled === null && t.kind === "work" && t.placed.some((x) => x.action === "deferred"))
86 return { phase: "snoozed", text: "Snoozed: you said it can wait", links, overrule: BUILD };
87 if (t.overruled === null && t.repeats !== null)
88 return { phase: "duplicate", text: `Duplicate: ${why} ${issues.length > 0 ? `(${progress.toLowerCase()})` : ""}`.trim(), links, overrule: BUILD };

Low: Snoozed: ${why} doubles the prefix because the planner's reply already begins 'Snoozed:'. Same for the Declined line when the reply carries its own prefix.

89 return { phase, text: progress, links, overrule: phase === "planned" && t.overruled === null && issues.some((x) => x.action === "opened") ? DECLINE : null };
90 }
src/core/placements.ts +7
@@ -60,6 +60,13 @@ function toPlacement(r: Row): Placement {
6060 };
6161 }
6262
63 /** Add what a person's overrule did to a placement (#8696); what the planner wrote stays as it was. */
64 export function appendPlaced(db: Database, thoughtId: string, more: readonly Placed[]): void {
65 const now = placementOf(db, thoughtId);
66 if (!now) return;
67 db.query("UPDATE placement SET placed = ?2 WHERE thought_id = ?1").run(thoughtId, JSON.stringify([...now.placed, ...more]));
68 }
69
6370 export function placementOf(db: Database, thoughtId: string): Placement | undefined {
6471 const row = db.query<Row, [string]>("SELECT * FROM placement WHERE thought_id = ?1").get(thoughtId);
6572 return row ? toPlacement(row) : undefined;
src/core/reserved.ts +1
@@ -111,6 +111,7 @@ export const SYSTEM_NAMES: readonly string[] = [
111111 "sw.js",
112112 "tell",
113113 "talk",
114 "notes",
114115 "theme",
115116 "verify",
116117 "recover",
src/core/thought_stream.ts +24 −2
@@ -29,6 +29,8 @@ import type { User } from "./users.ts";
2929 export type ThoughtState = "unplaced" | "placed" | "building" | "shipped" | "superseded";
3030
3131 export const FLAG_DAYS = 3;
32 /** The user setting that holds a person's overrule of one note. */
33 export const overruleKey = (thoughtId: string): string => `note.${thoughtId}`;
3234 export const FLAG_DAYS_KEY = "thoughtFlagDays";
3335 export const THOUGHT_LIMIT = 200;
3436
@@ -40,6 +42,9 @@ export interface PlacedLink extends Placed {
4042 readonly readable: boolean;
4143 readonly title: string | null;
4244 readonly issueState: "open" | "closed" | null;
45 /** The pulls that close it, and whether a run is on it now (what a note's one line reads). */
46 readonly pulls: readonly { readonly number: number; readonly state: "open" | "merged" | "closed" }[];
47 readonly running: boolean;
4348 }
4449
4550 export interface StreamThought {
@@ -55,6 +60,8 @@ export interface StreamThought {
5560 readonly placed: readonly PlacedLink[];
5661 readonly state: ThoughtState;
5762 readonly flagged: boolean;
63 /** What the person said against the planner's verdict (#8696), if they did. */
64 readonly overruled: "accepted" | "declined" | null;
5865 /** The later thought that superseded this one. */
5966 readonly supersededBy: { readonly id: string; readonly text: string; readonly receivedAt: string } | null;
6067 /** The earlier thought this one says again (#654). */
@@ -76,7 +83,7 @@ export function flagDays(db: Database, userId: string): number {
7683 }
7784
7885 const keyOf = (p: Placed) => `${p.repo}#${p.number}`;
79 const BUILDS = new Set(["opened", "amended", "linked"]);
86 export const BUILDS = new Set(["opened", "amended", "linked"]);
8087
8188 export function thoughtStream(
8289 db: Database,
@@ -128,6 +135,12 @@ export function thoughtStream(
128135 return t ? { id: t.id, text: t.text, receivedAt: t.receivedAt } : null;
129136 };
130137
138 const overrules = new Map(
139 db
140 .query<{ key: string; value: string }, [string]>("SELECT key, value FROM user_setting WHERE user_id = ?1 AND key LIKE 'note.%'")
141 .all(viewer.id)
142 .map((r) => [r.key, r.value]),
143 );
131144 return thoughts.map((t) => {
132145 const p = placements.get(t.id);
133146 const from = linksFrom(db, t.id);
@@ -135,7 +148,15 @@ export function thoughtStream(
135148 const links: PlacedLink[] = (p?.placed ?? []).map((x) => {
136149 const repo = repoOf(x.repo);
137150 const issue = repo ? getIssue(db, repo.id, x.number) : undefined;
138 return { ...x, readable: repo !== undefined, title: issue?.title ?? null, issueState: issue?.state ?? null };
151 const its = repo ? (pullsOf(repo).get(`${repo.id}#${x.number}`) ?? []) : [];
152 return {
153 ...x,
154 readable: repo !== undefined,
155 title: issue?.title ?? null,
156 issueState: issue?.state ?? null,
157 pulls: its.map((q) => ({ number: q.number, state: q.state })),
158 running: issue !== undefined && activeRuns.has(issue.id),
159 };
139160 });
140161 let built = false;
141162 let shipped = false;
@@ -176,6 +197,7 @@ export function thoughtStream(
176197 placed: links,
177198 state,
178199 flagged,
200 overruled: ((v) => (v === "accepted" || v === "declined" ? v : null))(overrules.get(overruleKey(t.id))),
179201 supersededBy:
180202 state === "superseded" && by ? { id: by.id, text: by.text, receivedAt: by.receivedAt } : null,
181203 repeats: from.repeats[0] ? ref(from.repeats[0]) : null,
src/crew/triage.ts +86 −15
@@ -90,6 +90,48 @@ function targetOf(text: string, open: readonly Open[]): Open | undefined {
9090 return best;
9191 }
9292
93 /** Words that say an idea can wait. */
94 const SNOOZE = /\b(someday|some day|maybe later|not now|eventually|nice to have|when we have time|low priority)\b/i;
outdated

Medium: 'backlog' is an ordinary product word and will snooze real work; and this regex is applied to the whole text (see SNOOZE.test(text) below), so one bullet snoozes the whole dump. Match per idea and remove 'backlog'.

95 /** At most this many issues come of one note. */
96 const IDEAS_MAX = 8;
97 const BULLET = /^\s*(?:[-*•]|\d+[.)])\s+/;
98
99 /**
100 * The ideas in a dump: its bullets or numbered lines when it has two or more
101 * (a line that continues one belongs to it), else its blank-line paragraphs;
102 * otherwise the whole text, one idea. An item of under three words is
103 * a heading or a stray, and is kept with the one before it.
104 */
105 export function splitIdeas(text: string): string[] {
106 const lines = text.split("\n");
107 let items: string[] = [];
108 if (lines.filter((l) => BULLET.test(l)).length >= 2) {
109 for (const l of lines) {
110 if (BULLET.test(l)) items.push(l.replace(BULLET, "").trim());
111 else if (items.length > 0 && l.trim() !== "") items[items.length - 1] += `\n${l.trim()}`;
112 }
113 } else items = text.split(/\n\s*\n/).map((p) => p.trim());
114 const kept: string[] = [];
115 for (const item of items.filter((i) => i !== "")) {
116 if (words(item).size < 3 && kept.length > 0) kept[kept.length - 1] += `\n${item}`;
117 else kept.push(item);
118 }
119 return kept.length < 2 || kept.length > IDEAS_MAX ? [text] : kept;
120 }
121
122 /** The open issue an idea clearly touches (most of the shorter side's words are shared), else none. */
123 export function attachTarget(idea: string, open: readonly Open[]): Open | undefined {
124 const mine = words(idea);
125 let best: Open | undefined;
126 let most = 0;
127 for (const o of open) {
128 const theirs = words(o.title);
129 const n = overlap(mine, theirs);
130 if (n >= 3 && n / Math.min(mine.size, theirs.size) >= 0.6 && n > most && !opposed(idea, o.title)) (best = o), (most = n);
131 }
132 return best;
133 }
134
93135 function pillarOf(text: string, set: Pillars): string | undefined {
94136 if (set.pillars.length === 0) return undefined;
95137 const mine = words(text);
@@ -309,22 +351,51 @@ export async function placeThought(
309351 kind = kind === "reversal" ? "decision" : "work";
310352 }
311353 if (kind === "work") {
312 const first = text.trim().split("\n")[0] ?? "";
313 const opened = openPlan(app, by, repo, [
314 {
315 key: "thought",
316 title: clip(first, Math.min(MAX_TITLE, 120)) || "Thought",
317 body: `${ref}\n\n${quote(text)}${pillar ? `\n\nServes ${set.pillars.find((p) => p.short === pillar)?.name}.` : ""}`,
318 blockedBy: [],
319 ...(pillar ? { pillar } : {}),
320 },
321 ]);
322 if (opened.ok) {
323 const number = opened.value.opened[0]!.number;
324 placed.push({ action: "opened", repo: slug, number, ...(pillar ? { pillar } : {}) });
354 // A long dump is several ideas: each is attached to the open issue it
355 // touches, or becomes an issue of its own, linked back to this thought.
356 const ideas = splitIdeas(text);
357 const fresh: string[] = [];
358 for (const idea of ideas) {
359 const near = attachTarget(idea, open);
360 const issue = near ? here(near.number) : undefined;
361 if (issue) {
362 comment(issue, "A new thought touches this:");
363 placed.push({ action: "amended", repo: slug, number: issue.number });
364 say.push(`Added to #${issue.number}.`);
365 } else fresh.push(idea);
366 }
367 // Said to wait: it is opened, and sits behind the Later gate until its author says otherwise.
368 const waits = (idea: string): boolean => set.gate !== undefined && SNOOZE.test(idea);
369 const opened =
370 fresh.length === 0
371 ? undefined
372 : openPlan(
373 app,
374 by,
375 repo,
376 fresh.map((idea, i) => ({
377 key: `thought${i}`,
378 title: clip(idea.split("\n")[0] ?? "", Math.min(MAX_TITLE, 120)) || "Thought",
379 body: `${ref}\n\n${quote(idea)}${pillar ? `\n\nServes ${set.pillars.find((p) => p.short === pillar)?.name}.` : ""}`,
380 blockedBy: [],
381 ...(waits(idea) ? { later: "You said it can wait." } : pillar ? { pillar } : {}),
382 })),
383 );
384 if (opened?.ok) {
325385 const name = set.pillars.find((p) => p.short === pillar)?.name;
326 say.push(`Opened #${number}${name ? ` under ${name}` : ""}.`);
327 } else if (direction) {
386 const numbers = opened.value.opened.map((o) => o.number);
387 const later = new Set(fresh.flatMap((idea, i) => (waits(idea) ? [`thought${i}`] : [])));
388 const waiting = opened.value.opened.filter((o) => later.has(o.key)).map((o) => o.number);
389 for (const number of numbers) {
390 const waits = waiting.includes(number);
391 placed.push({ action: "opened", repo: slug, number, ...(pillar && !waits ? { pillar } : {}) });
392 if (waits) placed.push({ action: "deferred", repo: slug, number });
393 }
394 const list = (ns: readonly number[]) => ns.map((n) => `#${n}`).join(", ");
395 const now = numbers.filter((n) => !waiting.includes(n));
396 if (now.length > 0) say.push(`Opened ${list(now)}${name ? ` under ${name}` : ""}.`);
397 if (waiting.length > 0) say.push(`Snoozed: you said it can wait, so ${list(waiting)} sits behind the Later gate.`);
398 } else if (opened && direction) {
328399 comment(direction, "A thought that could not be opened as an issue:");
329400 placed.push({ action: "amended", repo: slug, number: direction.number });
330401 say.push(`Could not open it as an issue (${opened.error.message}); kept on #${direction.number}.`);
src/http/server.ts +4
@@ -226,6 +226,7 @@ import { forgeLinkSubmit, forgesPage } from "../web/pages/forges.ts";
226226 import { forgeItSubmit } from "../web/pages/forge-it.ts";
227227 import { newForgePage, newForgeSubmit } from "../web/pages/new-forge.ts";
228228 import { askedPage } from "../web/studio/asked_page.ts";
229 import { notesOverrule, notesSave, notesView } from "../web/pages/notes.ts";
229230 import { reviewPage } from "../web/studio/review_page.ts";
230231 import { reviewAct } from "../web/studio/review_act.ts";
231232 import {
@@ -734,6 +735,9 @@ export function buildRouter(): Router<Handler> {
734735 router.add("POST", "/asks/decide/:id/undo", asksDecideUndo);
735736 // What people asked of the crew, and what became of it (core/asked.ts).
736737 router.add("GET", "/asked", askedPage);
738 router.add("GET", "/notes", notesView);
739 router.add("POST", "/notes", notesSave);
740 router.add("POST", "/notes/:id/overrule", notesOverrule);
737741 router.add("GET", "/review", reviewPage);
738742 router.add("POST", "/review/act", reviewAct);
739743 // The big view, folded into the board: every old address lands on its wall mode.
src/tui/forge.ts +9
@@ -33,6 +33,7 @@ import {
3333 import { chainScreen } from "./screens/chain.ts";
3434 import { canSeeMemory } from "../boards/memory.ts";
3535 import { askedScreen } from "./screens/asked.ts";
36 import { notesScreen } from "./screens/thoughtnotes.ts";
3637 import { deciderScreen } from "./screens/decider.ts";
3738 import { familyScreen } from "./screens/family.ts";
3839 import { familyLines, familyView } from "../spawn/family.ts";
@@ -254,6 +255,14 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] {
254255 hint: "your thoughts and what became of them",
255256 run: () => ctx.push(askedScreen(session, ctx)),
256257 });
258 // One box to write an idea in, each note with its live state; /notes' own door (#8696).
259 if (session.signedIn)
260 commands.push({
261 id: "thought-notes",
262 title: "Notes",
263 hint: "write an idea and watch it become shipped work",
264 run: () => ctx.push(notesScreen(session, ctx)),
265 });
257266 if (session.signedIn)
258267 commands.push({
259268 id: "decider",
src/tui/screens/thoughtnotes.ts added +134
@@ -0,0 +1,134 @@
1 /**
2 * /notes in the terminal (#8696): the same door as the page. `n` writes a
3 * note (`keepThought`, the one door the page, the composer and the MCP use),
4 * the list is the signed-in person's own `thoughtStream` with `noteLine`'s
5 * one-line state, and `b` takes the tap the line offers (`overruleNote`, which
6 * refuses what the line does not offer). Everything is the person's own: a
7 * guest has nothing, and the palette offers the screen only when signed in.
8 * A note written here shows live on the page, being the same row.
9 */
10
11 import { overruleNote } from "../../actions/note_overrule.ts";
12 import { keepThought, THOUGHT_MAX } from "../../actions/thought.ts";
13 import { noteLine, type NotePhase } from "../../core/note_line.ts";
14 import { thoughtStream } from "../../core/thought_stream.ts";
15 import type { User } from "../../core/users.ts";
16 import type { Database } from "bun:sqlite";
17 import type { Ctx, Screen } from "../app/app.ts";
18 import { binding } from "../app/keys.ts";
19 import type { Session } from "../session.ts";
20 import { split, fill, len } from "../ui/layout.ts";
21 import { emptyState, input, type Tone } from "../ui/widgets.ts";
22 import { style } from "../term/style.ts";
23 import { truncate } from "../term/width.ts";
24 import { ListState, drawList, hitList, metaOf, relTime, type ListRow } from "./common.ts";
25
26 const SHOWN = 50;
27
28 /** One line of plain text: a typed note may carry newlines or escapes the terminal must not obey. */
29 const plain = (text: string): string =>
30 // eslint-disable-next-line no-control-regex
31 text.replace(/[\u0000-\u001f\u007f-\u009f]+/g, " ").trim();
32
33 const TONE: Partial<Record<NotePhase, Tone>> = { building: "attn", review: "attn", shipped: "success" };
34
35 export interface NoteRow extends ListRow {
36 readonly id: string;
37 /** The tap the line offers, if any. */
38 readonly overrule: "build" | "decline" | null;
39 }
40
41 /** The viewer's notes as rows; a guest has none. */
42 export function noteRows(db: Database, viewer: User | undefined): NoteRow[] {
43 if (viewer === undefined) return [];
44 return thoughtStream(db, viewer, { limit: SHOWN }).map((n) => {
45 const line = noteLine(n);
46 const tone = TONE[line.phase];
47 return {
48 id: n.id,
49 title: plain(n.text),
50 ...(tone ? { tone } : {}),
51 hollow: line.phase === "waiting" || line.phase === "superseded",
52 secondary: plain(line.text + (line.links.length > 0 ? ` · ${line.links.map((l) => l.label).join(" ")}` : "") + (line.overrule ? ` · b: ${line.overrule.label}` : "")),
53 meta: metaOf(line.phase, relTime(n.receivedAt)),
54 overrule: line.overrule?.do ?? null,
55 };
56 });
57 }
58
59 export function notesScreen(f: Session, ctx: Ctx): Screen {
60 const state = new ListState();
61 let rows: NoteRow[] = [];
62 let typing = false;
63 let draft = "";
64 const read = (): void => {
65 rows = noteRows(f.db, f.user);
66 state.clamp(rows.length);
67 };
68 const save = (): void => {
69 if (f.user === undefined || draft.trim() === "") return;
70 const kept = keepThought(f.app, f.user, draft, "composer");
71 if (!kept.ok) {
72 ctx.toast(`A note is at most ${THOUGHT_MAX} characters`, "attn");
73 return;
74 }
75 draft = "";
76 typing = false;
77 read();
78 state.index = 0;
79 state.offset = 0;
80 ctx.toast("Saved. The planner will pick it up.", "success");
81 };
82 const overrule = (): void => {
83 const row = rows[state.index];
84 if (row === undefined || row.overrule === null) return;
85 const done = overruleNote(f.app, f.user, row.id, row.overrule);
86 ctx.toast(done.ok ? (done.value === "accepted" ? "Built after all" : "Declined") : "That note cannot be changed now", done.ok ? "success" : "attn");
87 read();
88 };
89 return {
90 title: "Notes",
91 get keys() {
92 return typing
93 ? [binding(["enter"], "save"), binding(["esc"], "cancel")]
94 : [binding(["j", "k"], "move"), binding(["n"], "write a note"), binding(["b"], "overrule the planner"), binding(["r"], "refresh")];
95 },
96 load: async () => read(),
97 render(area, s) {
98 const c = ctx.theme.c;
99 const [head, body] = split(area, "v", [len(typing ? 2 : 1), fill(1)]);
100 if (typing) {
101 input(s, { ...head!, y: head!.y }, c, { value: draft, placeholder: "Write an idea. Enter saves it.", focused: true, cursor: [...draft].length });
102 } else s.text(head!.x, head!.y, truncate(`${rows.length} notes · n writes one`, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w);
103 if (rows.length === 0) {
104 emptyState(s, body!, c, { title: "No notes yet", body: "Press n and write an idea. The crew plans it and this page says where it is." });
105 return;
106 }
107 drawList(s, body!, c, state, rows);
108 hitList(ctx, body!, state, rows.length, {});
109 },
110 key(e) {
111 if (typing) {
112 if (e.key === "enter") save();
113 else if (e.key === "esc") typing = false;
114 else if (e.key === "backspace") draft = [...draft].slice(0, -1).join("");
115 else if (!e.ctrl && !e.alt && [...e.key].length === 1) draft += e.key;
116 ctx.invalidate();
117 return true;
118 }
119 if (state.key(e, rows.length)) return true;
120 if (e.key === "n") typing = true;
121 else if (e.key === "b") overrule();
122 else if (e.key === "r") read();
123 else return false;
124 ctx.invalidate();
125 return true;
126 },
127 paste(text) {
128 if (!typing) return false;
129 draft += plain(text);
130 ctx.invalidate();
131 return true;
132 },
133 };
134 }
src/web/layout.ts +2
@@ -239,6 +239,7 @@ function userMenu(
239239 { label: "Your profile", href: `/${user.username}`, icon: "user" },
240240 { label: "Your repositories", href: "/", icon: "repo" },
241241 { label: "Apps", href: "/apps", icon: "rocket" },
242 { label: "Notes", href: "/notes", icon: "comment" },
242243 { label: "Talk", href: "/talk", icon: "comment" },
243244 { label: "Explore", href: "/explore", icon: "search" },
244245 { label: "Map", href: "/map", icon: "map" },
@@ -317,6 +318,7 @@ const STATIC_PALETTE: PaletteItem[] = [
317318 { t: "New repository", h: "/new", c: "Create" },
318319 { t: "Migrate a repository", h: "/migrate", c: "Create" },
319320 { t: "New organization", h: "/organizations/new", c: "Create" },
321 { t: "Notes", h: "/notes", c: "Go to" },
320322 { t: "Talk to the forge", h: "/talk", c: "Go to" },
321323 { t: "Search", h: "/search", c: "Go to" },
322324 { t: "Account settings", h: "/settings", c: "Settings" },
src/web/pages/notes.ts added +132
@@ -0,0 +1,132 @@
1 /**
2 * /notes (#8696): what a person wrote, newest first, each with its live state
3 * in one line. One box: typing and Enter keeps the note (`keepThought`, the
4 * same door the composer and the MCP use); the planner turns it into issues
5 * on its own (`crew/triage.ts`) and the line under it follows them from
6 * planned to shipped (`core/note_line.ts`). Server-rendered; the script only
7 * makes Enter send, Shift+Enter a new line. Everything shown is the signed-in
8 * person's own.
9 */
10
11 import { overruleNote } from "../../actions/note_overrule.ts";
12 import { keepThought, THOUGHT_MAX } from "../../actions/thought.ts";
13 import { noteLine, type NotePhase } from "../../core/note_line.ts";
14 import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts";
15 import { publicForgeRepo } from "../../core/forge_repo.ts";
16 import { thoughtStream } from "../../core/thought_stream.ts";
17 import type { Ctx } from "../../http/context.ts";
18 import { forgeChangelog } from "../../release/changelog.ts";
19 import { signInFirst } from "../chrome.ts";
20 import { readForm } from "../form.ts";
21 import { html } from "../html.ts";
22 import { flashTo, page } from "../layout.ts";
23 import { badge, btn, empty, listCard, row, time, type Tone } from "../ui.ts";
24 import { cut } from "../../actions/ask_state_words.ts";
25
26 const HERE = "/notes";
27 const SHOWN = 100;
28
29 const TONE: Record<NotePhase, Tone> = {
30 waiting: "default",
31 declined: "default",
32 duplicate: "default",
33 snoozed: "default",
34 planned: "default",
35 building: "attn",
36 review: "attn",
37 shipped: "success",
38 superseded: "default",
39 };
40
41 /** Static code, never built from user text. */
42 export const notesJs = `
43 const form = document.querySelector("form.note-box");
44 if (form) {
45 const box = form.querySelector("textarea");
46 box.addEventListener("keydown", (e) => {
47 if (e.key === "Enter" && !e.shiftKey && !e.isComposing) {
48 e.preventDefault();
49 if (box.value.trim() !== "") form.requestSubmit();
50 }
51 });
52 }
53 `;
54
55 const css = /* css */ `
56 .note-box { display: grid; gap: 6px; margin-bottom: 1rem; }
57 .note-box textarea { width: 100%; min-height: 3.5rem; font-size: 16px; box-sizing: border-box; resize: vertical; }
58 .note-line a { margin-right: 6px; }
59 `;
60
61 // GET /notes
62 export async function notesView(ctx: Ctx): Promise<Response> {
63 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
64 const user = ctx.auth.user;
65 if (user === undefined) return signInFirst(ctx);
66 const notes = timePhase(ctx.request, "notes", () => thoughtStream(ctx.app.db, user, { limit: SHOWN }));
67 // The release that carried a pull is known only for the forge's own repository.
68 const own = publicForgeRepo(ctx.app);
69 const shipped = notes.some((n) => n.state === "shipped");
70 const entries = own !== undefined && shipped ? ((await forgeChangelog(ctx.app))?.entries ?? []) : [];
71 const ownSlug = own === undefined ? "" : `${own.ownerName}/${own.name}`;
72 const versionOf = (repo: string, pull: number) =>
73 repo === ownSlug ? entries.find((e) => e.pulls.some((p) => p.number === pull))?.version : undefined;
74 const rows = notes.map((n) => {
75 const line = noteLine(n, versionOf);
76 return row({
77 title: cut(n.text, 200),
78 titleAttr: n.text,
79 meta: html`<span class="note-line">${time(n.receivedAt)} · ${line.text}${line.links.length === 0 ? null : " · "}${line.links.map(
80 (l) => html`<a href="${l.href}">${l.label}</a>`,
81 )}</span>`,
82 trail: badge(line.phase === "review" ? "In review" : line.phase[0]!.toUpperCase() + line.phase.slice(1), { tone: TONE[line.phase] }),
83 ...(line.overrule === null
84 ? {}
85 : {
86 after: html`<form method="post" action="${HERE}/${n.id}/overrule">
87 <input type="hidden" name="do" value="${line.overrule.do}" />
88 ${btn(line.overrule.label, { title: "Overrule the planner" })}
89 </form>`,
90 }),
91 });
92 });
93 const body = html`<div class="page-head"><h1>Notes</h1></div>
94 <form class="note-box" method="post" action="${HERE}">
95 <textarea class="input" name="text" rows="2" maxlength="${THOUGHT_MAX}" required enterkeyhint="send" autofocus placeholder="Write an idea. Enter saves it." aria-label="A note"></textarea>
96 <div class="bar"><span class="muted small">Enter saves · Shift+Enter for a new line</span><span class="spacer"></span>${btn("Save", { variant: "primary" })}</div>
97 </form>
98 ${rows.length === 0
99 ? empty({ icon: "comment", title: "No notes yet", note: "Write an idea above. The crew plans it, builds it, and this page says where it is." })
100 : listCard(rows)}`;
101 return timePhase(ctx.request, "render", () =>
102 page({ ctx, title: "Notes", body, styleCss: css, scriptJs: notesJs, description: "Write an idea; watch it become shipped work." }),
103 );
104 }
105
106 // POST /notes
107 export async function notesSave(ctx: Ctx): Promise<Response> {
108 const user = ctx.auth.user;
109 if (user === undefined) return signInFirst(ctx);
110 const form = await readForm(ctx, "rw");
111 if (form instanceof Response) return form;
112 const text = form.get("text");
113 if (typeof text !== "string" || text.trim() === "")
114 return flashTo(ctx, HERE, { tone: "danger", message: "Write something first." });
115 const kept = keepThought(ctx.app, user, text, "composer");
116 if (!kept.ok)
117 return flashTo(ctx, HERE, { tone: "danger", message: `A note is at most ${THOUGHT_MAX} characters.` });
118 return flashTo(ctx, HERE, { tone: "success", message: "Saved. The planner will pick it up." });
119 }
120
121 const OVERRULED = { accepted: "Built after all.", declined: "Declined." } as const;
122
123 // POST /notes/:id/overrule
124 export async function notesOverrule(ctx: Ctx): Promise<Response> {
125 const user = ctx.auth.user;
126 if (user === undefined) return signInFirst(ctx);
127 const form = await readForm(ctx, "rw");
128 if (form instanceof Response) return form;
129 const done = overruleNote(ctx.app, user, ctx.params["id"] ?? "", form.get("do"));
130 if (!done.ok) return flashTo(ctx, HERE, { tone: "danger", message: "That note cannot be changed now." });
131 return flashTo(ctx, HERE, { tone: "success", message: OVERRULED[done.value] });
132 }
src/web/studio/living.ts +1
@@ -112,6 +112,7 @@ export function tellBox(ctx: Ctx, user: User): Html {
112112 <option value="${FORGE_TARGET}">the forge</option>
113113 </select>`}
114114 ${btn("Tell", { variant: "primary" })}
115 ${btn("Notes", { href: "/notes", icon: "comment", title: "Your notes and where they stand" })}
115116 ${btn("Talk", { href: "/talk", icon: "comment", title: "Talk to the forge" })}
116117 </form>`;
117118 }
test/forge-looks.test.ts +1
@@ -20,6 +20,7 @@ quietLogs();
2020
2121 /** Top-level routes that are not pages, or are not worth a picture, and why. */
2222 const NOT_A_PAGE: Record<string, string> = {
23 "/asked": "no picture: the cap of 24 looks went to /notes, the page that now leads with a person's thoughts; /asked is covered by test/asked.test.ts",
2324 "/llms.txt": "plain markdown for machines, not a page to picture",
2425 "/robots.txt": "plain text for crawlers",
2526 "/openapi.json": "a redirect to the contract document",
test/notes.test.ts added +265
@@ -0,0 +1,265 @@
1 /**
2 * Notes (#8696): /notes lists what a person wrote, newest first, saves on
3 * POST, and shows each note's live state in one line; the planner splits a
4 * dump into several issues and attaches an idea to an issue it touches.
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 { noteLine } from "../src/core/note_line.ts";
15 import { getIssue } from "../src/core/issues.ts";
16 import { getRepoById, type Repo } from "../src/core/repos.ts";
17 import { createSession } from "../src/core/sessions.ts";
18 import { thoughtStream, type PlacedLink, type StreamThought } from "../src/core/thought_stream.ts";
19 import { createUser, type User } from "../src/core/users.ts";
20 import { placeThought, splitIdeas, attachTarget } from "../src/crew/triage.ts";
21 import { serve, type Servers } from "../src/http/server.ts";
22 import { quietLogs } from "./helpers/globals.ts";
23
24 quietLogs();
25
26 const link = (o: Partial<PlacedLink> = {}): PlacedLink => ({
27 action: "opened", repo: "alice/forge", number: 5, readable: true, title: "t", issueState: "open", pulls: [], running: false, ...o,
28 });
29 const note = (placed: PlacedLink[], o: Partial<StreamThought> = {}): StreamThought => ({
30 id: "n", text: "x", source: "composer", receivedAt: "2026-01-01T00:00:00Z", kind: "work", reply: null, asked: false,
31 placedAt: "2026-01-01T00:01:00Z", placed, state: "placed", flagged: false, overruled: null, supersededBy: null, repeats: null, supersedes: [], ...o,
32 });
33
34 describe("note line", () => {
35 test("one line per state, with links", () => {
36 expect(noteLine(note([], { placedAt: null })).text).toBe("Waiting for the planner");
37 expect(noteLine(note([link()])).text).toBe("Planned");
38 expect(noteLine(note([link({ running: true })])).text).toBe("Building");
39 const review = noteLine(note([link({ pulls: [{ number: 9, state: "open" }] })]));
40 expect(review.text).toBe("In review");
41 expect(review.links.map((l) => l.href)).toEqual(["/alice/forge/issues/5", "/alice/forge/pulls/9"]);
42 const merged = [link({ pulls: [{ number: 9, state: "merged" }] })];
43 expect(noteLine(note(merged, { state: "shipped" }), (r, p) => (r === "alice/forge" && p === 9 ? "0.7.9" : undefined)).text).toBe("Shipped in version 0.7.9");
44 expect(noteLine(note(merged, { state: "shipped" })).text).toBe("Shipped");
45 });
46 test("a split dump counts its issues; a vision is kept", () => {
47 const l = noteLine(note([link(), link({ number: 6, pulls: [{ number: 9, state: "merged" }] }), link({ number: 7, running: true })]));
48 expect(l.text).toBe("3 issues: 1 shipped, 1 building, 1 planned");
49 expect(l.phase).toBe("planned");
50 expect(noteLine(note([], { reply: "Kept as a note: what should change?" })).phase).toBe("declined");
51 });
52 });
53
54 describe("verdicts", () => {
55 test("decline, duplicate and snooze say which and why, each with one tap to overrule", () => {
56 const declined = noteLine(note([], { reply: "Kept as a note: what should change?" }));
57 expect(declined.text).toBe("Declined: Kept as a note: what should change?");
58 expect(declined.overrule?.do).toBe("build");
59 const dup = noteLine(note([link({ action: "linked", running: true })], { reply: "You asked this on 2026-01-01; #5 is open.", repeats: { id: "e", text: "x", receivedAt: "" } }));
60 expect(dup.phase).toBe("duplicate");
61 expect(dup.text).toContain("You asked this on 2026-01-01");
62 expect(dup.overrule?.do).toBe("build");
63 const snoozed = noteLine(note([link(), link({ action: "deferred" })], { reply: "Snoozed: you said it can wait." }));
64 expect(snoozed.phase).toBe("snoozed");
65 expect(snoozed.overrule?.do).toBe("build");
66 expect(noteLine(note([link()])).overrule?.do).toBe("decline");
67 expect(noteLine(note([link({ running: true })])).overrule).toBeNull();
68 expect(noteLine(note([link()], { overruled: "declined" })).text).toBe("Declined by you");
69 expect(noteLine(note([link(), link({ action: "deferred" })], { overruled: "accepted" })).phase).toBe("planned");
70 });
71 });
72
73 describe("splitIdeas / attachTarget", () => {
74 test("bullets and paragraphs split; one idea stays whole", () => {
75 expect(splitIdeas("things:\n- make the search faster\n- add dark mode toggle\n in settings\n- show build time")).toEqual([
76 "make the search faster", "add dark mode toggle\nin settings", "show build time",
77 ]);
78 expect(splitIdeas("make the search faster please\n\nadd a dark mode toggle")).toHaveLength(2);
79 expect(splitIdeas("just one idea about the search box")).toEqual(["just one idea about the search box"]);
80 });
81 test("attaches only on a close match that is not a reversal", () => {
82 const open = [{ number: 4, title: "make the search box visible on tablets" }];
83 expect(attachTarget("also make search box visible on tablets please", open)?.number).toBe(4);
84 expect(attachTarget("make the search box hidden on tablets", open)).toBeUndefined();
85 expect(attachTarget("paint the footer green", open)).toBeUndefined();
86 });
87 });
88
89 describe("notes", () => {
90 let dir: string;
91 let app: App;
92 let servers: Servers;
93 let alice: User;
94 let bob: User;
95 let repo: Repo;
96 const fresh = () => getRepoById(app.db, repo.id)!;
97 const cookie = (u: User) => `forge_session=${createSession(app.db, u.id).token}`;
98
99 beforeAll(async () => {
100 dir = await mkdtemp(join(tmpdir(), "forge-notes-"));
101 app = createApp({ dataDir: dir, port: 0 });
102 const mk = async (username: string, first = false) => {
103 const u = await createUser(app.db, { origin: "seed", trusted: true, username, email: `${username}@forge.local`, password: "a-good-passphrase", adminIfFirst: first });
104 if (!u.ok) throw new Error(String(u.error));
105 return u.value;
106 };
107 alice = await mk("alice", true);
108 bob = await mk("bob");
109 const r = await createRepo(app, alice, { name: "forge", visibility: "private" });
110 if (!r.ok) throw new Error(String(r.error));
111 repo = r.value;
112 await updateRepoSettings(app, alice, repo, { autonomy: "autonomous", autonomyDirection: "Steer by thought.", autonomyCeilingUsd: 10, autonomySlots: 1, autonomyBy: alice.id });
113 servers = await serve(app);
114 });
115 afterAll(async () => {
116 servers.stop();
117 await rm(dir, { recursive: true, force: true });
118 });
119
120 const post = (u: User | undefined, text: string, origin = servers.url) =>
121 fetch(`${servers.url}/notes`, {
122 method: "POST", redirect: "manual",
123 headers: { origin, ...(u ? { cookie: cookie(u) } : {}) },
124 body: new URLSearchParams({ text }),
125 });
126 const get = (u?: User) => fetch(`${servers.url}/notes`, { redirect: "manual", headers: u ? { cookie: cookie(u) } : {} });
127
128 test("signed out goes to sign in", async () => {
129 expect((await get()).status).toBe(303);
130 expect((await post(undefined, "hello there")).status).toBe(303);
131 });
132
133 test("saves a note, newest first, escaped, private to its author", async () => {
134 expect((await post(alice, "first idea about search")).status).toBe(303);
135 await Bun.sleep(5);
136 expect((await post(alice, "<b>second</b> idea about tablets")).status).toBe(303);
137 const html = await (await get(alice)).text();
138 expect(html).toContain("Waiting for the planner");
139 expect(html).toContain("&lt;b&gt;second&lt;/b&gt;");
140 expect(html).not.toContain("<b>second</b>");
141 expect(html.indexOf("second")).toBeLessThan(html.indexOf("first idea"));
142 expect(html).toContain('enterkeyhint="send"');
143 expect(await (await get(bob)).text()).not.toContain("first idea");
144 });
145
146 test("refuses empty, oversized and cross-origin writes", async () => {
147 const before = thoughtStream(app.db, alice).length;
148 expect((await post(alice, " ")).status).toBe(303);
149 expect((await post(alice, "x".repeat(20_001))).status).toBe(303);
150 expect((await post(alice, "sneaky", "https://evil.example")).status).toBe(403);
151 expect(thoughtStream(app.db, alice).length).toBe(before);
152 });
153
154 test("a dump becomes several issues linked back, and the page says so", async () => {
155 const kept = keepThought(app, alice, "- build a sparkline for spend per day\n- show the queue depth on the front page\n- export the audit log as csv", "composer");
156 if (!kept.ok) throw new Error(kept.error);
157 const p = await placeThought(app, fresh(), alice, { id: kept.value.id, text: "- build a sparkline for spend per day\n- show the queue depth on the front page\n- export the audit log as csv" });
158 expect(p.placed.filter((x) => x.action === "opened")).toHaveLength(3);
159 const issue = getIssue(app.db, repo.id, p.placed[0]!.number)!;
160 expect(issue.body).toContain(kept.value.id);
161 const html = await (await get(alice)).text();
162 expect(html).toContain("3 issues: 3 planned");
163 expect(html).toContain(`/alice/forge/issues/${p.placed[0]!.number}`);
164 });
165
166 test("an idea that touches an open issue attaches rather than duplicates", async () => {
167 const opened = openIssue(app, alice, fresh(), { title: "make the tablet toolbar sticky while scrolling", body: "" });
168 if (!opened.ok) throw new Error(String(opened.error));
169 const text = "please make the tablet toolbar sticky while scrolling";
170 const kept = keepThought(app, alice, text, "composer");
171 if (!kept.ok) throw new Error(kept.error);
172 const p = await placeThought(app, fresh(), alice, { id: kept.value.id, text });
173 expect(p.placed).toEqual([{ action: "amended", repo: "alice/forge", number: opened.value.number }]);
174 });
175
176 const overrule = (u: User, id: string, to: string) =>
177 fetch(`${servers.url}/notes/${id}/overrule`, { method: "POST", redirect: "manual", headers: { origin: servers.url, cookie: cookie(u) }, body: new URLSearchParams({ do: to }) });
178 const rowOf = (id: string) => thoughtStream(app.db, alice).find((n) => n.id === id)!;
179
180 test("a note that can wait is snoozed with its reason, and one tap lets it out", async () => {
181 const gate = openIssue(app, alice, fresh(), { title: "Later gate: wait here" });
182 if (!gate.ok) throw new Error(String(gate.error));
183 const text = "someday add a keyboard shortcut cheatsheet overlay";
184 const kept = keepThought(app, alice, text, "composer");
185 if (!kept.ok) throw new Error(kept.error);
186 const p = await placeThought(app, fresh(), alice, { id: kept.value.id, text });
187 expect(p.placed.map((x) => x.action)).toEqual(["opened", "deferred"]);
188 const html = await (await get(alice)).text();
189 expect(html).toContain("Snoozed: you said it can wait");
190 expect(html).toContain("Build it anyway");
191 expect((await overrule(bob, kept.value.id, "build")).status).toBe(303);
192 expect(rowOf(kept.value.id).overruled).toBeNull();
193 expect((await overrule(alice, kept.value.id, "build")).status).toBe(303);
194 expect(rowOf(kept.value.id).overruled).toBe("accepted");
195 expect(noteLine(rowOf(kept.value.id)).phase).toBe("planned");
196 });
197
198 test("a guest and a cross-origin post cannot overrule", async () => {
199 const kept = keepThought(app, alice, "alice/forge", "composer");
200 if (!kept.ok) throw new Error(kept.error);
201 await placeThought(app, fresh(), alice, { id: kept.value.id, text: "alice/forge" });
202 const url = `${servers.url}/notes/${kept.value.id}/overrule`;
203 const guest = await fetch(url, { method: "POST", redirect: "manual", headers: { origin: servers.url }, body: new URLSearchParams({ do: "build" }) });
204 expect(guest.status).not.toBe(200);
205 const cross = await fetch(url, { method: "POST", redirect: "manual", headers: { origin: "https://evil.example", cookie: cookie(alice) }, body: new URLSearchParams({ do: "build" }) });
206 expect(cross.status).toBe(403);
207 expect(rowOf(kept.value.id).overruled).toBeNull();
208 expect(rowOf(kept.value.id).placed).toHaveLength(0);
209 });
210
211 test("a second build tap, or a build on an accepted note, opens nothing more", async () => {
212 const kept = keepThought(app, alice, "alice/forge", "composer");
213 if (!kept.ok) throw new Error(kept.error);
214 await placeThought(app, fresh(), alice, { id: kept.value.id, text: "alice/forge" });
215 await overrule(alice, kept.value.id, "build");
216 await overrule(alice, kept.value.id, "build");
217 expect(rowOf(kept.value.id).placed).toHaveLength(1);
218 const text = "list the stale branches on the repository page";
219 const k2 = keepThought(app, alice, text, "composer");
220 if (!k2.ok) throw new Error(k2.error);
221 await placeThought(app, fresh(), alice, { id: k2.value.id, text });
222 const before = rowOf(k2.value.id).placed.length;
223 await overrule(alice, k2.value.id, "build");
224 expect(rowOf(k2.value.id).placed).toHaveLength(before);
225 expect(rowOf(k2.value.id).overruled).toBeNull();
226 });
227
228 test("one 'someday' bullet snoozes its own idea only, and 'backlog' is an ordinary word", async () => {
229 const gate = openIssue(app, alice, fresh(), { title: "Later gate: wait here" });
230 if (!gate.ok) throw new Error(String(gate.error));
231 const text = "- surface the backlog totals within the sidebar widget\n- someday add a printable receipt view\n- add an export button to the audit trail";
232 const kept = keepThought(app, alice, text, "composer");
233 if (!kept.ok) throw new Error(kept.error);
234 const p = await placeThought(app, fresh(), alice, { id: kept.value.id, text });
235 expect(p.placed.filter((x) => x.action === "opened")).toHaveLength(3);
236 const deferred = p.placed.filter((x) => x.action === "deferred");
237 expect(deferred).toHaveLength(1);
238 const wait = getIssue(app.db, repo.id, deferred[0]!.number)!;
239 expect(wait.body).toContain("printable receipt");
240 });
241
242 test("a declined note is built after all on one tap", async () => {
243 const kept = keepThought(app, alice, "alice/forge", "composer");
244 if (!kept.ok) throw new Error(kept.error);
245 await placeThought(app, fresh(), alice, { id: kept.value.id, text: "alice/forge" });
246 expect(noteLine(rowOf(kept.value.id)).phase).toBe("declined");
247 expect((await overrule(alice, kept.value.id, "build")).status).toBe(303);
248 const after = rowOf(kept.value.id);
249 expect(after.placed.map((x) => x.action)).toEqual(["opened"]);
250 expect(noteLine(after).phase).toBe("planned");
251 });
252
253 test("an accepted note is declined on one tap: its issue closes", async () => {
254 const text = "render the pull list as a compact table";
255 const kept = keepThought(app, alice, text, "composer");
256 if (!kept.ok) throw new Error(kept.error);
257 const p = await placeThought(app, fresh(), alice, { id: kept.value.id, text });
258 const number = p.placed[0]!.number;
259 expect(noteLine(rowOf(kept.value.id)).overrule?.do).toBe("decline");
260 expect((await overrule(alice, kept.value.id, "decline")).status).toBe(303);
261 expect(getIssue(app.db, repo.id, number)!.state).toBe("closed");
262 expect(noteLine(rowOf(kept.value.id)).text).toBe("Declined by you");
263 expect((await overrule(alice, kept.value.id, "nonsense")).status).toBe(303);
264 });
265 });
test/palette-roster.test.ts +3
@@ -62,6 +62,7 @@ test("a site-admin person is offered the whole roster", () => {
6262 ["migrate", "Migrate a repository", "bring one in from an origin or a URL, pick its branch, put a crew on it"],
6363 ["new-forge", "New forge", "spawn a client's forge and get its claim link"],
6464 ["asked", "Asked", "your thoughts and what became of them"],
65 ["thought-notes", "Notes", "write an idea and watch it become shipped work"],
6566 ["decider", "Decider", "what it closed, deferred and asked you about today"],
6667 ["notify", "Phone notifications", "where the crew reaches your phone; s sets, x turns off"],
6768 ["confirms", "Confirms", "what the forge's agent asked to do; y does it once"],
@@ -140,6 +141,7 @@ test("a site-admin agent is offered the roster without the person-only entries",
140141 ["migrate", "Migrate a repository", "bring one in from an origin or a URL, pick its branch, put a crew on it"],
141142 ["new-forge", "New forge", "spawn a client's forge and get its claim link"],
142143 ["asked", "Asked", "your thoughts and what became of them"],
144 ["thought-notes", "Notes", "write an idea and watch it become shipped work"],
143145 ["decider", "Decider", "what it closed, deferred and asked you about today"],
144146 ["ssh-keys", "SSH keys", "list, add and remove the keys that sign in as you"],
145147 ["account", "Account settings", undefined],
@@ -196,6 +198,7 @@ test("a member person is offered the signed-in roster without the admin entries"
196198 ["migrate", "Migrate a repository", "bring one in from an origin or a URL, pick its branch, put a crew on it"],
197199 ["new-forge", "New forge", "spawn a client's forge and get its claim link"],
198200 ["asked", "Asked", "your thoughts and what became of them"],
201 ["thought-notes", "Notes", "write an idea and watch it become shipped work"],
199202 ["decider", "Decider", "what it closed, deferred and asked you about today"],
200203 ["notify", "Phone notifications", "where the crew reaches your phone; s sets, x turns off"],
201204 ["confirms", "Confirms", "what the forge's agent asked to do; y does it once"],
test/tui-thought-notes.test.ts added +71
@@ -0,0 +1,71 @@
1 /**
2 * /notes in the terminal (#8696): a note written there is the same row the
3 * page lists, a guest has nothing, and the overrule is the line's own tap.
4 */
5 import { afterAll, beforeAll, expect, test } from "bun:test";
6 import { mkdtemp, rm } from "node:fs/promises";
7 import { tmpdir } from "node:os";
8 import { join } from "node:path";
9 import { createApp, type App } from "../src/app.ts";
10 import { thoughtStream } from "../src/core/thought_stream.ts";
11 import { createUser, type User } from "../src/core/users.ts";
12 import { createForgeApp } from "../src/tui/forge.ts";
13 import { notesScreen, noteRows } from "../src/tui/screens/thoughtnotes.ts";
14 import { headlessTerminal } from "../src/tui/term/terminal.ts";
15 import { quietLogs } from "./helpers/globals.ts";
16 import { settleApp } from "./helpers/tui.ts";
17
18 quietLogs();
19
20 let dir: string;
21 let app: App;
22 let alice: User;
23 let bob: User;
24
25 beforeAll(async () => {
26 dir = await mkdtemp(join(tmpdir(), "forge-tui-thought-notes-"));
27 app = createApp({ dataDir: dir, port: 0 });
28 const mk = async (username: string, first = false) => {
29 const r = await createUser(app.db, { origin: "seed", trusted: true, username, email: `${username}@forge.local`, password: "a-good-passphrase", ...(first ? { admin: true } : {}) });
30 if (!r.ok) throw new Error(String(r.error));
31 return r.value;
32 };
33 alice = await mk("alice", true);
34 bob = await mk("bob");
35 });
36 afterAll(async () => {
37 app.close();
38 await rm(dir, { recursive: true, force: true });
39 });
40
41 async function drive(user: User | undefined, keys: string): Promise<string> {
42 const terminal = headlessTerminal(120, 30);
43 const { app: tui, session } = createForgeApp(
44 app,
45 { sshUsername: user?.username ?? "git", remoteAddress: "127.0.0.1", fingerprint: "SHA256:test", user },
46 { terminal, exitHooks: false },
47 );
48 tui.start();
49 tui.ctx.push(notesScreen(session, tui.ctx));
50 await settleApp(tui);
51 terminal.feed(keys);
52 await settleApp(tui);
53 const text = tui.screen.toText();
54 tui.stop?.();
55 return text;
56 }
57
58 test("a note written in the terminal is the page's row, and only its author's", async () => {
59 const text = await drive(alice, "nbuild a sparkline for spend\r");
60 expect(text).toContain("build a sparkline for spend");
61 expect(text).toContain("Waiting for the planner");
62 expect(thoughtStream(app.db, alice).map((n) => n.text)).toEqual(["build a sparkline for spend"]);
63 expect(noteRows(app.db, bob)).toEqual([]);
64 expect(noteRows(app.db, undefined)).toEqual([]);
65 expect(await drive(bob, "")).toContain("No notes yet");
66 });
67
68 test("nothing typed saves nothing", async () => {
69 await drive(bob, "n\r");
70 expect(thoughtStream(app.db, bob)).toHaveLength(0);
71 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs