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

A wave proposal is one inbox card: Approve opens its issues, Redirect re-plans from the owner's words, Later puts it aside #2023

Merged opened this

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

Merged

scout/forge:scout/1622-a-wave-proposal-is-one-inbox-card-approv → main 5 commits fork 8 threads · 8 unresolved

  • Merged. scout/1622-a-wave-proposal-is-one-inbox-card-approv was merged into main as 4818d97.

Review

Sign in to review this change.
15 files changed +810 −12
README.md +2
@@ -304,6 +304,8 @@ and `forge sync` below is what acts on it.
304304
305305 **Muting a repository from the home**: each ask row on the home carries a "Mute owner/name" chip. A muted repository's asks leave the home, `/api/v1/asks`, the header count and the phone's pushes (the same per-user mute the On you card uses, no new table); the home says "N muted repositories still ask" with a "Listen again" button, and an app's own page still counts them.
306306
307 **A wave proposal is one card.** With "plans need a nod" on, a plan the planner writes is kept as a proposal and opens nothing. It is one row in the asks list (home, `/asks`, `/api/v1/asks`, MCP `asks`, the terminal's On you card) with its title, its why and its issue titles, all escaped, and it asks the app's owner only. Three taps, each one POST that leaves you on the page you were on: **Approve** opens the issues with their links, exactly as an unheld plan would; **Redirect** takes a text box and hands your words to the planner as a new run on the direction issue; **Later** hides it until you bring it back, from the "N waves put aside" line under the asks (each with a "Bring back" button). A proposal is decided once. The same four verbs (`approve`, `redirect`, `later`, `again`) are on every door, all calling `actions/proposals.ts`: the page (`/asks/proposal/<id>/<verb>`), the API (`POST /api/v1/asks/proposal/<id>/<verb>`, a `rw` token, `{"words": …}` for redirect), the MCP tool `asks_proposal` (`id`, `verb`, `words`), and in the terminal `y` approve, `R` redirect, `l` later and `B` bring every put-aside wave back, on a wave row of the On you card.
308
307309 **Chips on the feed**: kind chips switch a kind of post off: `reviews` (pulls), `decisions` (decisions and choose decks), `crew` (tells, replies, looks) and `releases`; a person chip switches one person or agent off. Saved per user, one filter (`chipHides`) behind the home feed, `/api/v1/feed`, the Asks list and the terminal's inbox. `/live/apps` (your apps) is not feed items, so chips do not apply there. `GET /api/v1/feed/chips` answers `{kinds, people}`; `POST /api/v1/feed/chips` takes `{"kind": "kind"|"person", "value": "reviews"|"<username>", "hidden": true|false}` (scope `rw`, same-origin). In the terminal dashboard `1`–`4` switch the kind chips `h` on an On you row switches its author off (not your own) and the author's rows leave the card, and `P` brings every person back.
308310
309311 **On you, in the terminal**, answers to the same mutes and one-move verbs as
scripts/gate.ts +1
@@ -228,6 +228,7 @@ export const GATE: readonly string[] = [
228228 "test/asks.test.ts",
229229 "test/review-wait.test.ts",
230230 "test/asks-bulk.test.ts",
231 "test/proposal-card.test.ts",
231232 "test/asks-waiting-for.test.ts",
232233 "test/asks-one-list.test.ts",
233234 "test/hand-decider.test.ts",
src/actions/proposals.ts added +164
@@ -0,0 +1,164 @@
1 /**
2 * The four things a person does with a wave proposal (`core/proposals.ts`),
3 * for every door (the web form, `POST /api/v1/asks/proposal/:id/<verb>`,
outdated

"for every door": only the web form calls these today. The JSON API, the MCP tools and the terminal need routes or keys that call them too.

4 * MCP's `asks_proposal` and the terminal): approve opens its issues exactly
5 * as an unheld plan would,
6 * redirect hands the owner's words to the planner as a new run, later puts
7 * it aside, and again brings it back. Each takes the actor and the id,
8 * checks the actor is who the card asks (the same predicate as the asks
9 * list) and hands back plain data.
10 */
11
12 import type { App } from "../app.ts";
13 import { listCrewRoles } from "../core/agents.ts";
14 import { getProposal, moveProposal, type Proposal, type ProposalState } from "../core/proposals.ts";
15 import { getRepoById, ownerFactRepoIds, canRead, type Repo } from "../core/repos.ts";
16 import type { User } from "../core/users.ts";
17 import { ensureDirectionIssue } from "../crew/autonomy.ts";
18 import { queueAgentRun } from "../crew/dispatch.ts";
19 import { err, ok, type Result } from "../kernel/result.ts";
20 import { MAX_BODY } from "../core/issues.ts";
21 import { assignIssue, addComment } from "./issues.ts";
22 import { openPlan, parsePlan, type Planned } from "./plan.ts";
23
24 export interface ProposalError {
25 readonly code: "unauthorized" | "not_found" | "forbidden" | "invalid" | "conflict";
26 readonly message: string;
27 }
28
29 const fail = (code: ProposalError["code"], message: string) =>
30 err<ProposalError>({ code, message });
31
32 /** An error from another action as a sentence, never "[object Object]". */
33 function errorWords(e: unknown): string {
34 if (typeof e === "string") return e;
35 const m = e !== null && typeof e === "object" ? (e as { message?: unknown }).message : undefined;
36 return typeof m === "string" ? m : "it was refused";
37 }
38
39 /** The proposal and its repository, when this person is who it asks and it is in one of `states`. */
40 function mine(
41 app: App,
42 actor: User | undefined,
43 id: unknown,
44 states: readonly ProposalState[],
45 ): Result<{ proposal: Proposal; repo: Repo; actor: User }, ProposalError> {
46 if (!actor) return fail("unauthorized", "sign in to act on your asks");
47 const proposal = typeof id === "string" ? getProposal(app.db, id) : undefined;
48 const repo = proposal ? getRepoById(app.db, proposal.repoId) : undefined;
49 if (!proposal || !repo || !canRead(app.db, repo, actor))
50 return fail("not_found", "no such proposal");
51 if (!ownerFactRepoIds(app.db, [repo], actor).has(repo.id))
52 return fail("forbidden", "a proposal is the owner's to decide");
53 if (!states.includes(proposal.state))
54 return fail("conflict", "it was already decided");
55 return ok({ proposal, repo, actor });
56 }
57
58 /** Open the proposal's issues with their links, as `openPlan` does for a plan nobody held. */
59 export function approveProposal(
60 app: App,
61 actor: User | undefined,
62 id: unknown,
63 ): Result<Planned, ProposalError> {
64 const found = mine(app, actor, id, ["pending", "later"]);
65 if (!found.ok) return found;
66 const { proposal, repo } = found.value;
67 const items = parsePlan(proposal.issues);
68 if (!items.ok) return fail("invalid", items.error.message);
69 // Taken first, so two taps open the issues once.
70 if (!moveProposal(app.db, proposal.id, ["pending", "later"], "approved"))
71 return fail("conflict", "it was already decided");
72 const opened = openPlan(app, found.value.actor, repo, items.value);
73 if (!opened.ok) {
74 moveProposal(app.db, proposal.id, ["approved"], proposal.state);
75 return fail("invalid", opened.error.message);
76 }
77 return ok(opened.value);
78 }
79
80 /** Hand the owner's words to the planner as a new run on the direction issue. */
81 export function redirectProposal(
82 app: App,
83 actor: User | undefined,
84 id: unknown,
85 words: unknown,
86 ): Result<{ issue: number }, ProposalError> {
87 const said = typeof words === "string" ? words.trim() : "";
88 if (said === "") return fail("invalid", "say what the planner should do instead");
89 if (said.length > MAX_BODY) return fail("invalid", `the words are over ${MAX_BODY} characters`);
90 const found = mine(app, actor, id, ["pending", "later"]);
91 if (!found.ok) return found;
92 const { proposal, repo } = found.value;
93 const planner = listCrewRoles(app.db, repo.id).find((c) => c.role === "planner")?.agent;
94 if (planner === undefined) return fail("invalid", "no planner on this crew to hand it to");
95 const direction = ensureDirectionIssue(app, found.value.actor, repo);

Minor: this can create a direction issue before moveProposal below refuses a proposal that was already decided. Taking the state first (and putting it back if this fails, as Approve does) would make a lost race write nothing.

96 if (!direction) return fail("invalid", "this app has no direction issue to plan from");
97 if (!moveProposal(app.db, proposal.id, ["pending", "later"], "redirected"))
98 return fail("conflict", "it was already decided");
99 const said_ = addComment(
100 app,
101 found.value.actor,
102 repo,
103 direction,
104 `Redirect: not the wave "${proposal.title}" as proposed.\n\n${said}`,
105 );
106 if (!said_.ok) {
107 moveProposal(app.db, proposal.id, ["redirected"], proposal.state);
108 return fail("invalid", `could not write to the direction issue: ${errorWords(said_.error)}`);
109 }
110 if (direction.assigneeId === planner.user.id) {
111 queueAgentRun(app, repo, direction, planner, "feedback", found.value.actor.id);
112 } else {
113 if (direction.assigneeId !== null) assignIssue(app, found.value.actor, repo, direction, null);
114 assignIssue(app, found.value.actor, repo, direction, planner.user);

Minor: the result of assignIssue is dropped. If it is refused, the card is gone as redirected, the owner's words sit on the direction issue, and no planner run starts. Consider checking it and rolling the state back, as the addComment branch above does.

115 }
116 return ok({ issue: direction.number });
117 }
118
119 /** Hide the proposal until the owner brings it back. */
120 export function laterProposal(
121 app: App,
122 actor: User | undefined,
123 id: unknown,
124 ): Result<null, ProposalError> {
125 const found = mine(app, actor, id, ["pending"]);
126 if (!found.ok) return found;
127 return moveProposal(app.db, found.value.proposal.id, ["pending"], "later")
128 ? ok(null)
129 : fail("conflict", "it was already decided");
130 }
131
132 /** Bring a proposal put aside back among the asks. */
133 export function againProposal(
134 app: App,
135 actor: User | undefined,
136 id: unknown,
137 ): Result<null, ProposalError> {
138 const found = mine(app, actor, id, ["later"]);
139 if (!found.ok) return found;
140 return moveProposal(app.db, found.value.proposal.id, ["later"], "pending")
141 ? ok(null)
142 : fail("conflict", "it was already decided");
143 }
144
145 export const PROPOSAL_VERBS = ["approve", "redirect", "later", "again"] as const;
146 export type ProposalVerb = (typeof PROPOSAL_VERBS)[number];
147
148 /** Whether a door's word names one of the four verbs. */
149 export const isProposalVerb = (v: unknown): v is ProposalVerb =>
150 typeof v === "string" && (PROPOSAL_VERBS as readonly string[]).includes(v);
151
152 /** One verb on one proposal: what every door calls, so each does the same thing. */
153 export function actOnProposal(
154 app: App,
155 actor: User | undefined,
156 verb: ProposalVerb,
157 id: unknown,
158 words?: unknown,
159 ): Result<unknown, ProposalError> {
160 if (verb === "approve") return approveProposal(app, actor, id);
161 if (verb === "redirect") return redirectProposal(app, actor, id, words);
162 if (verb === "later") return laterProposal(app, actor, id);
163 return againProposal(app, actor, id);
164 }
src/core/migrations/20261016120000-proposal-state.ts added +6
@@ -0,0 +1,6 @@
1 // a wave proposal is one inbox card (#1622): pending asks the owner, later
2 // hides it until they open it again, approved and redirected are decided.
3 export default `
4 ALTER TABLE plan_proposal ADD COLUMN state TEXT NOT NULL DEFAULT 'pending';
5 ALTER TABLE plan_proposal ADD COLUMN decided_at TEXT;
6 `;
src/core/proposals.ts added +99
@@ -0,0 +1,99 @@
1 /**
2 * Wave proposals: a plan kept for a person's nod (`actions/plan.ts`), read
3 * and moved between its states. Plain rows in, plain data out; the
4 * actions in `actions/proposals.ts` own who may move one.
5 */
6
7 import type { Database } from "bun:sqlite";
8
9 export type ProposalState = "pending" | "later" | "approved" | "redirected";
10
11 export interface Proposal {
12 readonly id: string;
13 readonly repoId: string;
14 readonly authorId: string;
15 readonly title: string;
16 readonly why: string;
17 /** The plan's items as stored JSON; `parsePlan` checks them again before use. */
18 readonly issues: unknown;
19 readonly state: ProposalState;
20 readonly createdAt: string;
21 }
22
23 interface Row {
24 id: string;
25 repo_id: string;
26 author_id: string;
27 title: string;
28 why: string;
29 issues: string;
30 state: string;
31 created_at: string;
32 }
33
34 const toProposal = (r: Row): Proposal => {
35 let issues: unknown = [];
36 try {
37 issues = JSON.parse(r.issues);
38 } catch {
39 // A row we cannot read shows no issues and cannot be approved.
40 }
41 return {
42 id: r.id,
43 repoId: r.repo_id,
44 authorId: r.author_id,
45 title: r.title,
46 why: r.why,
47 issues,
48 state: r.state as ProposalState,
49 createdAt: r.created_at,
50 };
51 };
52
53 export function getProposal(db: Database, id: string): Proposal | undefined {
54 const row = db.query<Row, [string]>("SELECT * FROM plan_proposal WHERE id = ?1").get(id);
55 return row ? toProposal(row) : undefined;
56 }
57
58 /** The proposals in `state` for these repositories, oldest first. */
59 export function proposalsFor(
60 db: Database,
61 repoIds: readonly string[],
62 state: ProposalState,
63 ): Proposal[] {
64 if (repoIds.length === 0) return [];
65 const marks = repoIds.map((_, i) => `?${i + 2}`).join(",");
66 return db
67 .query<Row, string[]>(
68 `SELECT * FROM plan_proposal WHERE state = ?1 AND repo_id IN (${marks})
69 ORDER BY created_at, id`,
70 )
71 .all(state, ...repoIds)
72 .map(toProposal);
73 }
74
75 /** The titles of a proposal's issues, for a card and a row. */
76 export function issueTitles(p: Proposal): string[] {
77 return Array.isArray(p.issues)
78 ? p.issues
79 .map((i) => (i !== null && typeof i === "object" ? (i as { title?: unknown }).title : undefined))
80 .filter((t): t is string => typeof t === "string")
81 : [];
82 }
83
84 /** Move a proposal from one of `from` to `to`; false when it was not in one (someone moved it first). */
85 export function moveProposal(
86 db: Database,
87 id: string,
88 from: readonly ProposalState[],
89 to: ProposalState,
90 ): boolean {
91 const marks = from.map((_, i) => `?${i + 4}`).join(",");
92 const done = db
93 .query(
94 `UPDATE plan_proposal SET state = ?1, decided_at = ?2
95 WHERE id = ?3 AND state IN (${marks})`,
96 )
97 .run(to, new Date().toISOString(), id, ...from);
98 return done.changes === 1;
99 }
src/core/waiting_for.ts +1 −1
@@ -18,7 +18,7 @@ export interface WaitingFor {
1818
1919 export interface WaitFacts {
2020 /** The ask's kind (`DecisionKind`). */
21 readonly kind: "stopped" | "look" | "review" | "paused" | "parked" | "declined" | "handover" | "assigned" | "setting";
21 readonly kind: "stopped" | "look" | "review" | "paused" | "parked" | "declined" | "handover" | "assigned" | "proposal" | "setting";
2222 /** Files stopping the pull from merging, as the last sweep found it. */
2323 readonly conflicts: number;
2424 /** Whether the pull or issue the ask is about is assigned to the viewer. */
src/http/feed.ts +16
@@ -29,6 +29,7 @@ import {
2929 readFeed,
3030 } from "../feed/feed.ts";
3131 import { decideAsk, undoDecideAsk } from "../actions/asks_decide.ts";
32 import { actOnProposal, isProposalVerb } from "../actions/proposals.ts";
3233 import { asksAnswer } from "../web/studio/asks.ts";
3334 import {
3435 applyAsksBulk,
@@ -163,6 +164,21 @@ export function feedRoutes(router: Router<Handler>): void {
163164 return json(undone.value, 201);
164165 });
165166
167 // A wave proposal's four verbs (`actions/proposals.ts`): approve, redirect (with `words`), later, again.
168 router.add("POST", "/api/v1/asks/proposal/:id/:verb", async (ctx) => {
169 const denied = requireScope(ctx, "rw");
170 if (denied) return denied;
171 const verb = ctx.params.verb;
172 if (!isProposalVerb(verb)) return notFound();
173 const input = await body(ctx.request);
174 const done = actOnProposal(ctx.app, ctx.auth.user, verb, ctx.params.id, input.words);
175 if (done.ok) return json(done.value ?? { ok: true }, 201);
176 const { code, message } = done.error;
177 if (code === "not_found") return notFound();
178 if (code === "unauthorized") return unauthorized();
179 return apiError(code, code === "forbidden" ? 403 : code === "conflict" ? 409 : 422, message);
180 });
181
166182 router.add("POST", "/api/v1/feed/interactions", async (ctx) => {
167183 const denied = requireScope(ctx, "rw");
168184 if (denied) return denied;
src/http/mcp.ts +22
@@ -121,6 +121,7 @@ import {
121121 } from "../web/pages/milestones.ts";
122122 import { appsOf, asksAnswer } from "../web/studio/asks.ts";
123123 import { decideAsk, undoDecideAsk } from "../actions/asks_decide.ts";
124 import { actOnProposal, isProposalVerb, PROPOSAL_VERBS } from "../actions/proposals.ts";
124125 import { applyAsksBulk, previewAsksBulk } from "../actions/asks_bulk.ts";
125126 import type { Ctx, Handler } from "./context.ts";
126127 import {
@@ -633,6 +634,27 @@ const TOOLS: readonly Tool[] = [
633634 return done.ok ? good(done.value) : bad(done.error.code, done.error.message);
634635 },
635636 },
637 {
638 name: "asks_proposal",
639 description:
640 "Act on a wave proposal (an `asks` row with a `proposal`; `id` is its `proposal.id`). `approve` opens its issues with their links, exactly as an unheld plan would; `redirect` hands your `words` to the planner as a new run; `later` hides it until `again` brings it back. Only the app's owner may; a proposal is decided once.",
641 inputSchema: {
642 type: "object",
643 properties: {
644 id: { type: "string", maxLength: 80 },
645 verb: { type: "string", enum: [...PROPOSAL_VERBS] },
646 words: { type: "string", maxLength: 65536 },
647 },
648 required: ["id", "verb"],
649 },
650 writes: true,
651 run(ctx, user, args) {
652 const verb = args["verb"];
653 if (!isProposalVerb(verb)) return bad("invalid", `verb is ${PROPOSAL_VERBS.join(", ")}`);
654 const done = actOnProposal(ctx.app, user, verb, args["id"], args["words"]);
655 return done.ok ? good(done.value ?? { ok: true }) : bad(done.error.code, done.error.message);
656 },
657 },
636658 {
637659 name: "asks_undecide",
638660 description:
src/http/server.ts +5
@@ -220,6 +220,7 @@ import {
220220 asksBulkSubmit,
221221 asksBulkUndo,
222222 asksDecide,
223 asksProposal,
223224 asksSetting,
224225 asksDecideUndo,
225226 asksListen,
@@ -620,6 +621,10 @@ export function buildRouter(): Router<Handler> {
620621 router.add("POST", "/asks/bulk/:id/undo", asksBulkUndo);
621622 router.add("POST", "/asks/listen", asksListen);
622623 router.add("POST", "/asks/mute", asksMute);
624 router.add("POST", "/asks/proposal/:id/approve", asksProposal("approve"));
625 router.add("POST", "/asks/proposal/:id/redirect", asksProposal("redirect"));
626 router.add("POST", "/asks/proposal/:id/later", asksProposal("later"));
627 router.add("POST", "/asks/proposal/:id/again", asksProposal("again"));
623628 router.add("POST", "/asks/decide", asksDecide);
624629 router.add("POST", "/asks/setting", asksSetting);
625630 router.add("POST", "/asks/decide/:id/undo", asksDecideUndo);
src/tui/screens/dashboard.ts +88 −5
@@ -22,10 +22,14 @@ import { nextStepsFor } from "../../core/next-steps.ts";
2222 import { agentsLine } from "../../web/studio/client_home.ts";
2323 import { mayView } from "../../core/sealed.ts";
2424 import { pinRepo, unpinRepo } from "../../actions/origins.ts";
25 import { getProposal } from "../../core/proposals.ts";
2526 import { getIssueById, listAssignedTo, type Issue } from "../../core/issues.ts";
2627 import { askLinesFor } from "../../web/studio/asks.ts";
2728 import { waitingFor, type WaitingFor } from "../../core/waiting_for.ts";
2829 import { decideAsk, undoDecideAsk } from "../../actions/asks_decide.ts";
30 import { actOnProposal, type ProposalVerb } from "../../actions/proposals.ts";
31 import { putAsideWaves } from "../../web/studio/asks.ts";
32 import type { Proposal } from "../../core/proposals.ts";
2933 import { answerSetting, openFindings } from "../../actions/setting_ask.ts";
3034 import {
3135 applyAsksBulk,
@@ -118,6 +122,10 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
118122 let pins: Pin[] = [];
119123 let pinned = new Set<string>();
120124 let onYou: OnYouItem[] = [];
125 /** Wave proposals that wait for this person's nod, listed after the rows above. */
126 let waves: { proposal: Proposal; repo: Repo }[] = [];
127 /** Waves put aside with Later, which `B` brings back. */
128 let asideWaves: Proposal[] = [];
121129 /** Repositories the viewer muted that hold work assigned to them: said, never silent. */
122130 let silenced = new Map<string, Repo>();
123131 /** The rows marked for one verb, by issue id. */
@@ -161,6 +169,8 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
161169 pinned = f.user ? pinnedRepoIds(f.db, f.user.id) : new Set<string>();
162170 all = listReposVisibleTo(f.db, f.user, { limit: 200 });
163171 onYou = assignedToMe();
172 waves = f.user === undefined ? [] : wavesFor();
173 asideWaves = f.user === undefined ? [] : putAsideWaves(f.db, f.user);
164174 for (const id of [...marked])
165175 if (!onYou.some((item) => item.issue.id === id)) marked.delete(id);
166176 lastSet = f.user ? latestUndoable(f.app, f.user) : undefined;
@@ -274,6 +284,60 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
274284 return out;
275285 };
276286
287 /** The wave proposals that ask this person, as the web's asks list has them. */
288 const wavesFor = (): { proposal: Proposal; repo: Repo }[] => {
289 if (f.user === undefined) return [];
290 const out: { proposal: Proposal; repo: Repo }[] = [];
291 for (const row of askLinesFor(f.db, f.user)) {
292 if (row.proposal === undefined) continue;
293 const proposal = getProposal(f.db, row.proposal.id);
294 const repo = proposal === undefined ? undefined : getRepoById(f.db, proposal.repoId);
295 if (proposal !== undefined && repo !== undefined) out.push({ proposal, repo });
296 }
297 return out;
298 };
299
300 /** The wave under the cursor, when the cursor is past the issues and pulls. */
301 const waveHere = (): { proposal: Proposal; repo: Repo } | undefined =>
302 waves[at("onyou").index - onYou.length];
303
304 /** One verb on the wave under the cursor, through the action every door uses. */
305 const actOnWave = (verb: ProposalVerb, words?: string): void => {
306 const here = waveHere();
307 if (here === undefined) {
308 ctx.toast("only a wave proposal takes this", "attn");
309 return;
310 }
311 const done = actOnProposal(f.app, f.user, verb, here.proposal.id, words);
312 reload();
313 ctx.toast(
314 done.ok ? (verb === "approve" ? "issues opened" : verb === "later" ? "put aside · B brings it back" : "sent to the planner") : done.error.message,
315 done.ok ? "success" : "danger",
316 );
317 };
318
319 const redirectWave = (): void => {
320 if (waveHere() === undefined) return actOnWave("redirect");
321 ctx.push(
322 promptScreen(ctx, screen, {
323 title: "Redirect the planner",
324 label: "Plan this instead",
325 hint: "Your words go to the planner as a new run.",
326 onSubmit: (value) => {
327 actOnWave("redirect", value);
328 return undefined;
329 },
330 }),
331 );
332 };
333
334 const bringBackWaves = (): void => {
335 let n = 0;
336 for (const w of asideWaves) if (actOnProposal(f.app, f.user, "again", w.id).ok) n++;
337 reload();
338 ctx.toast(`${n} ${n === 1 ? "wave" : "waves"} back`, "success");
339 };
340
277341 /** The marked rows, or the one under the cursor when none is marked. */
278342 const targets = (): OnYouItem[] => {
279343 const mine = onYou.filter((item) => item.hand !== true && item.setting === undefined);
@@ -474,7 +538,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
474538 pane === "repos"
475539 ? shown().length
476540 : pane === "onyou"
477 ? onYou.length
541 ? onYou.length + waves.length
478542 : pins.length;
479543
480544 const cycle = (step: number): void => {
@@ -557,6 +621,12 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
557621 ...(focus === "onyou" && onYou[at("onyou").index]?.hand === true
558622 ? [binding(["D"], "let the decider")]
559623 : []),
624 ...(focus === "onyou" && waveHere() !== undefined
625 ? [binding(["y"], "approve wave"), binding(["R"], "redirect"), binding(["l"], "later")]
626 : []),
627 ...(asideWaves.length > 0 && !(focus === "onyou" && onYou[at("onyou").index]?.setting !== undefined)
outdated

With a setting card under the cursor, this shows next to "B put back", but B only puts the setting back. Hide this binding when onYou[at("onyou").index]?.setting !== undefined.

628 ? [binding(["B"], "bring waves back")]
629 : []),
560630 ...(focus === "onyou" && onYou[at("onyou").index]?.setting !== undefined
561631 ? [binding(["K"], "keep setting"), binding(["B"], "put back")]
562632 : []),
@@ -707,9 +777,13 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
707777 if (e.key === "S") return shipSet(), true;
708778 if (e.key === "D" && item !== undefined) return handToDecider(), true;
709779 if (e.key === "K") return answerHere("keep"), true;
710 if (e.key === "B") return answerHere("put"), true;
780 if (e.key === "B" && item?.setting !== undefined) return answerHere("put"), true;
711781 if (e.key === "h") return hideAuthor(), true;
782 if (e.key === "y") return actOnWave("approve"), true;
783 if (e.key === "R") return redirectWave(), true;
784 if (e.key === "l") return actOnWave("later"), true;
outdated

This catches B in every pane, even with nothing put aside, and toasts "0 waves back". Gate it: if (e.key === "B" && f.user && asideWaves.length > 0).

712785 }
786 if (e.key === "B" && f.user && asideWaves.length > 0) return bringBackWaves(), true;
713787 if (e.key === "u" && f.user) return undoSet(), true;
714788 if (e.key === "U" && f.user) return undoHand(), true;
715789 if (e.key === "L" && f.user) return listenAgain(), true;
@@ -766,7 +840,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
766840 focused: on,
767841 ...(footer === undefined ? {} : { footer }),
768842 });
769 if (onYou.length === 0) {
843 if (onYou.length + waves.length === 0) {
770844 note(s, inner, c, [
771845 silenced.size > 0
772846 ? "Nothing is on you outside muted repositories."
@@ -779,7 +853,8 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
779853 inner,
780854 c,
781855 at("onyou"),
782 onYou.map((item): ListRow => {
856 [
857 ...onYou.map((item): ListRow => {
783858 const mark =
784859 set === "nerd"
785860 ? icon(item.issue.kind === "pull" ? "pull" : "issue", set)
@@ -791,9 +866,16 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
791866 meta: `${item.waiting.word} · on ${item.waiting.on}`,
792867 };
793868 }),
869 ...waves.map(
870 (w): ListRow => ({
871 title: `wave ${w.proposal.title} · ${w.repo.ownerName}/${w.repo.name}`,
872 meta: "waits for your nod · y approve · R redirect · l later",
873 }),
874 ),
875 ],
794876 on,
795877 );
796 hitList(ctx, inner, at("onyou"), onYou.length, {
878 hitList(ctx, inner, at("onyou"), onYou.length + waves.length, {
797879 select: () => {
798880 focus = "onyou";
799881 },
@@ -825,6 +907,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
825907 if (lastSet !== undefined) parts.push(`u undo ${lastSet.verb}`);
826908 if (lastHand !== undefined) parts.push("U undo decider");
827909 if (onYou[at("onyou").index]?.hand === true) parts.push("D let the decider");
910 if (asideWaves.length > 0) parts.push(`${asideWaves.length} ${asideWaves.length === 1 ? "wave" : "waves"} put aside · B bring back`);
828911 return parts.length > 0 ? parts.join(" · ") : undefined;
829912 }
830913
src/web/studio/asks.ts +78 −2
@@ -18,6 +18,7 @@ import { appsRuntime } from "../../apps/runtime.ts";
1818 import { appsForRepos, getAppByRepo, listApps } from "../../core/apps.ts";
1919 import { latestDecision, stopStands } from "../../core/decisions.ts";
2020 import { getHold, holdsFor } from "../../core/holds.ts";
21 import { issueTitles, proposalsFor, type Proposal } from "../../core/proposals.ts";
2122 import { chipsOf } from "../../core/feed_chips.ts";
2223 import { chipHides } from "../../feed/chips.ts";
2324 import { askDismissals, mutedRepos } from "../../core/interactions.ts";
@@ -106,7 +107,7 @@ export function writableAppsOf(ctx: Ctx, user: User | undefined) {
106107 );
107108 }
108109
109 export type DecisionKind = "stopped" | "look" | "review" | "paused" | "parked" | "declined" | "handover" | "assigned" | "setting";
110 export type DecisionKind = "stopped" | "look" | "review" | "paused" | "parked" | "declined" | "handover" | "assigned" | "proposal" | "setting";
110111
111112 /** Dollars as a person reads them: cents when small, whole when not. */
112113 const dollars = (n: number): string => (n < 10 ? n.toFixed(2) : n.toFixed(0));
@@ -121,6 +122,8 @@ export interface Decision {
121122 /** For "parked": the issue the breaker stopped; for "declined": the
122123 * direction issue the planner declined. */
123124 readonly issue?: Issue | undefined;
125 /** For "proposal": the wave waiting for a nod. */
126 readonly proposal?: Proposal | undefined;
124127 readonly who: string;
125128 /** Three sentences at most: what, why, what happens next. */
126129 readonly says: string;
@@ -190,6 +193,11 @@ export const ASK_WORDS: Readonly<Record<DecisionKind, AskWords>> = {
190193 options: ["Open it"],
191194 ifNothing: "it stays assigned to you",
192195 },
196 proposal: {
197 decision: "Open this wave of issues?",
198 options: ["Approve", "Redirect", "Later"],
199 ifNothing: "it stays proposed; no issue is opened",
200 },
193201 setting: {
194202 decision: "A setting is away from its default: keep it, or put it back?",
195203 options: ["Keep", "Put back"],
@@ -215,6 +223,7 @@ export interface Consequence {
215223
216224 /** The tier of a kind when nothing about the thing itself says more. */
217225 export const KIND_TIER: Readonly<Record<DecisionKind, Consequence>> = {
226 proposal: { score: 60, why: "a wave of work waits for your nod" },
218227 paused: { score: 80, why: "money: the crew is stopped at its ceiling" },
219228 declined: { score: 70, why: "the planner has stopped for this app" },
220229 stopped: { score: 50, why: "red, and the crew stopped" },
@@ -252,6 +261,8 @@ interface AskCandidate {
252261 readonly repo: Repo;
253262 readonly pull?: Pull;
254263 readonly issue?: Issue;
264 /** For "proposal": the wave waiting for a nod. */
265 readonly proposal?: Proposal;
255266 readonly who: string;
256267 /** Whether the author is a crew member — a "look" card words itself
257268 * differently for a person's own change than for the crew's (below).
@@ -459,6 +470,16 @@ function askCandidatesFor(
459470 who: "the planner",
460471 decline: { runId: d.runId, words: d.words },
461472 });
473 // A wave the planner proposed waits for the owner's nod: one card each.
474 for (const proposal of proposalsFor(db, repos.map((r) => r.id), "pending"))
475 out.push({
476 kind: "proposal",
477 id: `proposal:${proposal.id}`,
478 since: proposal.createdAt,
479 repo: byId.get(proposal.repoId)!,
480 proposal,
481 who: getUserById(db, proposal.authorId)?.username ?? "the planner",
482 });
462483 // A setting the sentinel found away from its default is the owner's to keep
463484 // or put back, never a crew run: one ask per key, from its newest open finding.
464485 if (user.isAdmin)
@@ -572,7 +593,7 @@ function consequencesOf(
572593 /** The row an ask collapses into: one kind in one repository. */
573594 const groupId = (a: { kind: DecisionKind; repo: Repo; id: string }): string =>
574595 // Work assigned to you is each its own row: they share no decision.
575 a.kind === "assigned" || a.kind === "setting" ? a.id : `${a.kind}:${a.repo.id}`;
596 a.kind === "assigned" || a.kind === "proposal" || a.kind === "setting" ? a.id : `${a.kind}:${a.repo.id}`;
576597
577598 /**
578599 * The asks the viewer has not set aside: a muted repository's asks are all
@@ -731,6 +752,13 @@ function scoredFor(
731752 }));
732753 }
733754
755 /** The waves the viewer put aside with Later, oldest first, for the line that brings them back. */
756 export function putAsideWaves(db: Database, user: User): Proposal[] {
757 const repos = listReposVisibleTo(db, user, { limit: 200 });
758 const mine = ownerFactRepoIds(db, repos, user);
759 return proposalsFor(db, repos.filter((r) => mine.has(r.id)).map((r) => r.id), "later");
760 }
761
734762 /**
735763 * The viewer's muted repositories that hold asks right now: said on the
736764 * home, never silent, so a mute is always one tap from being taken back.
@@ -873,6 +901,8 @@ function lineOf(c: AskCandidate): string {
873901 return `"${c.pull?.title ?? c.issue?.title ?? ""}" is assigned to you`;
874902 if (c.kind === "parked")
875903 return `the crew parked #${c.issue!.number} after three failed runs`;
904 if (c.kind === "proposal")
905 return `proposes "${c.proposal?.title ?? ""}" and waits for your nod`;
876906 if (c.kind === "declined")
877907 return "the planner declined the direction and waits for you";
878908 if (c.kind === "setting" && c.setting)
@@ -905,6 +935,7 @@ export const ROW_PLURAL: Readonly<Record<DecisionKind, (n: number) => string>> =
905935 parked: (n) => `the crew parked ${n} issues after three failed runs`,
906936 handover: (n) => `${n} changes had three reviews asking for changes and wait for you`,
907937 declined: (n) => `the planner declined ${n} times and waits for you`,
938 proposal: (n) => `${n} waves wait for your nod`,
908939 paused: (n) => `${n} pauses at the ceiling`,
909940 setting: (n) => `${n} settings are away from their defaults`,
910941 assigned: (n) => `${n} things assigned to you`,
@@ -937,6 +968,8 @@ export function askLinesFor(
937968 link: string;
938969 /** The pull's or issue's number, for a row of one that is about one. */
939970 number?: number;
971 /** For a wave proposal: what it says, as plain text (the reader escapes it). */
972 proposal?: { id: string; title: string; why: string; issues: string[] };
940973 items: string[];
941974 /** The decision, its options and what happens if nobody acts (`ASK_WORDS`). */
942975 words: AskWords;
@@ -968,6 +1001,18 @@ export function askLinesFor(
9681001 const line = `${row.repo.name}${only.kind === "paused" ? " " : ": "}${lineOf(only)}`;
9691002 if (only.kind === "paused")
9701003 return { ...about, line, link: `${base}/settings/autonomy` };
1004 if (only.proposal !== undefined)
1005 return {
1006 ...about,
1007 line,
1008 link: `/#${askAnchor(only.id)}`,
1009 proposal: {
1010 id: only.proposal.id,
1011 title: only.proposal.title,
1012 why: only.proposal.why,
1013 issues: issueTitles(only.proposal),
1014 },
1015 };
9711016 if (only.pull === undefined && only.issue !== undefined)
9721017 return {
9731018 ...about,
@@ -1032,6 +1077,16 @@ export function decisionsFor(
10321077 short: `assigned to you · updated ${thing.updatedAt.slice(0, 10)}`,
10331078 };
10341079 }
1080 if (c.kind === "proposal" && c.proposal)
1081 return {
1082 kind: "proposal",
1083 id: `proposal:${c.proposal.id}`,
1084 repo: c.repo,
1085 proposal: c.proposal,
1086 who: c.who,
1087 says: `${c.who} proposes "${c.proposal.title}" (${issueTitles(c.proposal).length} issues). Approve opens them, Redirect hands your words to the planner, Later puts it aside.`,
1088 short: `proposes "${c.proposal.title}"`,
1089 };
10351090 if (c.kind === "parked" && c.issue)
10361091 return {
10371092 kind: "parked",
@@ -1151,6 +1206,7 @@ export function decisionForm(d: Decision, back?: string): Html | null {
11511206 return html`<form ${own} action="/asks/decide">
11521207 <input type="hidden" name="ask" value="${d.id}" />
11531208 </form>`;
1209 if (d.kind === "proposal" && d.proposal) return proposalForms(d, back);
11541210 if (d.kind === "setting" && d.setting)
11551211 return html`<form ${own}-keep action="/asks/setting">
11561212 <input type="hidden" name="key" value="${d.setting.key}" />
@@ -1227,6 +1283,11 @@ export function decisionButtons(ctx: Ctx, d: Decision): { primary: Html; others:
12271283 }),
12281284 others: [],
12291285 };
1286 if (d.kind === "proposal" && d.proposal)
1287 return {
1288 primary: btn("Approve", { size: "sm", form: askFormId(d), variant: "primary" }),
1289 others: [btn("Later", { size: "sm", form: `${askFormId(d)}-later` })],
1290 };
12301291 if (d.kind === "setting" && d.setting)
12311292 return {
12321293 primary: btn("Keep", { size: "sm", form: `${askFormId(d)}-keep`, variant: "primary" }),
@@ -1393,9 +1454,24 @@ export function askTargetsFor(
13931454 else if (ask === undefined) refused.push({ item, reason: "it no longer asks you" });
13941455 else if (ask.kind === "paused")
13951456 refused.push({ item, reason: "a ceiling is raised in the app's settings" });
1457 else if (ask.kind === "proposal")
1458 refused.push({ item, reason: "a proposal is approved, redirected or put aside on its card" });
13961459 else if (ask.kind === "declined")
13971460 refused.push({ item, reason: "a planner's decline is answered on its issue" });
13981461 else issueIds.push((ask.pull ?? ask.issue)!.id);
13991462 }
14001463 return { issueIds: [...new Set(issueIds)], refused };
14011464 }
1465
1466 /**
1467 * The forms behind a proposal card: Approve, Later and Redirect, each one
1468 * POST to `/asks/proposal/:id/<verb>` that lands back on the page it came
1469 * from. They sit apart from the card's own form, as forms do not nest.
1470 */
1471 function proposalForms(d: Decision, back?: string): Html {
1472 const id = askFormId(d);
1473 const post = (verb: string) => `/asks/proposal/${d.proposal!.id}/${verb}`;
1474 const where = back === undefined ? null : html`<input type="hidden" name="back" value="${back}" />`;
1475 return html`<form id="${id}" method="post" action="${post("approve")}" hidden>${where}</form>
1476 <form id="${id}-later" method="post" action="${post("later")}" hidden>${where}</form>`;
1477 }
src/web/studio/asks_page.ts +60 −4
@@ -29,7 +29,10 @@ import type { Ctx } from "../../http/context.ts";
2929 import type { User } from "../../core/users.ts";
3030 import { onYouAnswer, studioHome } from "./home.ts";
3131 import { notFoundPage, repoBase, signInFirst } from "../chrome.ts";
32 import { field, readForm } from "../form.ts";
32 import { backTo, field, readForm } from "../form.ts";
33 import { MAX_BODY } from "../../core/issues.ts";
34 import { issueTitles } from "../../core/proposals.ts";
35 import { actOnProposal, type ProposalVerb } from "../../actions/proposals.ts";
3336 import {
3437 canRead,
3538 getRepoById,
@@ -50,6 +53,7 @@ import {
5053 decisionsFor,
5154 forgeNeedsFor,
5255 silencedRepos,
56 putAsideWaves,
5357 type AskRow,
5458 type Consequence,
5559 type Decision,
@@ -168,6 +172,7 @@ const KIND_LABEL: Readonly<Record<Decision["kind"], string>> = {
168172 handover: "Handed over",
169173 parked: "Parked",
170174 declined: "Declined",
175 proposal: "Proposed",
171176 paused: "Paused",
172177 assigned: "Assigned",
173178 setting: "Setting away",
@@ -191,15 +196,33 @@ function acts(ctx: Ctx, d: Decision): Html {
191196 </div>`;
192197 }
193198
199 /** A wave proposal's why, its issue titles and the Redirect box; nothing for any other ask. */
200 function proposalBody(ctx: Ctx, d: Decision): Html | null {
201 if (!d.proposal) return null;
202 return html`<p class="small">${d.proposal.why}</p>
203 <ul class="small">
204 ${issueTitles(d.proposal).map((t) => html`<li>${t}</li>`)}
205 </ul>
206 <form method="post" action="/asks/proposal/${d.proposal.id}/redirect" class="bar">
207 <input type="hidden" name="back" value="${ctx.url.pathname}" />
208 <label class="sr-only" for="redirect-${d.proposal.id}">Redirect the planner</label>
209 <textarea id="redirect-${d.proposal.id}" name="words" rows="2" maxlength="${MAX_BODY}" required placeholder="Plan this instead…"></textarea>
210 ${btn("Redirect", { size: "sm" })}
211 </form>`;
212 }
213
194214 function decisionCard(ctx: Ctx, d: Decision, picked: ReadonlySet<string>): Html {
195215 const base = repoBase(d.repo);
196216 const title = d.pull
197217 ? html`<a href="${changeHref(base, d.pull.number)}">${d.pull.title}</a>`
198218 : d.issue
199219 ? html`<a href="${base}/issues/${d.issue.number}">${d.issue.title}</a>`
200 : html`<a href="${base}/settings/autonomy">Paused</a>`;
220 : d.proposal
221 ? html`${d.proposal.title}`
222 : html`<a href="${base}/settings/autonomy">Paused</a>`;
201223 return card(
202224 html`${clause(d, d.kind, d.consequence)}
225 ${proposalBody(ctx, d)}
203226 ${acts(ctx, d)}`,
204227 {
205228 id: askAnchor(d.id),
@@ -379,6 +402,22 @@ export function needsYouSection(
379402 >
380403 <button type="submit" class="btn btn-sm btn-ghost">Listen again</button>
381404 </form>`;
405 const aside = putAsideWaves(ctx.app.db, user);
406 const asideNote =
407 aside.length === 0
408 ? null
409 : html`<details class="bar">
410 <summary class="muted small"
411 >${aside.length} ${aside.length === 1 ? "wave" : "waves"} put aside</summary
412 >
413 ${aside.map(
414 (w) => html`<form method="post" action="/asks/proposal/${w.id}/again" class="bar">
415 <input type="hidden" name="back" value="${ctx.url.pathname}" />
416 <span class="small">${w.title}</span>
417 ${btn("Bring back", { size: "sm" })}
418 </form>`,
419 )}
420 </details>`;
382421 const last = latestUndoable(ctx.app, user);
383422 const undo =
384423 last === undefined
@@ -391,7 +430,7 @@ export function needsYouSection(
391430 const count = rows.length + needs.length;
392431 if (count === 0 && top === undefined)
393432 return {
394 body: html`<h2 id="asks-sentence" class="sr-only">${askSentence(0)}</h2>${silencedNote}${undo}`,
433 body: html`<h2 id="asks-sentence" class="sr-only">${askSentence(0)}</h2>${silencedNote}${asideNote}${undo}`,
395434 scriptJs: "",
396435 };
397436 // The forge's own health first, then the few asks that matter most;
@@ -442,7 +481,7 @@ export function needsYouSection(
442481 ${reasonInput(top?.reason)} ${btn("Preview", { size: "sm" })}
443482 <span class="muted small">on the selected asks</span>
444483 </form>`}
445 ${silencedNote} ${undo}
484 ${silencedNote} ${asideNote} ${undo}
446485 ${decisions.map((d) => decisionForm(d, ctx.url.pathname))}
447486 </section>`;
448487 return { body, scriptJs: SELECT_JS };
@@ -649,6 +688,23 @@ export async function asksDecide(ctx: Ctx): Promise<Response> {
649688 return redirect(`/?decided=${done.value.id}#asks-bulk-result`);
650689 }
651690
691 /** POST /asks/proposal/:id/(approve|redirect|later|again) — one wave proposal, then back to the page the person came from. */
692 export function asksProposal(verb: ProposalVerb) {
693 return async (ctx: Ctx): Promise<Response> => {
694 if (!ctx.auth.user) return signInFirst(ctx);
695 const form = await readForm(ctx, "rw");
696 if (form instanceof Response) return form;
697 const done = actOnProposal(ctx.app, ctx.auth.user, verb, ctx.params.id, form.get("words"));
outdated

Unused: id is never read (the next line uses ctx.params.id). Remove it.

698 if (!done.ok)
699 return done.error.code === "not_found"
700 ? notFoundPage(ctx)
701 : done.error.code === "forbidden" || done.error.code === "unauthorized"
702 ? new Response("forbidden", { status: 403 })
703 : await asksScreen(ctx, nothingDone(done.error.message));
704 return redirect(backTo(form, "/"));
705 };
706 }
707
652708 /** POST /asks/setting — the owner's Keep or Put back on a setting away from its default. */
653709 export async function asksSetting(ctx: Ctx): Promise<Response> {
654710 if (!ctx.auth.user) return signInFirst(ctx);
test/mcp.test.ts +1
@@ -169,6 +169,7 @@ describe("the forge over MCP", () => {
169169 "asks",
170170 "asks_act",
171171 "asks_decide",
172 "asks_proposal",
172173 "asks_undecide",
173174 "seal",
174175 "now",
test/proposal-card.test.ts added +258
@@ -0,0 +1,258 @@
1 /**
2 * A wave proposal is one inbox card (#1622): Approve opens its issues,
3 * Redirect hands the owner's words to the planner, Later puts it aside.
4 * Refusals come first: a non-owner and a cross-origin post change nothing.
5 */
6
7 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
8 import { mkdtemp, rm } from "node:fs/promises";
9 import { tmpdir } from "node:os";
10 import { join } from "node:path";
11 import { createApp, type App } from "../src/app.ts";
12 import { addToCrew, createAgent } from "../src/actions/crew.ts";
13 import { proposePlan } from "../src/actions/plan.ts";
14 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
15 import { getAgentByName, setCrewRole } from "../src/core/agents.ts";
16 import { listIssues, listComments } from "../src/core/issues.ts";
17 import { getProposal } from "../src/core/proposals.ts";
18 import type { Repo } from "../src/core/repos.ts";
19 import { createToken } from "../src/core/tokens.ts";
20 import { createUser, type User } from "../src/core/users.ts";
21 import { createForgeApp } from "../src/tui/forge.ts";
22 import { headlessTerminal } from "../src/tui/term/terminal.ts";
23 import { settleApp } from "./helpers/tui.ts";
24 import { serve, type Servers } from "../src/http/server.ts";
25 import { askLinesFor } from "../src/web/studio/asks.ts";
26 import { quietLogs } from "./helpers/globals.ts";
27
28 quietLogs();
29
30 const PASSWORD = "a-long-proposal-passphrase";
31
32 describe("a wave proposal is one inbox card", () => {
33 let dataDir = "";
34 let app: App;
35 let servers: Servers;
36 let alice: User;
37 let bob: User;
38 let aliceCookie = "";
39 let bobCookie = "";
40 let repo: Repo;
41
42 const signIn = async (username: string): Promise<string> => {
43 const res = await fetch(`${servers.url}/signin`, {
44 method: "POST",
45 body: new URLSearchParams({ username, password: PASSWORD }),
46 redirect: "manual",
47 });
48 return (res.headers.get("set-cookie") ?? "").split(";")[0]!;
49 };
50
51 const post = (cookie: string, path: string, fields: [string, string][] = [], origin = servers.url) =>
52 fetch(`${servers.url}${path}`, {
53 method: "POST",
54 headers: { cookie: `${cookie}; forge_mode=studio`, origin },
55 body: new URLSearchParams(fields),
56 redirect: "manual",
57 });
58
59 const propose = (title: string): string => {
60 const made = proposePlan(
61 app,
62 alice,
63 repo,
64 [
65 { key: "a", title: "First <b>step</b>", body: "", blockedBy: [] },
66 { key: "b", title: "Second step", body: "", blockedBy: ["a"] },
67 ],
68 { title, why: "because <script>x</script>" },
69 );
70 if (!made.ok) throw new Error(JSON.stringify(made.error));
71 return made.value.id;
72 };
73
74 const rows = () => askLinesFor(app.db, alice).filter((r) => r.proposal !== undefined);
75
76 beforeAll(async () => {
77 dataDir = await mkdtemp(join(tmpdir(), "forge-proposal-"));
78 app = createApp({ dataDir, port: 0 });
79 servers = await serve(app);
80 const a = await createUser(app.db, { trusted: true, username: "alice", email: "[email protected]", password: PASSWORD, adminIfFirst: true });
81 const b = await createUser(app.db, { trusted: true, username: "bob", email: "[email protected]", password: PASSWORD });
82 if (!a.ok || !b.ok) throw new Error("users");
83 alice = a.value;
84 bob = b.value;
85 aliceCookie = await signIn("alice");
86 bobCookie = await signIn("bob");
87 await createAgent(app, alice, { username: "planner" });
88 const made = await createRepo(app, alice, { name: "wave", visibility: "public" });
89 if (!made.ok) throw new Error(String(made.error));
90 repo = made.value;
91 await commitToBranch(app, alice, repo, { message: "seed", files: [{ path: "README.md", content: "# wave\n" }] });
92 addToCrew(app, alice, repo, "planner");
93 setCrewRole(app.db, repo.id, getAgentByName(app.db, "planner")!.user.id, "planner");
94 }, 30_000);
95
96 afterAll(async () => {
97 await servers.stop();
98 await rm(dataDir, { recursive: true, force: true });
99 });
100
101 test("refusals: a non-owner and a cross-origin post change nothing", async () => {
102 const id = propose("Refused wave");
103 expect((await post(bobCookie, `/asks/proposal/${id}/approve`)).status).toBe(403);
104 expect((await post(aliceCookie, `/asks/proposal/${id}/approve`, [], "https://evil.example")).status).toBe(403);
105 expect((await post("", `/asks/proposal/${id}/later`)).headers.get("location")).toContain("signin");
106 expect(getProposal(app.db, id)!.state).toBe("pending");
107 expect(listIssues(app.db, repo.id, { state: "open" }).length).toBe(0);
108 expect(askLinesFor(app.db, bob).some((r) => r.proposal !== undefined)).toBe(false);
109 });
110
111 test("a pending proposal is one row with title, why and issue titles", async () => {
112 const [row] = rows();
113 expect(row!.proposal!.title).toBe("Refused wave");
114 expect(row!.proposal!.issues).toEqual(["First <b>step</b>", "Second step"]);
115 const page = await (await fetch(`${servers.url}/asks`, { headers: { cookie: `${aliceCookie}; forge_mode=studio` } })).text();
116 expect(page).toContain("First &lt;b&gt;step&lt;/b&gt;");
117 expect(page).toContain("because &lt;script&gt;");
118 expect(page).not.toContain("<b>step</b>");
119 });
120
121 test("Later hides it; again brings it back", async () => {
122 const id = rows()[0]!.proposal!.id;
123 const res = await post(aliceCookie, `/asks/proposal/${id}/later`, [["back", "/asks"]]);
124 expect(res.status).toBe(303);
125 expect(res.headers.get("location")).toBe("/asks");
126 expect(rows()).toHaveLength(0);
127 expect((await post(aliceCookie, `/asks/proposal/${id}/again`)).status).toBe(303);
128 expect(rows()).toHaveLength(1);
129 });
130
131 test("Approve opens the issues with their links, once", async () => {
132 const id = rows()[0]!.proposal!.id;
133 const res = await post(aliceCookie, `/asks/proposal/${id}/approve`, [["back", "/asks"]]);
134 expect(res.status).toBe(303);
135 expect(res.headers.get("location")).toBe("/asks");
136 const open = listIssues(app.db, repo.id, { state: "open" });
137 expect(open.map((i) => i.title).sort()).toEqual(["First <b>step</b>", "Second step"]);
138 expect(rows()).toHaveLength(0);
139 await post(aliceCookie, `/asks/proposal/${id}/approve`);
140 expect(listIssues(app.db, repo.id, { state: "open" })).toHaveLength(2);
141 });
142
143 test("Redirect writes the owner's words for the planner and takes the card off", async () => {
144 const id = propose("Wrong wave");
145 expect((await post(aliceCookie, `/asks/proposal/${id}/redirect`, [["words", " "]])).status).toBe(200);
146 expect(getProposal(app.db, id)!.state).toBe("pending");
147 const res = await post(aliceCookie, `/asks/proposal/${id}/redirect`, [["words", "Do the tests first"], ["back", "/"]]);
148 expect(res.status).toBe(303);
149 expect(getProposal(app.db, id)!.state).toBe("redirected");
150 const direction = listIssues(app.db, repo.id, { state: "open" }).find((i) => i.assigneeName === "planner");
151 expect(direction).toBeDefined();
152 expect(listComments(app.db, direction!.id).some((c) => c.body.includes("Do the tests first"))).toBe(true);
153 expect(rows()).toHaveLength(0);
154 });
155
156 test("home shows the Redirect box on the card, and Later's way back", async () => {
157 const id = propose("Home wave");
158 const get = async () =>
159 (await (await fetch(`${servers.url}/`, { headers: { cookie: `${aliceCookie}; forge_mode=studio` } })).text());
160 expect(await get()).toContain(`/asks/proposal/${id}/redirect`);
161 await post(aliceCookie, `/asks/proposal/${id}/later`, [["back", "/"]]);
162 const page = await get();
163 expect(page).not.toContain(`/asks/proposal/${id}/redirect`);
164 expect(page).toContain("1 wave put aside");
165 expect(page).toContain(`/asks/proposal/${id}/again`);
166 await post(aliceCookie, `/asks/proposal/${id}/again`);
167 expect(getProposal(app.db, id)!.state).toBe("pending");
168 await post(aliceCookie, `/asks/proposal/${id}/later`);
169 });
170
171 describe("the other doors", () => {
172 let rw = "";
173 let ro = "";
174 let bobRw = "";
175 beforeAll(() => {
176 rw = createToken(app.db, alice.id, "rw", "rw").token;
177 ro = createToken(app.db, alice.id, "ro", "ro").token;
178 bobRw = createToken(app.db, bob.id, "rw", "rw").token;
179 });
180 const api = async (token: string, id: string, verb: string, payload: unknown = {}) => {
181 const res = await fetch(`${servers.url}/api/v1/asks/proposal/${id}/${verb}`, {
182 method: "POST",
183 headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
184 body: JSON.stringify(payload),
185 });
186 return { status: res.status, body: (await res.json()) as any };
187 };
188 const mcp = async (token: string, args: unknown) => {
189 const res = await fetch(`${servers.url}/mcp`, {
190 method: "POST",
191 headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${token}` },
192 body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "asks_proposal", arguments: args } }),
193 });
194 const body = (await res.json()) as any;
195 return { error: body.result.isError === true, text: body.result.content[0].text as string };
196 };
197
198 test("API: refused to a reader, a read-only token and a stranger's verb; works for the owner", async () => {
199 const id = propose("Api wave");
200 expect((await api(bobRw, id, "approve")).status).toBe(403);
201 expect((await api(ro, id, "later")).status).toBe(403);
202 expect((await api(rw, id, "explode")).status).toBe(404);
203 expect(getProposal(app.db, id)!.state).toBe("pending");
204 expect((await api(rw, id, "redirect", { words: "" })).status).toBe(422);
205 expect((await api(rw, id, "later")).status).toBe(201);
206 expect(getProposal(app.db, id)!.state).toBe("later");
207 expect((await api(rw, id, "later")).status).toBe(409);
208 expect((await api(rw, id, "again")).status).toBe(201);
209 const before = listIssues(app.db, repo.id, { state: "open" }).length;
210 const done = await api(rw, id, "approve");
211 expect(done.status).toBe(201);
212 expect(done.body.opened).toHaveLength(2);
213 expect(listIssues(app.db, repo.id, { state: "open" }).length).toBe(before + 2);
214 });
215
216 test("MCP: asks_proposal is a write that the owner alone may do", async () => {
217 const id = propose("Mcp wave");
218 expect((await mcp(ro, { id, verb: "later" })).error).toBe(true);
219 expect((await mcp(bobRw, { id, verb: "later" })).error).toBe(true);
220 expect((await mcp(rw, { id, verb: "bogus" })).error).toBe(true);
221 expect((await mcp(rw, { id, verb: "later" })).error).toBe(false);
222 expect(getProposal(app.db, id)!.state).toBe("later");
223 expect((await mcp(rw, { id, verb: "again" })).error).toBe(false);
224 const row = askLinesFor(app.db, alice).find((r) => r.proposal?.id === id);
225 expect(row).toBeDefined();
226 expect((await mcp(rw, { id, verb: "redirect", words: "Smaller, please" })).error).toBe(false);
227 expect(getProposal(app.db, id)!.state).toBe("redirected");
228 });
229
230 test("terminal: a wave is a row on the On you card; y approves, l puts aside, B brings back", async () => {
231 const a = propose("Tui approve wave");
232 const b = propose("Tui later wave");
233 const terminal = headlessTerminal(140, 40);
234 const { app: ui } = createForgeApp(
235 app,
236 { sshUsername: "alice", remoteAddress: "127.0.0.1", fingerprint: "SHA256:t", user: alice },
237 { terminal, exitHooks: false },
238 );
239 ui.start();
240 const press = async (key: string) => {
241 ui.dispatch({ type: "key", key, ctrl: false, alt: false, shift: false });
242 await settleApp(ui);
243 };
244 await settleApp(ui);
245 await press("tab");
246 expect(ui.screen.toText()).toContain("Tui later wave");
247 // The first row is wave a (oldest); y approves it.
248 await press("y");
249 expect(getProposal(app.db, a)!.state).toBe("approved");
250 await press("l");
251 expect(getProposal(app.db, b)!.state).toBe("later");
252 expect(ui.screen.toText()).toContain("B brings it back");
253 await press("B");
254 expect(getProposal(app.db, b)!.state).toBe("pending");
255 ui.stop();
256 });
257 });
258 });
test/tui-onyou.test.ts +9
@@ -249,4 +249,13 @@ describe("terminal On you: mutes and many in one move", () => {
249249 expect(h.text()).not.toContain("setting cells.egress");
250250 h.stop();
251251 });
252
253 test("B with no wave put aside and no setting under the cursor does nothing", async () => {
254 hunt(forge, "alice/lamp");
255 const h = await boot();
256 await h.press("B");
257 expect(h.text()).not.toContain("wave back");
258 expect(h.text()).not.toContain("waves back");
259 h.stop();
260 });
252261 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs