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

`asks` answers with a ui://forge/asks card: each decision waiting on the owner as one line with its one action #11133

Merged opened this

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

Merged

mason/forge:mason/11121-asks-answers-with-a-ui-forge-asks-card-e → main 6 commits fork 3 threads · 3 unresolved

  • Merged. mason/11121-asks-answers-with-a-ui-forge-asks-card-e was merged into main as 11cd37f.

Review

Sign in to review this change.
6 files changed +104 −75
docs/MCP.md +12 −8
@@ -57,8 +57,8 @@ bytes in `tools/list`. After changing a tool run `bun scripts/mcp-table.ts`;
5757 | `apps` | — | Apps you can see: live or not, where served, how each runs, what works | 343 |
5858 | `archive` | — | Archive or wake a repository (`archived`) | 332 |
5959 | `asked` | — | What people asked the crew for: unmerged by default, oldest first | 372 |
60 | `asks` | — | What waits on you, most important first; saved filter applies | 457 |
61 | `asks_act` | `dismiss`, `decline`, `mute`, `listen`, `undo`, `ship`, `close`, `replan` | Decide asks (`items`): dismiss, ship, close (`reason`), mute, replan; `listen` (`repo`) unmutes; `undo` (`operation`) | 539 |
60 | `asks` | — | What waits on you, most important first; saved filter applies | 453 |
61 | `asks_act` | `dismiss`, `decline`, `mute`, `listen`, `undo`, `ship`, `close`, `replan` | Decide asks (`items`): dismiss, ship, close (`reason`), mute, replan; `listen` (`repo`) unmutes; `undo` (`operation`) | 535 |
6262 | `asks_decide` | — | Answer a hand-over: the decider (`parked:<id>`), or `give_back` to its builder (`handover:`/`assigned:<id>`) | 270 |
6363 | `asks_proposal` | — | Act on a wave proposal: `approve`, `redirect` (`words`), `later` (`again`) | 315 |
6464 | `asks_undecide` | — | Put a set-aside card back on your board, within a day | 208 |
@@ -484,9 +484,15 @@ answers
484484
485485 - **Cards.** `apps`, `new_app`, `now`, `asks`, `milestones`, `releases`, `waves` (one wave), `map`, `feed`, `pull`, `pulls` and `asked` name a `ui://` resource in
486486 `_meta.ui.resourceUri` (MCP Apps, SEP-1865): `ui://forge/app`, `run`,
487 `decision`, `release`, `wave`, `map`, `feed`, `pull`, `pulls`, `asked`. The Plan card (`map`) shows one row per pillar with
487 `asks`, `release`, `wave`, `map`, `feed`, `pull`, `pulls`, `asked`. The Plan card (`map`) shows one row per pillar with
488488 its ready, working and waiting counts, and the first five ready issues linked
489 to their pages; it has no actions. The decision card's rows link to the pull or issue
489 to their pages; it has no actions. The asks card (`ui://forge/asks`, drawn by `asks`) is one row per waiting decision:
490 what, which repository, how long it has waited, and its one button on the row itself.
491 **Ship** is for a green or agent change, **Decide** for one parked issue and **Decline** otherwise.
492 A proposal, a pause and a row of several parked issues have no button.
493 The buttons call `asks_act` or `asks_decide`, which check the same permission as the page's buttons.
494 Your saved filter applies to it as to the answer; an empty inbox is one line, `Nothing waits on you.`
495 The rows link to the pull or issue
490496 they are about (a paused repository's row has none).
491497 The Apps card (`apps`) links each `owner/name` row to the app's page on the forge and adds a
492498 linked production row when the app has an http(s) URL; the `new_app` card links its
@@ -500,10 +506,8 @@ answers
500506 buttons ask the host to call a tool (`asks_act`, `say`, `stop`, `tell`); the
501507 host decides, and every call is checked as any other. A card with a
502508 words action shows one text field: **Tell <app>** (app card) sends the
503 words to `tell` as `text` with `repo` and `context.said`; **Redirect**
504 (decision card) sends them to `say` with the first ask's `repo`, `number`
505 and `text`. Empty words send nothing. `asks` rows carry `repo`
506 (`owner/name`) beside `number` for this.
509 words to `tell` as `text` with `repo` and `context.said`.
510 Empty words send nothing.
507511 - **The manifest's shape.** `GET /api/v1/here?path=/owner/name` (or the
508512 `here` tool with the page URL) answers `{ here, you, can, mcp, docs }`.
509513 `you.as` is `anonymous`, `reader`, `writer`, `owner` or `agent`; `can` is
src/web/studio/asks.ts +3
@@ -1078,6 +1078,8 @@ export function askLinesFor(
10781078 /** For a wave proposal: what it says, as plain text (the reader escapes it). */
10791079 proposal?: { id: string; title: string; why: string; issues: string[] };
10801080 items: string[];
1081 /** When the oldest ask in the row began to wait (ISO), for how long it has waited. */
1082 since: string;
10811083 /** The decision, its options and what happens if nobody acts (`ASK_WORDS`). */
10821084 words: AskWords;
10831085 /** Why it ranks where it does; rows come most consequential first. */
@@ -1098,6 +1100,7 @@ export function askLinesFor(
10981100 repoId: row.repo.id,
10991101 repo: `${row.repo.ownerName}/${row.repo.name}`,
11001102 items: row.items.map((c) => c.id),
1103 since: row.items.reduce((a, c) => (c.since < a ? c.since : a), row.items[0]!.since),
11011104 words: ASK_WORDS[row.kind],
11021105 consequence: row.consequence,
11031106 waitingFor: row.items[0]!.waiting,
src/web/studio/cards.ts +43 −40
@@ -11,7 +11,7 @@ import { html, raw } from "../html.ts";
1111 import { timeAgo } from "../ui.ts";
1212 import { span, STAGE_LABEL, verdict, type PipelineKey, type PipelineStage } from "../../flow/pipeline.ts";
1313
14 export type CardKind = "app" | "run" | "decision" | "wave" | "map" | "feed" | "pull" | "pulls" | "asked" | "explore" | "issue" | "flow" | "scorecard" | "crew" | "board" | "forks" | "milestones" | "release";
14 export type CardKind = "app" | "run" | "asks" | "wave" | "map" | "feed" | "pull" | "pulls" | "asked" | "explore" | "issue" | "flow" | "scorecard" | "crew" | "board" | "forks" | "milestones" | "release";
1515
1616 export interface CardRow {
1717 readonly label: string;
@@ -20,6 +20,8 @@ export interface CardRow {
2020 readonly link?: string;
2121 /** How many steps the row is nested (a tree): the card pads the label by this much. */
2222 readonly depth?: number;
23 /** The row's own buttons: what the person can do with this one row. */
24 readonly actions?: readonly CardAction[];
2325 }
2426 export interface CardAction {
2527 readonly label: string;
@@ -42,17 +44,17 @@ const here = (origin: string, path: string): boolean => origin !== "" && /^\/(?!
4244
4345 export const CARD_MIME = "text/html;profile=mcp-app";
4446 export const CARD_URI = (kind: CardKind): string => `ui://forge/${kind}`;
45 export const CARD_KINDS: readonly CardKind[] = ["app", "run", "decision", "wave", "map", "feed", "pull", "pulls", "asked", "explore", "issue", "flow", "scorecard", "crew", "board", "forks", "milestones", "release"];
47 export const CARD_KINDS: readonly CardKind[] = ["app", "run", "asks", "wave", "map", "feed", "pull", "pulls", "asked", "explore", "issue", "flow", "scorecard", "crew", "board", "forks", "milestones", "release"];
4648
4749 /** The tool whose answer each card draws. */
4850 export const CARD_OF_TOOL: Readonly<Record<string, CardKind>> = {
4951 apps: "app",
5052 now: "crew",
5153 runs: "run",
52 asks: "decision",
54 asks: "asks",
5355 milestones: "milestones",
5456 releases: "release",
55 asks_act: "decision",
57 asks_act: "asks",
5658 waves: "wave",
5759 map: "map",
5860 feed: "feed",
@@ -71,7 +73,7 @@ export const CARD_OF_TOOL: Readonly<Record<string, CardKind>> = {
7173 const CARD_TITLE: Readonly<Record<CardKind, string>> = {
7274 app: "Apps",
7375 run: "Working now",
74 decision: "Waiting on you",
76 asks: "Waiting on you",
7577 wave: "Wave",
7678 map: "Plan",
7779 feed: "Feed",
@@ -448,39 +450,36 @@ export function cardFor(
448450 if (r["run"] && actions.length < 3)
449451 actions.push({ label: `Stop ${text(r["who"])}`, tool: "stop", args: { repo: text(r["app"]), run: text(r["run"]) } });
450452 }
451 } else if (kind === "decision") {
453 } else if (kind === "asks") {
452454 const asks = list(obj(value)["rows"]).map(obj);
453 status = asks.length === 0 ? "nothing waits" : `${asks.length} waiting`;
455 status = asks.length === 0 ? "Nothing waits on you." : `${asks.length} waiting`;
454456 for (const a of asks.slice(0, LIMIT)) {
455457 const words = obj(a["words"]);
456458 const repo = text(a["repo"]);
457459 const number = Number(a["number"]);
458460 const page = a["numberOf"] === "pull" ? "pulls" : a["numberOf"] === "issue" ? "issues" : "";
459461 const at = text(a["link"]);
462 const waited = text(a["since"]) === "" ? "" : `waiting ${timeAgo(text(a["since"]))}`.replace(" ago", "");
463 const what = `${text(words["decision"])} ${list(words["options"]).map(text).join(" / ")}`.trim();
464 const items = list(a["items"]).map(text);
465 const kindOf = text(a["kind"]);
466 // No `dryRun`: the press is the dry run `asks_act` defaults to, and the
467 // host shows what would change before anything ships or closes. The
468 // tools check the same permission the page's buttons do.
469 const one: CardAction | undefined =
470 items.length === 0 || kindOf === "proposal" || kindOf === "paused" ? undefined
471 : kindOf === "look" || kindOf === "review" ? { label: "Ship", tool: "asks_act", args: { action: "ship", items } }
472 : kindOf === "parked" ? (items.length === 1 ? { label: "Decide", tool: "asks_decide", args: { item: items[0] } } : undefined)
473 : { label: "Decline", tool: "asks_act", args: { action: "close", items } };
460474 rows.push({
461475 label: text(a["line"]),
462 value: `${text(words["decision"])} ${list(words["options"]).map(text).join(" / ")}`.trim(),
476 value: [waited, what].filter((x) => x !== "").join(" · "),
463477 ...(web(origin) && slugOf(repo) && Number.isInteger(number) && number > 0 && page !== ""
464478 ? { link: `${origin}/${repo}/${page}/${number}` }
465479 : web(origin) && here(origin, at) ? { link: origin + at } : {}),
480 ...(one === undefined ? {} : { actions: [one] }),
466481 });
467482 }
468 const first = asks[0];
469 if (first !== undefined) {
470 const items = list(first["items"]).map(text);
471 const kindOf = text(first["kind"]);
472 // No `dryRun`: the press is the dry run `asks_act` defaults to, and the
473 // host shows what would change before anything ships or closes. Redirect
474 // sends the typed words to `say` on the row's own pull or issue.
475 if (kindOf === "look" || kindOf === "review")
476 actions.push({ label: "Approve", tool: "asks_act", args: { action: "ship", items } });
477 actions.push({ label: "Decline", tool: "asks_act", args: { action: "close", items } });
478 actions.push({ label: "Dismiss", tool: "asks_act", args: { action: "dismiss", items } });
479 const repo = text(first["repo"]);
480 const number = Number(first["number"]);
481 if (repo !== "" && Number.isInteger(number) && number > 0)
482 actions.push({ label: "Redirect", tool: "say", args: { repo, number }, words: true });
483 }
484483 } else if (kind === "map") {
485484 const v = obj(value);
486485 const issues = list(v["issues"]).map(obj);
@@ -577,6 +576,21 @@ export const SHELL_JS = `
577576 const root = document.getElementById("card");
578577 const el = (tag, text, cls) => { const e = document.createElement(tag); e.textContent = text; if (cls) e.className = cls; return e; };
579578 let n = 0;
579 let field = null;
580 function button(a) {
581 const b = el("button", a.label);
582 b.onclick = () => {
583 let args = a.args;
584 if (a.words) {
585 const said = field.value.trim();
586 if (said === "") { field.focus(); return; }
587 args = { ...a.args, text: said };
588 if (a.args.context && typeof a.args.context === "object") args.context = { ...a.args.context, said };
589 }
590 parent.postMessage({ jsonrpc: "2.0", id: ++n, method: "tools/call", params: { name: a.tool, arguments: args } }, "*");
591 };
592 return b;
593 }
580594 function draw(card) {
581595 root.replaceChildren(el("h2", card.title), el("p", card.status, "status"));
582596 const dl = document.createElement("dl");
@@ -584,25 +598,14 @@ function draw(card) {
584598 const dt = el("dt", r.label);
585599 if (r.depth > 0) dt.style.paddingInlineStart = Math.min(8, r.depth) * 1.25 + "em";
586600 if (r.link && (r.link.startsWith("https://") || r.link.startsWith("http://"))) { const a = el("a", r.label); a.href = r.link; a.target = "_blank"; a.rel = "noopener noreferrer"; a.onclick = (ev) => { ev.preventDefault(); parent.postMessage({ jsonrpc: "2.0", id: ++n, method: "ui/open-link", params: { url: r.link } }, "*"); }; dt.replaceChildren(a); }
587 dl.append(dt, el("dd", r.value));
601 const dd = el("dd", r.value);
602 for (const a of r.actions || []) dd.append(button(a));
603 dl.append(dt, dd);
588604 }
589605 root.append(dl);
590 const field = card.actions.some((a) => a.words) ? document.createElement("input") : null;
606 field = card.actions.some((a) => a.words) ? document.createElement("input") : null;
591607 if (field) { field.type = "text"; field.maxLength = 2000; field.placeholder = "Your words"; field.setAttribute("aria-label", "Your words"); root.append(field); }
592 for (const a of card.actions) {
593 const b = el("button", a.label);
594 b.onclick = () => {
595 let args = a.args;
596 if (a.words) {
597 const said = field.value.trim();
598 if (said === "") { field.focus(); return; }
599 args = { ...a.args, text: said };
600 if (a.args.context && typeof a.args.context === "object") args.context = { ...a.args.context, said };
601 }
602 parent.postMessage({ jsonrpc: "2.0", id: ++n, method: "tools/call", params: { name: a.tool, arguments: args } }, "*");
603 };
604 root.append(b);
605 }
608 for (const a of card.actions) root.append(button(a));
606609 }
607610 addEventListener("message", (e) => {
608611 if (e.source !== parent) return;
test/card-shell.test.ts +15 −15
@@ -57,7 +57,7 @@ describe("the card shell", () => {
5757 });
5858 });
5959
60 test("Tell and Redirect send the typed words, and nothing when empty", () => {
60 test("Tell sends the typed words, and nothing when empty", () => {
6161 const s = shell();
6262 const app = cardFor("apps", [{ app: "alice/game", live: "up" }])!;
6363 s.send(s.result(app));
@@ -72,22 +72,22 @@ describe("the card shell", () => {
7272 name: "tell",
7373 arguments: { repo: "alice/game", context: { said: "make it blue" }, text: "make it blue" },
7474 });
75 const ask = cardFor("asks", {
76 rows: [{ line: "a", kind: "look", items: ["x:1"], repo: "alice/game", number: 4, words: {} }],
77 })!;
78 s.send(s.result(ask));
79 const redirect = s.root.all().find((n) => n.tag === "button" && n.textContent === "Redirect")!;
80 s.root.all().find((n) => n.tag === "input")!.value = "not like that";
81 redirect.onclick!();
82 expect(s.posted[2].params).toEqual({
83 name: "say",
84 arguments: { repo: "alice/game", number: 4, text: "not like that" },
85 });
8675 });
8776
88 test("an ask without a number offers no Redirect", () => {
89 const card = cardFor("asks", { rows: [{ line: "a", kind: "paused", items: ["x"], repo: "a/b", words: {} }] })!;
90 expect(card.actions.some((a) => a.tool === "say")).toBe(false);
77 test("an ask whose words are markup reaches the page only as text, and a collapsed parked row has no button", () => {
78 const s = shell();
79 const evil = "<script>alert(1)</script>";
80 const row = (kind: string, items: string[], line: string) =>
81 ({ line, kind, items, repo: "a/b", number: 1, numberOf: "issue", words: { decision: evil, options: [evil] } });
82 s.send(s.result(cardFor("asks", { rows: [row("parked", ["p:1"], evil), row("parked", ["p:1", "p:2"], "many"), row("look", ["l:1"], "l")] })!));
83 const nodes = s.root.all();
84 const dts = nodes.filter((n) => n.tag === "dt");
85 expect(dts[0]!.textContent).toBe(evil);
86 expect(nodes.filter((n) => n.tag === "dd")[0]!.textContent).toContain(evil);
87 // Only createElement'd text nodes: no node is a script, and no tag carries the markup.
88 expect(nodes.some((n) => n.tag === "script")).toBe(false);
89 expect(nodes.filter((n) => n.tag === "button").map((b) => b.textContent)).toEqual(["Decide", "Ship"]);
90 expect(nodes.some((n) => n.tag === "input")).toBe(false);
9191 });
9292
9393 test("the map draws a Plan card from what mapJson returns: one count per issue, agreeing everywhere", () => {
test/mcp-asks-now-milestones-cards.test.ts +25 −6
@@ -12,7 +12,7 @@ import { claimRun, createRun } from "../src/core/runs.ts";
1212 import { createToken } from "../src/core/tokens.ts";
1313 import { createUser } from "../src/core/users.ts";
1414 import { serve, type Servers } from "../src/http/server.ts";
15 import { CARD_URI, cardFor } from "../src/web/studio/cards.ts";
15 import { CARD_SHELL, CARD_URI, SHELL_JS, cardFor } from "../src/web/studio/cards.ts";
1616 import { quietLogs } from "./helpers/globals.ts";
1717 import { seedMilestones } from "./fixtures/milestones-seed.ts";
1818 import { listVia } from "./mcp-list.ts";
@@ -68,24 +68,43 @@ describe("MCP asks, now and milestones cards", () => {
6868 const body = await call("asks");
6969 const text = JSON.parse(body.result.content[0].text);
7070 const card = body.result.structuredContent.card;
71 expect(CARD_URI(card.kind)).toBe("ui://forge/decision");
71 expect(CARD_URI(card.kind)).toBe("ui://forge/asks");
7272 expect(card.title).toBe("Waiting on you");
7373 expect(card.rows.length).toBe(text.rows.length);
7474 expect(card.rows.map((r: any) => r.label)).toEqual(text.rows.map((r: any) => r.line));
7575 expect(card.rows.some((r: any) => r.link === `${servers.url}/alice/game/issues/${text.rows.find((x: any) => x.numberOf === "issue")?.number}`)).toBe(true);
7676 rooted(servers.url, card.rows);
77 const act = card.actions.filter((a: any) => a.tool === "asks_act");
78 expect(act.map((a: any) => a.args.action)).toEqual(expect.arrayContaining(["close", "dismiss"]));
79 for (const a of act) expect(a.args.items).toEqual(text.rows[0].items);
77 // Each row carries its one action, on its own items.
78 for (const [i, r] of card.rows.entries()) {
79 if (r.actions === undefined) continue;
80 expect(r.actions.length).toBe(1);
81 const args = r.actions[0].args;
82 expect(args.items ?? [args.item]).toEqual(r.actions[0].tool === "asks_decide" ? [text.rows[i].items[0]] : text.rows[i].items);
83 }
84 expect(card.rows.some((r: any) => r.actions?.[0]?.tool === "asks_act")).toBe(true);
85 expect(card.rows[0].value).toMatch(/^waiting /);
8086 // The tools/list entry names the card.
8187 const list = await listVia(async (p) => (await fetch(`${servers.url}/mcp`, { method: "POST",
8288 headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${token}` },
8389 body: JSON.stringify({ jsonrpc: "2.0", id: 2, method: "tools/list", params: p }) })).json());
8490 const by = (n: string) => list.result.tools.find((t: any) => t.name === n)?._meta?.ui?.resourceUri;
85 expect(by("asks")).toBe("ui://forge/decision");
91 expect(by("asks")).toBe("ui://forge/asks");
8692 expect(by("milestones")).toBe("ui://forge/milestones");
8793 });
8894
95 test("asks card: parked is Decide, an empty inbox is one calm line, and nothing in it is markup", () => {
96 const row = (kind: string, items: string[], line: string) => ({ line, kind, items, repo: "a/b", number: 1, numberOf: "issue", since: "2020-01-01T00:00:00Z", words: { decision: "d", options: ["x"] } });
97 const card = cardFor("asks", { rows: [row("parked", ["parked:i1"], "<script>alert(1)</script>"), row("look", ["look:p1"], "l"), row("stopped", ["stopped:p2"], "s")] }, "https://forge.test")!;
98 expect(card.rows.map((r) => r.actions?.[0]?.label)).toEqual(["Decide", "Ship", "Decline"]);
99 expect(card.rows[0]!.actions![0]).toEqual({ label: "Decide", tool: "asks_decide", args: { item: "parked:i1" } });
100 expect(card.rows[0]!.label).toBe("<script>alert(1)</script>");
101 expect(SHELL_JS).not.toMatch(/innerHTML|insertAdjacentHTML|outerHTML/);
102 expect(CARD_SHELL("asks")).not.toContain("<script>alert");
103 const empty = cardFor("asks", { rows: [] }, "https://forge.test")!;

This does not render the card. A shell that wrote the title into the page would still pass. Render the shell and assert the title arrives only as text.

104 expect(empty.rows).toEqual([]);
105 expect(empty.status).toBe("Nothing waits on you.");
106 });
107
89108 test("asks_act: a done action's receipt carries Undo with its operation; a plan has no card", () => {
90109 expect(cardFor("asks_act", { wouldDismiss: ["x"] }, "https://forge.test")).toBeUndefined();
91110 const done = cardFor("asks_act", { id: "op1", receipt: "Dismissed 2 asks", undoOf: null, undoneBy: null }, "https://forge.test")!;
test/mcp.test.ts +6 −6
@@ -1287,9 +1287,9 @@ describe("the forge over MCP", () => {
12871287 expect(uris).toEqual([
12881288 "ui://forge/app",
12891289 "ui://forge/asked",
1290 "ui://forge/asks",
12901291 "ui://forge/board",
12911292 "ui://forge/crew",
1292 "ui://forge/decision",
12931293 "ui://forge/explore",
12941294 "ui://forge/feed",
12951295 "ui://forge/flow",
@@ -1316,9 +1316,9 @@ describe("the forge over MCP", () => {
13161316 expect(meta("apps")).toBe("ui://forge/app");
13171317 expect(meta("new_app")).toBe("ui://forge/app");
13181318 expect(meta("now")).toBe("ui://forge/crew");
1319 expect(meta("asks")).toBe("ui://forge/decision");
1319 expect(meta("asks")).toBe("ui://forge/asks");
13201320 expect(meta("milestones")).toBe("ui://forge/milestones");
1321 expect(meta("asks_act")).toBe("ui://forge/decision");
1321 expect(meta("asks_act")).toBe("ui://forge/asks");
13221322 expect(meta("map")).toBe("ui://forge/map");
13231323 expect(meta("boards")).toBe("ui://forge/board");
13241324 expect(meta("feed")).toBe("ui://forge/feed");
@@ -1433,7 +1433,7 @@ describe("the forge over MCP", () => {
14331433 expect(cardFor("issue", {}, "https://forge.test", { repo: "alice/game" })).toBeUndefined();
14341434 });
14351435
1436 test("the decision card links each ask to its pull or issue, and to nothing unsafe", async () => {
1436 test("the asks card links each ask to its pull or issue, and to nothing unsafe", async () => {
14371437 const { cardFor } = await import("../src/web/studio/cards.ts");
14381438 const words = { decision: "Ship it?", options: ["ship", "close"] };
14391439 const rows = [
@@ -1529,7 +1529,7 @@ describe("the forge over MCP", () => {
15291529 expect(card.kind).toBe("app");
15301530 expect(card.rows.some((r: any) => r.label === "alice/game")).toBe(true);
15311531 const asks = await call(rw, "tools/call", { name: "asks", arguments: {} });
1532 expect(asks.body.result.structuredContent.card.kind).toBe("decision");
1532 expect(asks.body.result.structuredContent.card.kind).toBe("asks");
15331533 });
15341534
15351535 test("ship and close card actions never apply at once and always meet the target tool's required fields", async () => {
@@ -1547,7 +1547,7 @@ describe("the forge over MCP", () => {
15471547 ];
15481548 let actions = 0;
15491549 for (const card of cards)
1550 for (const a of card!.actions) {
1550 for (const a of [...card!.actions, ...card!.rows.flatMap((r) => r.actions ?? [])]) {
15511551 actions++;
15521552 expect(a.args["dryRun"]).not.toBe(false);
15531553 const t = tools.find((x: any) => x.name === a.tool);

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs