@flowriki/agent 0.1.0-beta.64 → 0.1.0-beta.66

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -3,7 +3,9 @@ import {
3
3
  AGENT_LATEST_URL,
4
4
  AGENT_VERSION,
5
5
  agentUpdateFor,
6
+ allowCommands,
6
7
  authenticationResolver,
8
+ backupsFor,
7
9
  checkAssertion,
8
10
  closeRunSession,
9
11
  connect,
@@ -17,14 +19,17 @@ import {
17
19
  helperRunner,
18
20
  openRunSession,
19
21
  prepareBrowsers,
22
+ readApprovals,
20
23
  replayCase,
21
24
  replayFlow,
22
25
  resolveCheckouts,
23
26
  resolveService,
27
+ revokeCommands,
24
28
  runtimeValue,
25
29
  start,
26
- status
27
- } from "./chunk-2MTLWAXK.js";
30
+ status,
31
+ withBackups
32
+ } from "./chunk-WUK3X43D.js";
28
33
  import {
29
34
  CLOUD_PROVIDERS,
30
35
  CLOUD_PROVIDER_NAME,
@@ -52,7 +57,7 @@ import {
52
57
  slugify,
53
58
  stepRefusedOn,
54
59
  targetFor
55
- } from "./chunk-RI63WNGU.js";
60
+ } from "./chunk-YD7F7JMA.js";
56
61
 
57
62
  // src/cli.ts
58
63
  import { parseArgs } from "util";
@@ -245,6 +250,13 @@ var ServiceClient = class {
245
250
  { method: "POST", body: JSON.stringify(input) }
246
251
  );
247
252
  }
253
+ /** A test case that cannot be automated, and why. */
254
+ reportBlocker(projectId, input) {
255
+ return this.call(
256
+ `/api/agent/cases/blocker?projectId=${encodeURIComponent(projectId)}`,
257
+ { method: "POST", body: JSON.stringify(input) }
258
+ );
259
+ }
248
260
  /** A helper, its secrets sealed by the service. */
249
261
  createHelper(projectId, input) {
250
262
  return this.call(
@@ -253,6 +265,11 @@ var ServiceClient = class {
253
265
  );
254
266
  }
255
267
  /** What a new flow starts after: the flows, in run order, and their steps. */
268
+ /** A saved flow's steps, to fix it from rather than record it again. */
269
+ savedFlow(projectId, name, platform) {
270
+ const q = new URLSearchParams({ projectId, name, ...platform ? { platform } : {} });
271
+ return this.call(`/api/agent/flows/saved?${q}`);
272
+ }
256
273
  flowsAfter(projectId, after) {
257
274
  return this.call(
258
275
  `/api/agent/flows/after?projectId=${encodeURIComponent(projectId)}`,
@@ -417,6 +434,15 @@ import fs from "fs";
417
434
  import path from "path";
418
435
  var MAX_TREE = 14e3;
419
436
  var MAX_STEP_VIEW = 6e3;
437
+ function quiet(tree) {
438
+ return tree.split("\n").filter((line) => {
439
+ const t = line.trim();
440
+ if (/^- \/url:/.test(t)) return false;
441
+ if (/^- img$/.test(t) || /^- img:$/.test(t)) return false;
442
+ if (/^- listitem$/.test(t)) return false;
443
+ return true;
444
+ }).join("\n");
445
+ }
420
446
  function topLevelBlocks(tree) {
421
447
  const blocks = [];
422
448
  for (const line of tree.split("\n")) {
@@ -430,7 +456,7 @@ function topLevelBlocks(tree) {
430
456
  };
431
457
  }
432
458
  var STEP_TIMEOUT_MS = 1e4;
433
- var SECRET_WORDS = /password|passwd|pass[-_\s]?code|pwd|secret|otp|one[-_\s]?time[-_\s]?(code|pass)|security[-_\s]?(code|answer)|cvv|cvc|\bpin\b/i;
459
+ var SECRET_WORDS = /password|passwd|pass[-_\s]?code|pwd|secret|otp|one[-_\s]?time[-_\s]?(code|pass)|security[-_\s]?(code|answer)|cvv|cvc|\bpin\b|token|api[-_\s]?key|access[-_\s]?key|card[-_\s]?number|credit[-_\s]?card/i;
434
460
  var TYPED = "typed_";
435
461
  var withoutTyped = (steps) => steps.map((st) => st.secretRef?.startsWith(TYPED) ? (({ secretRef: _, ...rest }) => rest)(st) : st);
436
462
  var SIGN_UP = /\b(sign[\s-]?up|register|registration|create (an |your )?account|join)\b/i;
@@ -608,7 +634,11 @@ var AuthoringSession = class _AuthoringSession {
608
634
  mobile = null;
609
635
  values = /* @__PURE__ */ new Map();
610
636
  resolvers = null;
637
+ /** Every helper runner made in this session, so background commands stop with it. */
638
+ helperRunners = [];
611
639
  lastTree = null;
640
+ /** The navigation as last shown, so a step repeats it only when it changed. */
641
+ lastNavigation = null;
612
642
  /** Values typed into secret fields, by their `typed:` reference. */
613
643
  typed = /* @__PURE__ */ new Map();
614
644
  draft = null;
@@ -652,6 +682,7 @@ var AuthoringSession = class _AuthoringSession {
652
682
  draft.device?.kind === "cloud" ? { cloud: draft.device.provider } : {}
653
683
  );
654
684
  const runHelper = helperRunner(prepared.helpers, prepared.secrets);
685
+ if (runHelper) this.helperRunners.push(runHelper);
655
686
  this.resolvers = {
656
687
  resolveAuth: authenticationResolver(prepared.authentications),
657
688
  resolveSecret: (ref) => this.typed.get(ref) ?? prepared.secrets[ref],
@@ -670,6 +701,8 @@ var AuthoringSession = class _AuthoringSession {
670
701
  * it is kept, so a flow interrupted — the program stopped, the limit hit,
671
702
  * the agent restarted — is not lost: starting the same flow again replays
672
703
  * what was kept and carries on from there, unless `fresh` says start over. */
704
+ /** Steps of a saved flow still to put back, after one failed on the way. */
705
+ waiting = [];
673
706
  async start(input, catalog, opts = {}) {
674
707
  await this.close();
675
708
  this.catalog = catalog;
@@ -678,7 +711,8 @@ var AuthoringSession = class _AuthoringSession {
678
711
  this.typed = /* @__PURE__ */ new Map();
679
712
  this.lastTree = null;
680
713
  this.verified = null;
681
- const left = opts.fresh ? null : this.readDraft(input.projectId, input.name);
714
+ this.waiting = [];
715
+ const left = opts.fresh ? null : this.readDraft(input.projectId, input.name) ?? (opts.saved?.length ? { steps: opts.saved } : null);
682
716
  if (opts.fresh) this.dropDraft(input.projectId, input.name);
683
717
  const opened = isMobile(input.platform) ? await this.startMobile(input.platform, catalog) : await this.startWeb(input, catalog);
684
718
  this.report();
@@ -691,7 +725,7 @@ var AuthoringSession = class _AuthoringSession {
691
725
  async resume(steps, opened) {
692
726
  const draft = this.draft;
693
727
  let failed = null;
694
- for (const step of steps) {
728
+ for (const [at, step] of steps.entries()) {
695
729
  try {
696
730
  if (this.mobile) {
697
731
  const done = await runMobileSteps(this.mobile, [step], this.mobileResolvers());
@@ -713,17 +747,28 @@ var AuthoringSession = class _AuthoringSession {
713
747
  this.checkpoint();
714
748
  } catch (error) {
715
749
  failed = `${describe(step)} \u2014 ${error.message.split("\n")[0]}`;
750
+ this.waiting = steps.slice(at + 1);
716
751
  break;
717
752
  }
718
753
  }
719
754
  this.checkpoint();
720
755
  const kept = draft.steps.length;
721
- const head = failed ? `Resumed "${draft.name}": ${kept} of ${steps.length} earlier steps are back; the next one did not work this time and was dropped (${failed}). Carry on from here.` : `Resumed "${draft.name}": its ${kept} earlier step${kept === 1 ? "" : "s"} ${kept === 1 ? "is" : "are"} back. Carry on from here.`;
756
+ const later = this.waiting.length ? ` ${this.waiting.length} later step${this.waiting.length === 1 ? " is" : "s are"} waiting: do the failed step again the way the page now needs, then continue_flow puts ${this.waiting.length === 1 ? "it" : "them"} back.` : "";
757
+ const head = failed ? `Resumed "${draft.name}": ${kept} of ${steps.length} earlier steps are back; the next one did not work this time and was dropped (${failed}).${later}` : `Resumed "${draft.name}": its ${kept} earlier step${kept === 1 ? "" : "s"} ${kept === 1 ? "is" : "are"} back. Carry on from here.`;
722
758
  const view = this.mobile ? await this.snapshot() : opened.includes("\n") ? await this.snapshot() : opened;
723
759
  return `${head}
724
760
 
725
761
  ${view}`;
726
762
  }
763
+ /** Put back the steps that were waiting after a failed one — replayed,
764
+ * kept as they pass; the next that fails is where you take over again. */
765
+ async continueFlow() {
766
+ this.requireOpen();
767
+ const steps = this.waiting;
768
+ if (!steps.length) throw new Error("no steps are waiting \u2014 the flow is whole; add_check, replay_flow and save_flow");
769
+ this.waiting = [];
770
+ return this.resume(steps, "");
771
+ }
727
772
  // ── Drafts on disk ──────────────────────────────────────────────────────
728
773
  static draftFile(projectId, name) {
729
774
  return path.join(flowrikiHome(), "drafts", projectId.replace(/[^A-Za-z0-9_-]/g, "_"), `${slugify(name) || "flow"}.json`);
@@ -945,32 +990,36 @@ Record the flows it needs; on every act, add_check and use_helper pass line: the
945
990
  const before = this.lastTree;
946
991
  this.lastTree = tree;
947
992
  const cap = (t, max) => t.length > max ? `${t.slice(0, max)}
948
- \u2026 (cut \u2014 read_page shows the whole page)` : t;
949
- if (full || before === null) return head + cap(tree, MAX_TREE);
950
- const { dialogs, navigation } = topLevelBlocks(tree);
993
+ \u2026 (cut)` : t;
994
+ if (full || before === null) {
995
+ this.lastNavigation = topLevelBlocks(tree).navigation ?? null;
996
+ return head + cap(tree, MAX_TREE);
997
+ }
998
+ const { dialogs, navigation: nav } = topLevelBlocks(tree);
999
+ const navigation = nav && nav !== this.lastNavigation ? nav : null;
1000
+ this.lastNavigation = nav ?? null;
951
1001
  if (dialogs.length) {
952
- return head + cap([...navigation ? [navigation] : [], ...dialogs].join("\n"), MAX_STEP_VIEW) + "\n(only the open dialog and the navigation \u2014 read_page shows the whole page)";
1002
+ return head + cap([...navigation ? [navigation] : [], ...dialogs].join("\n"), MAX_STEP_VIEW) + "\n(only the open dialog)";
953
1003
  }
954
1004
  const was = new Set(before.split("\n"));
955
1005
  const now = new Set(tree.split("\n"));
956
1006
  const added = tree.split("\n").filter((l) => !was.has(l));
957
1007
  const removed = before.split("\n").filter((l) => !now.has(l));
958
- if (added.length === 0 && removed.length === 0) return head + "(the page did not change \u2014 read_page shows it)";
1008
+ if (added.length === 0 && removed.length === 0) return head + "(the page did not change)";
959
1009
  if (added.length + removed.length > 120) return head + cap(tree, MAX_STEP_VIEW);
960
1010
  return head + [
961
1011
  ...navigation ? [navigation, ""] : [],
962
1012
  added.length ? `Appeared:
963
1013
  ${added.join("\n")}` : "",
964
1014
  removed.length ? `Gone:
965
- ${removed.join("\n")}` : "",
966
- "(changes since the last step \u2014 read_page shows the whole page)"
1015
+ ${removed.join("\n")}` : ""
967
1016
  ].filter(Boolean).join("\n");
968
1017
  }
969
1018
  async webTree() {
970
1019
  const { page, draft } = this.requireOpen();
971
1020
  await page.waitForLoadState("domcontentloaded", { timeout: 1e4 }).catch(() => void 0);
972
1021
  const tree = numberDuplicates(labelTree(
973
- await page.locator("body").ariaSnapshot({ timeout: 8e3 }).catch((e) => `(could not read the page: ${e.message})`),
1022
+ quiet(await page.locator("body").ariaSnapshot({ timeout: 8e3 }).catch((e) => `(could not read the page: ${e.message})`)),
974
1023
  await fieldNames(page)
975
1024
  ));
976
1025
  const title = await page.title().catch(() => "");
@@ -980,7 +1029,28 @@ Steps so far: ${draft.steps.length}
980
1029
  ` };
981
1030
  }
982
1031
  /** Do one thing on the page, and keep it as a step if it worked. */
1032
+ /** The step that failed last, and how many times in a row. */
1033
+ failing = null;
1034
+ /** DO ONE STEP — and notice when the same step keeps failing. Retrying it
1035
+ * turn after turn was where whole jobs went: each try re-reads the entire
1036
+ * conversation. On the third failure in a row, the answer says to stop. */
983
1037
  async act(input) {
1038
+ const key = [input.action, input.role ?? "", input.name ?? "", input.within ?? "", input.nth ?? ""].join("|");
1039
+ try {
1040
+ const done = await this.actOnce(input);
1041
+ this.failing = null;
1042
+ return done;
1043
+ } catch (error) {
1044
+ this.failing = this.failing?.key === key ? { key, times: this.failing.times + 1 } : { key, times: 1 };
1045
+ if (this.failing.times >= 3) {
1046
+ error.message = `${error.message}
1047
+
1048
+ THIS STEP HAS FAILED ${this.failing.times} TIMES IN A ROW. Do not try it again: if another way to reach the same thing is not obvious from the page, call report_blocker for the test case (kind: stuck, with the line) and move on.`;
1049
+ }
1050
+ throw error;
1051
+ }
1052
+ }
1053
+ async actOnce(input) {
984
1054
  if (this.mobile) return this.mobileAct(input);
985
1055
  const refused = stepRefusedOn(input.action === "tap" ? "click" : input.action, "web");
986
1056
  if (refused) throw new Error(refused);
@@ -992,11 +1062,11 @@ Steps so far: ${draft.steps.length}
992
1062
  if ((input.action === "fill" || input.action === "select" || input.action === "press" || input.action === "navigate") && input.value === void 0) {
993
1063
  throw new Error(`${input.action} needs a value`);
994
1064
  }
995
- const target = navigating ? { role: "", name: "" } : asLabelled({ role: input.role ?? "", name: input.name ?? "" }, await fieldNames(page));
1065
+ const target = navigating ? { role: "", name: "", scope: "" } : asLabelled({ role: input.role ?? "", name: input.name ?? "" }, await fieldNames(page));
996
1066
  const secret = input.action === "fill" && (input.secret ?? (SECRET_WORDS.test(input.name ?? "") || SECRET_WORDS.test(target.name)));
997
1067
  const id = `s${draft.steps.length + 1}_${Date.now().toString(36)}`;
998
1068
  if (secret) this.typed.set(`${TYPED}${id}`, input.value ?? "");
999
- const step = RecordedStepSchema.parse({
1069
+ let step = RecordedStepSchema.parse({
1000
1070
  id,
1001
1071
  type: input.action,
1002
1072
  ...target,
@@ -1011,6 +1081,7 @@ Steps so far: ${draft.steps.length}
1011
1081
  // environment the run is pointed at rather than the one written on.
1012
1082
  ...input.action === "navigate" && input.value?.startsWith("/") ? { navigationBase: "application" } : {}
1013
1083
  });
1084
+ if (!navigating && input.action !== "press") step = RecordedStepSchema.parse(withBackups(step, await backupsFor(page, step)));
1014
1085
  try {
1015
1086
  this.page = await executeStep(page, step, {
1016
1087
  pages: run.pages,
@@ -1133,9 +1204,7 @@ ${draft.steps.map((s, i) => `${i + 1}. ${describe(s)}`).join("\n")}`;
1133
1204
  if (!Number.isInteger(number) || number < 1 || number > draft.steps.length) throw new Error(`there is no step ${number}`);
1134
1205
  const [gone] = draft.steps.splice(number - 1, 1);
1135
1206
  this.checkpoint();
1136
- return `Removed step ${number} (${describe(gone)}). The browser was not undone \u2014 replay_flow shows the flow as it now stands.
1137
-
1138
- ${this.list()}`;
1207
+ return `Removed step ${number} (${describe(gone)}); ${draft.steps.length} steps now. The browser was not undone.`;
1139
1208
  }
1140
1209
  /** Run the whole draft from the start, in a browser of its own, the way a
1141
1210
  * run will — the proof the steps work together, not just one at a time. */
@@ -1233,6 +1302,7 @@ ${describeRows(built.rows, built.testCase)}`;
1233
1302
  const steps = testCase.occurrences.flatMap((o) => o.steps);
1234
1303
  const prepared = await this.service.prepare(projectId, steps, null);
1235
1304
  const runHelper = helperRunner(prepared.helpers, prepared.secrets);
1305
+ if (runHelper) this.helperRunners.push(runHelper);
1236
1306
  const base = catalog.baseUrl;
1237
1307
  const phoneRows = testCase.occurrences.filter((o) => isMobile(o.platform));
1238
1308
  if (!base && phoneRows.length < testCase.occurrences.length) throw new Error("this project has no base URL \u2014 set one in the project's settings first");
@@ -1281,6 +1351,7 @@ ${lines.join("\n")}
1281
1351
  Review and publish it here: ${review}`;
1282
1352
  }
1283
1353
  async close() {
1354
+ for (const runner of this.helperRunners.splice(0)) runner.close();
1284
1355
  if (this.reportTimer) await this.sendReport();
1285
1356
  const run = this.run;
1286
1357
  const mobile = this.mobile;
@@ -1347,7 +1418,7 @@ Steps so far: ${draft.steps.length}
1347
1418
  throw new Error(`${input.action} needs the element's role and name, exactly as read_page shows them`);
1348
1419
  }
1349
1420
  if ((type === "fill" || type === "select" || type === "navigate") && input.value === void 0) throw new Error(`${input.action} needs a value`);
1350
- let target = { role: "", name: "" };
1421
+ let target = { role: "", name: "", scope: "" };
1351
1422
  let password = false;
1352
1423
  if (input.role) {
1353
1424
  const asked = { role: input.role, name: input.name ?? "", ...scopeOf(input.within), ...input.nth !== void 0 ? { nth: input.nth } : {} };
@@ -1552,6 +1623,20 @@ THE MODEL \u2014 get this right before recording anything:
1552
1623
  account.
1553
1624
  - HELPERS (an inbox for email codes, an authenticator, SMS, an API call) are made
1554
1625
  with create_helper when the catalog has none that fits.
1626
+ - HEALING fixes a test's automation, never its business: change only how steps
1627
+ find and reach things (an element renamed or moved, a new screen in the way,
1628
+ a wait). Never change a case's Given / When / Then, what a check expects or
1629
+ which checks are on, and never drop a row \u2014 in a heal the service refuses
1630
+ such a save. If the app itself behaves differently now, report_blocker
1631
+ (app-bug) and leave that case.
1632
+ - WHEN A CASE CANNOT BE AUTOMATED, say so with report_blocker and move on to
1633
+ the next case \u2014 never loop. Report it when the app itself is broken at that
1634
+ step (app-bug), when it needs something only a person has \u2014 a captcha, a
1635
+ real card, a code sent to somebody's own phone (needs-person), when a
1636
+ business step is unclear or does not match the app (unclear-step), when the
1637
+ screen or feature cannot be reached (not-found), or after 3 failed tries at
1638
+ the same step (stuck). Name the line it stopped at and what would unblock it.
1639
+ A case marked manual by a person is not to be automated at all.
1555
1640
  - SEVERAL ELEMENTS WITH THE SAME NAME (two "Proceed to Checkout" buttons, twelve
1556
1641
  "Select & Customize") are told apart by POSITION, like an XPath index: read_page
1557
1642
  marks each one [nth N of M], and act / add_check take nth (0 = the first). Use it
@@ -1571,7 +1656,7 @@ THE MODEL \u2014 get this right before recording anything:
1571
1656
  "Ordering \u2013 Pizza builder", "Checkout \u2013 Payment". Flows (features) and the
1572
1657
  cases built from them use the same names. Keep one area per folder.
1573
1658
 
1574
- KNOWLEDGE: project_brief is the project's memory \u2014 read it first. Search the
1659
+ KNOWLEDGE: project_brief is the project's memory \u2014 given with the request when there is one (then do not call it again). Search the
1575
1660
  knowledge pages with knowledge_search; read one with knowledge_read.
1576
1661
  product.md is "About the product": the person's own description of
1577
1662
  the product, in their words, as long as they like \u2014 read it first. Beside it,
@@ -1631,14 +1716,17 @@ mix rows on a phone with rows in a browser (a customer orders in the app, an
1631
1716
  admin approves on the website).
1632
1717
 
1633
1718
  HOW TO WORK:
1634
- 1. Call project_catalog. Reuse what exists \u2014 published, and on Claude's working
1635
- branch \u2014 rather than writing it again.
1636
- 2. Before recording, say the plan to the person: the folders, the building-block
1637
- flows (and what each starts after), and the test cases (rows + what each
1638
- checks). Then record the blocks, earliest first.
1639
- 3. Record with start_flow (pass "after" for the flows it continues from), read
1640
- the page with read_page, and act using the role and name EXACTLY as the page
1641
- shows them. If a name appears more than once, pass "within" with the name of
1719
+ 1. Reuse what exists \u2014 published, and on Claude's working branch \u2014 rather than
1720
+ writing it again: project_catalog lists it (once is enough; a repeat says
1721
+ whether anything changed).
1722
+ 2. Plan the folders, the building-block flows (and what each starts after) and
1723
+ the test cases (rows + what each checks), then record the blocks, earliest
1724
+ first. No need to wait for an answer: nobody is at the keyboard.
1725
+ 3. Record with start_flow (pass "after" for the flows it continues from) \u2014 it
1726
+ answers with the page \u2014 and act using the role and name EXACTLY as the page
1727
+ shows them, several steps per act when they are all on the page already.
1728
+ Each step answers with what changed; read_page only when you need the whole
1729
+ page again. If a name appears more than once, pass "within" with the name of
1642
1730
  the region, dialog or form \u2014 or "dialog" / "navigation".
1643
1731
  4. For things outside the page \u2014 an emailed code or link, an authenticator
1644
1732
  code, a text message, an API call \u2014 use use_helper with a helper from the
@@ -1724,8 +1812,19 @@ var caseSpec = (args) => ({
1724
1812
  });
1725
1813
  var text = (value, isError = false) => ({ content: [{ type: "text", text: value }], ...isError ? { isError } : {} });
1726
1814
  var str = (value) => typeof value === "string" ? value : value === void 0 || value === null ? void 0 : String(value);
1815
+ var STEP_PROPERTIES = {
1816
+ action: { type: "string", enum: ["click", "dblclick", "rightclick", "hover", "fill", "select", "check", "press", "navigate", "back", "forward", "tap", "longPress", "swipe", "scroll", "hideKeyboard", "permission", "launchApp", "terminateApp", "resetApp", "background", "rotate"] },
1817
+ role: { type: "string", description: "The element's role as read_page shows it, e.g. button, link, textbox." },
1818
+ name: { type: "string", description: "The element's accessible name, exactly as read_page shows it." },
1819
+ within: { type: "string", description: "When the name is not unique: the region, dialog or form that holds it." },
1820
+ nth: { type: "integer", minimum: 0, description: "Which one when several share role and name: 0 is the first ([nth N of M] in read_page)." },
1821
+ value: { type: "string" },
1822
+ secret: { type: "boolean", description: "fill only: a password, PIN or key \u2014 kept out of the flow, saved to the vault." },
1823
+ line: { type: "integer", minimum: 1, description: "The test case line (from start_case) this step carries out." }
1824
+ };
1727
1825
  function authoringTools(service, session) {
1728
- let defaultProject = null;
1826
+ let defaultProject = process.env["FLOWRIKI_PROJECT_ID"]?.trim() || null;
1827
+ const catalogSaid = /* @__PURE__ */ new Map();
1729
1828
  const catalogs = /* @__PURE__ */ new Map();
1730
1829
  const projectFor = async (asked) => {
1731
1830
  const named = str(asked);
@@ -1819,13 +1918,20 @@ It is now the project these tools work on. Open it: ${service.origin}/projects/$
1819
1918
  {
1820
1919
  name: "project_catalog",
1821
1920
  title: "What a test can be built from",
1822
- description: "The project's base URL, features (folders for flows), existing flows, sign-in profiles with their accounts, and helpers (email codes, 2FA, SMS, API calls). Read this before starting a flow.",
1823
- inputSchema: { type: "object", properties: { projectId: { type: "string", description: "Optional when you are on one project." } }, additionalProperties: false },
1921
+ description: "What exists to build from: base URL, features with their flows, test-case folders (counts \u2014 name a folder to list its cases), sign-in profiles with accounts, and helpers. start_flow reads it by itself; call this to see what to reuse. platform: only that platform's flows. A second call in the same job says whether anything changed.",
1922
+ inputSchema: { type: "object", properties: {
1923
+ projectId: { type: "string", description: "Optional when you are on one project." },
1924
+ platform: { type: "string", enum: ["web", "android", "ios"], description: "Only this platform's flows." },
1925
+ folder: { type: "string", description: "List the test cases of this folder (by name)." }
1926
+ }, additionalProperties: false },
1824
1927
  annotations: { readOnlyHint: true },
1825
1928
  run: async (args) => {
1826
1929
  const projectId = await projectFor(args["projectId"]);
1827
1930
  const c = await catalogOf(projectId, true);
1828
- const flowsBy = (featureId) => c.flows.filter((f) => f.featureId === featureId).map((f) => ` - ${f.name} (${f.steps} steps${isMobile(f.platform) ? `, ${f.platform}` : ""})`).join("\n");
1931
+ const onlyPlatform = str(args["platform"]);
1932
+ const onPlatform = (f) => !onlyPlatform || (onlyPlatform === "web" ? !isMobile(f.platform) : f.platform === onlyPlatform);
1933
+ const flowsBy = (featureId) => c.flows.filter((f) => f.featureId === featureId && onPlatform(f)).map((f) => ` - ${f.name} (${f.steps} steps${isMobile(f.platform) ? `, ${f.platform}` : ""})`).join("\n");
1934
+ const askedFolder = str(args["folder"])?.toLowerCase();
1829
1935
  const platforms = platformsOf(c);
1830
1936
  const phones = platforms.android || platforms.ios ? await devicesSummary() : [];
1831
1937
  const phoneLines = ["android", "ios"].filter((p) => platforms[p]).map((p) => {
@@ -1834,11 +1940,11 @@ It is now the project these tools work on. Open it: ${service.origin}/projects/$
1834
1940
  const farms = (c.mobile?.cloud ?? []).map((f) => CLOUD_PROVIDER_NAME[f.provider]);
1835
1941
  const chosen = c.mobile?.devices[p];
1836
1942
  return `${p === "android" ? "Android" : "iOS"} app: ${app ?? "(not named \u2014 Settings \u2192 Environments)"}
1837
- Phones on this computer: ${here.length ? here.map((d) => `${d.name}${d.state === "ready" ? "" : ` (${d.state})`}`).join(", ") : "none"}` + (farms.length ? `
1943
+ Phones on this computer: ${here.filter((d) => d.state === "ready").map((d) => d.name).join(", ") || "none running"}${here.some((d) => d.state !== "ready") ? ` (+${here.filter((d) => d.state !== "ready").length} switched off)` : ""}` + (farms.length ? `
1838
1944
  Cloud device farms: ${farms.join(", ")}` : "") + `
1839
1945
  Runs: ${c.runWhere?.[p] === "cloud" ? "in the cloud" : "on this computer"}` + (chosen ? ` \u2014 ${chosen.kind === "local" ? chosen.name || chosen.id : `${CLOUD_PROVIDER_NAME[chosen.provider]}: ${chosen.device}`}` : "");
1840
1946
  });
1841
- return text([
1947
+ const out = [
1842
1948
  `Project ${projectId}. Tests: ${[platforms.web && "web", platforms.android && "Android app", platforms.ios && "iOS app"].filter(Boolean).join(", ")}.`,
1843
1949
  platforms.web ? `Base URL: ${c.baseUrl || "(not set)"}${c.runWhere?.web === "cloud" ? " \u2014 test runs use a cloud browser; recording is on this computer" : ""}` : "",
1844
1950
  ...phoneLines,
@@ -1857,15 +1963,27 @@ On Claude's working branch "${c.draft.title}" (${c.draft.changeId}), not yet pub
1857
1963
  ] : [],
1858
1964
  `
1859
1965
  Test cases:${(c.cases ?? []).length ? "" : " none published"}`,
1860
- ...(c.caseFolders ?? []).map((f) => ` - ${f.key ? `${f.key} ` : ""}${f.name} (id ${f.id})
1861
- ${(c.cases ?? []).filter((t) => t.folderId === f.id).map((t) => ` - ${t.key ? `${t.key} ` : ""}${t.name} \u2014 ${t.rows.length ? `rows: ${t.rows.join(" \u2192 ")}` : "not automated yet"}`).join("\n")}`),
1966
+ // COUNTS, unless a folder is asked for: hundreds of cases written
1967
+ // and not automated yet were most of this answer, re-read on every
1968
+ // turn after. read_test_cases has any case's steps.
1969
+ ...(c.caseFolders ?? []).map((f) => {
1970
+ const inside = (c.cases ?? []).filter((t) => t.folderId === f.id);
1971
+ const head = ` - ${f.key ? `${f.key} ` : ""}${f.name} (id ${f.id}) \u2014 ${inside.length} case${inside.length === 1 ? "" : "s"}, ${inside.filter((t) => t.rows.length).length} automated`;
1972
+ if (askedFolder !== f.name.toLowerCase()) return head;
1973
+ return `${head}
1974
+ ${inside.map((t) => ` - ${t.key ? `${t.key} ` : ""}${t.name} \u2014 ${t.rows.length ? `rows: ${t.rows.join(" \u2192 ")}` : "not automated yet"}`).join("\n")}`;
1975
+ }),
1862
1976
  `
1863
1977
  Sign-in profiles:${c.signIns.length ? "" : " none"}`,
1864
1978
  ...c.signIns.map((s) => ` - ${s.name} (profileId ${s.profileId})${s.fields?.length ? ` \u2014 fields ${s.fields.map((f) => `"${f}"`).join(", ")}` : ""}: ${s.accounts.map((a) => `${a.name} (accountId ${a.accountId})`).join(", ") || "no accounts yet"}`),
1865
1979
  `
1866
1980
  Helpers:${c.helpers.length ? "" : " none \u2014 create_helper makes one"}`,
1867
1981
  ...c.helpers.map((h) => ` - ${h.name} (id ${h.id}) \u2014 ${h.what}${h.inputs.length ? `; needs ${h.inputs.map((n) => `{{${n}}}`).join(", ")}` : ""}${h.description ? ` \u2014 ${h.description}` : ""}`)
1868
- ].filter(Boolean).join("\n"));
1982
+ ].filter(Boolean).join("\n");
1983
+ const key = `${projectId}|${onlyPlatform ?? ""}|${askedFolder ?? ""}`;
1984
+ if (catalogSaid.get(key) === out) return text("Nothing has changed since you last read the catalog in this job.");
1985
+ catalogSaid.set(key, out);
1986
+ return text(out);
1869
1987
  }
1870
1988
  },
1871
1989
  {
@@ -1957,9 +2075,13 @@ ${m.platform === "web" ? "Web pages" : "Android screens"} runs reached:`);
1957
2075
  if (signIn && !catalog.signIns.some((s) => s.profileId === signIn.profileId)) {
1958
2076
  throw new Error(`no sign-in profile ${signIn.profileId} \u2014 project_catalog lists them`);
1959
2077
  }
1960
- const asked = Array.isArray(args["after"]) ? args["after"].map(String).filter(Boolean) : [];
1961
- const plan = asked.length ? await service.flowsAfter(projectId, asked) : null;
1962
2078
  const platforms = platformsOf(catalog);
2079
+ const named = str(args["name"]) ?? "";
2080
+ const wantedPlatform = str(args["platform"]);
2081
+ const saved = args["fresh"] === true ? null : await Promise.resolve().then(() => service.savedFlow(projectId, named, wantedPlatform)).then((r) => r.flow).catch(() => null);
2082
+ const askedAfter = Array.isArray(args["after"]) ? args["after"].map(String).filter(Boolean) : [];
2083
+ const asked = askedAfter.length ? askedAfter : saved?.after ?? [];
2084
+ const plan = asked.length ? await service.flowsAfter(projectId, asked) : null;
1963
2085
  const enabled = ["web", "android", "ios"].filter((p) => platforms[p]);
1964
2086
  const platform = str(args["platform"]) ?? (enabled.length === 1 ? enabled[0] : enabled.includes("web") ? "web" : void 0);
1965
2087
  if (!platform) throw new Error(`this project tests ${enabled.join(" and ")} \u2014 say which with platform`);
@@ -1970,10 +2092,10 @@ ${m.platform === "web" ? "Web pages" : "Android screens"} runs reached:`);
1970
2092
  ...isMobile(platform) ? { platform, ...device ? { device } : {} } : {},
1971
2093
  ...plan ? { after: plan.prerequisites, preparation: plan.steps } : {},
1972
2094
  name: str(args["name"]) ?? "Untitled flow",
1973
- ...feature ? { featureId: feature.id } : { featureName: featureAsked },
1974
- startPath: str(args["startPath"]) || (isMobile(platform) ? "" : "/"),
1975
- ...signIn?.profileId ? { signIn: { profileId: signIn.profileId, ...signIn.accountId ? { accountId: signIn.accountId } : {} } } : {}
1976
- }, catalog, { ...args["fresh"] === true ? { fresh: true } : {} });
2095
+ ...feature ? { featureId: feature.id } : saved ? { featureId: saved.featureId } : { featureName: featureAsked },
2096
+ startPath: str(args["startPath"]) || saved?.startPath || (isMobile(platform) ? "" : "/"),
2097
+ ...signIn?.profileId ? { signIn: { profileId: signIn.profileId, ...signIn.accountId ? { accountId: signIn.accountId } : {} } } : saved?.signIn ? { signIn: saved.signIn } : {}
2098
+ }, catalog, { ...args["fresh"] === true ? { fresh: true } : {}, ...saved ? { saved: saved.steps } : {} });
1977
2099
  const after = plan?.prerequisites.length ? ` It starts after ${plan.prerequisites.map((p) => `"${p.name}"`).join(" \u2192 ")}, which ran first.` : "";
1978
2100
  return text(`Started "${str(args["name"])}"${feature ? ` in ${feature.name}` : ` in a new feature, ${featureAsked}`}.${after} ${isMobile(platform) ? "The app is open on the phone." : "The browser is open."}
1979
2101
 
@@ -1990,37 +2112,60 @@ ${page}`);
1990
2112
  },
1991
2113
  {
1992
2114
  name: "act",
1993
- title: "Do one thing on the page",
1994
- description: `Perform one step and keep it in the flow if it worked. Returns the page afterwards. click/dblclick/rightclick/hover: role + name. fill: role + name + value (may use {{values}} from helpers). select: role + name + the option's label as value. check: role + name, value "true" or "false". press: a key such as "Enter" as value, optionally role + name of where. navigate: a path such as "/settings" or a full URL as value (on a phone, a deep link). back/forward: nothing. PHONE ONLY: tap (role + name), longPress (role + name), swipe (value up/down/left/right, optionally on role + name), scroll (to role + name, or value up/down), hideKeyboard, permission (value allow/deny), launchApp, terminateApp, resetApp, background (value: seconds), rotate (value portrait/landscape).`,
2115
+ title: "Do things on the page",
2116
+ description: `Perform a step \u2014 or several, in order, as steps: [...] \u2014 and keep each one that works. Stops at the first that fails; returns one line per step and the page afterwards. Batch steps whose elements are already on the page (filling a form, then submitting). click/dblclick/rightclick/hover: role + name. fill: role + name + value (may use {{values}} from helpers). select: role + name + the option's label as value. check: role + name, value "true" or "false". press: a key such as "Enter" as value, optionally role + name of where. navigate: a path such as "/settings" or a full URL as value (on a phone, a deep link). back/forward: nothing. PHONE ONLY: tap (role + name), longPress (role + name), swipe (value up/down/left/right, optionally on role + name), scroll (to role + name, or value up/down), hideKeyboard, permission (value allow/deny), launchApp, terminateApp, resetApp, background (value: seconds), rotate (value portrait/landscape).`,
1995
2117
  inputSchema: {
1996
2118
  type: "object",
1997
2119
  properties: {
1998
- action: { type: "string", enum: ["click", "dblclick", "rightclick", "hover", "fill", "select", "check", "press", "navigate", "back", "forward", "tap", "longPress", "swipe", "scroll", "hideKeyboard", "permission", "launchApp", "terminateApp", "resetApp", "background", "rotate"] },
1999
- role: { type: "string", description: "The element's role as read_page shows it, e.g. button, link, textbox, checkbox, combobox." },
2000
- name: { type: "string", description: "The element's accessible name, exactly as read_page shows it." },
2001
- within: { type: "string", description: "When the name is not unique: the name of the region, dialog, form or navigation that holds it." },
2002
- nth: { type: "integer", minimum: 0, description: "Which one, when several elements share this role and name: 0 is the first in the order read_page lists them (each shared name is marked [nth N of M]). Leave it out when the name is unique." },
2003
- value: { type: "string" },
2004
- secret: { type: "boolean", description: "fill only: the value is a secret (a password, PIN, key) \u2014 kept out of the flow and saved to the vault. Fields named like a password are secret without this." },
2005
- line: { type: "integer", minimum: 1, description: "The number of the test case's line (from start_case) this step carries out." }
2120
+ ...STEP_PROPERTIES,
2121
+ steps: { type: "array", maxItems: 25, items: { type: "object", properties: STEP_PROPERTIES, required: ["action"], additionalProperties: false }, description: "Several steps in order, instead of one." }
2006
2122
  },
2007
- required: ["action"],
2008
2123
  additionalProperties: false
2009
2124
  },
2010
2125
  run: async (args) => {
2011
- if (!isAction(args["action"])) throw new Error("action must be one of click, dblclick, rightclick, hover, fill, select, check, press, navigate, back, forward \u2014 or on a phone tap, longPress, swipe, scroll, hideKeyboard, permission, launchApp, terminateApp, resetApp, background, rotate");
2012
- const action = args["action"];
2013
- return tagged(args, async () => session.act({
2014
- action,
2015
- ...str(args["role"]) ? { role: str(args["role"]) } : {},
2016
- ...str(args["name"]) !== void 0 ? { name: str(args["name"]) } : {},
2017
- ...str(args["within"]) ? { within: str(args["within"]) } : {},
2018
- ...Number.isInteger(args["nth"]) && args["nth"] >= 0 ? { nth: args["nth"] } : {},
2019
- ...str(args["value"]) !== void 0 ? { value: str(args["value"]) } : {},
2020
- ...typeof args["secret"] === "boolean" ? { secret: args["secret"] } : {}
2021
- }));
2126
+ const stepOf = (raw) => {
2127
+ if (!isAction(raw["action"])) throw new Error("action must be one of click, dblclick, rightclick, hover, fill, select, check, press, navigate, back, forward \u2014 or on a phone tap, longPress, swipe, scroll, hideKeyboard, permission, launchApp, terminateApp, resetApp, background, rotate");
2128
+ return {
2129
+ action: raw["action"],
2130
+ ...str(raw["role"]) ? { role: str(raw["role"]) } : {},
2131
+ ...str(raw["name"]) !== void 0 ? { name: str(raw["name"]) } : {},
2132
+ ...str(raw["within"]) ? { within: str(raw["within"]) } : {},
2133
+ ...Number.isInteger(raw["nth"]) && raw["nth"] >= 0 ? { nth: raw["nth"] } : {},
2134
+ ...str(raw["value"]) !== void 0 ? { value: str(raw["value"]) } : {},
2135
+ ...typeof raw["secret"] === "boolean" ? { secret: raw["secret"] } : {}
2136
+ };
2137
+ };
2138
+ const batch = Array.isArray(args["steps"]) ? args["steps"].slice(0, 25) : null;
2139
+ if (!batch?.length) return tagged(args, async () => session.act(stepOf(args)));
2140
+ const said = [];
2141
+ let page = "";
2142
+ for (let i = 0; i < batch.length; i++) {
2143
+ const raw = batch[i];
2144
+ const what = `${String(raw["action"])}${str(raw["name"]) ? ` "${str(raw["name"])}"` : str(raw["value"]) && raw["action"] !== "fill" ? ` ${str(raw["value"])}` : ""}`;
2145
+ const before = session.keptCount();
2146
+ try {
2147
+ page = await session.act(stepOf(raw));
2148
+ const line = Number(raw["line"]);
2149
+ said.push(`${i + 1}. ${what} \u2014 kept${Number.isInteger(line) && line > 0 ? session.tagSince(before, line) : ""}`);
2150
+ } catch (error) {
2151
+ said.push(`${i + 1}. ${what} \u2014 FAILED: ${error.message}`);
2152
+ if (i < batch.length - 1) said.push(`Steps ${i + 2}\u2013${batch.length} were not done.`);
2153
+ break;
2154
+ }
2155
+ }
2156
+ return text(`${said.join("\n")}${page ? `
2157
+
2158
+ The page after the last kept step:
2159
+ ${page}` : ""}`);
2022
2160
  }
2023
2161
  },
2162
+ {
2163
+ name: "continue_flow",
2164
+ title: "Put back the waiting steps",
2165
+ description: "After start_flow reopened a saved flow and one step no longer worked: once you have done that step again the way the page now needs, put back the saved steps that came after it \u2014 replayed and kept as they pass, up to the next that fails.",
2166
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
2167
+ run: async () => text(await session.continueFlow())
2168
+ },
2024
2169
  {
2025
2170
  name: "add_check",
2026
2171
  title: "Add a check",
@@ -2336,8 +2481,10 @@ ${c.key ? `${c.key} ` : ""}${c.feature} \u203A ${c.name} (${c.id})`, ...c.busine
2336
2481
  const { cases, missing } = await service.readCases(projectId, refs);
2337
2482
  const parts = cases.map((c) => [
2338
2483
  `${c.key ? `${c.key} ` : ""}${c.folder} \u203A ${c.name} (${c.id}) \u2014 ${c.automated ? `automated, rows: ${c.rows.join(" \u2192 ")}` : "not automated yet"}`,
2484
+ ...c.manual ? [" MANUAL \u2014 a person runs this one by hand. Do not automate it."] : [],
2485
+ ...c.blocked ? [` BLOCKED last time (${c.blocked.kind}${c.blocked.line ? `, at line ${c.blocked.line}` : ""}): ${c.blocked.detail}${c.blocked.needs ? ` \u2014 needs: ${c.blocked.needs}` : ""}. Try again only if that has changed; otherwise report_blocker again.`] : [],
2339
2486
  ...c.description ? [` ${c.description}`] : [],
2340
- ...c.businessSteps.length ? c.businessSteps.map((s) => ` ${s.keyword} ${s.text}`) : [" (no business steps written)"]
2487
+ ...c.businessSteps.length ? c.businessSteps.map((s, i) => ` ${i + 1}. ${s.keyword} ${s.text}`) : [" (no business steps written)"]
2341
2488
  ].join("\n"));
2342
2489
  if (missing.length) parts.push(`Not found: ${missing.join(", ")} \u2014 project_catalog lists the test cases.`);
2343
2490
  return text(parts.join("\n\n") || "No test cases found.");
@@ -2463,10 +2610,43 @@ Next: create_account for each person who signs in this way.`);
2463
2610
  Use it with start_flow signIn {"profileId": "\u2026", "accountId": "${made.accountId}"}.`);
2464
2611
  }
2465
2612
  },
2613
+ {
2614
+ name: "report_blocker",
2615
+ title: "Say a test case cannot be automated",
2616
+ description: "Record that a test case could not be automated, and why \u2014 so it is marked Blocked on its page with the line it stopped at and what would unblock it, instead of looking untouched. kind: app-bug (the app itself is broken at that step), needs-person (a captcha, a real card, a code on somebody's own phone \u2014 something only a person has), unclear-step (a business step is unclear or does not match the app), not-found (the screen or feature cannot be reached), stuck (3 failed tries at the same step). Then move on to the next case. Saving the case automated later takes the mark off by itself.",
2617
+ inputSchema: {
2618
+ type: "object",
2619
+ properties: {
2620
+ projectId: { type: "string" },
2621
+ case: { type: "string", description: "The case's id, TC-\u2026 (read_test_cases gives it)." },
2622
+ kind: { type: "string", enum: ["app-bug", "needs-person", "unclear-step", "not-found", "stuck"] },
2623
+ line: { type: "number", description: "The business step it stopped at, counted from 1." },
2624
+ detail: { type: "string", description: "What happened, in a sentence or two: what you did and what the app did." },
2625
+ needs: { type: "string", description: 'What would unblock it, said for a person \u2014 e.g. "a helper for the SMS code", "the step should say which plan to pick".' },
2626
+ platform: { type: "string", enum: ["web", "android", "ios"] },
2627
+ branch: { type: "string", description: "Optional: the id of one of Claude's open branches the case is on." }
2628
+ },
2629
+ required: ["case", "kind", "detail"],
2630
+ additionalProperties: false
2631
+ },
2632
+ run: async (args) => {
2633
+ const projectId = await projectFor(args["projectId"]);
2634
+ const made = await service.reportBlocker(projectId, {
2635
+ case: str(args["case"]) ?? "",
2636
+ kind: str(args["kind"]) ?? "",
2637
+ detail: str(args["detail"]) ?? "",
2638
+ ...typeof args["line"] === "number" ? { line: args["line"] } : {},
2639
+ ...str(args["needs"]) ? { needs: str(args["needs"]) } : {},
2640
+ ...str(args["platform"]) ? { platform: str(args["platform"]) } : {},
2641
+ ...str(args["branch"]) ? { changeId: str(args["branch"]) } : {}
2642
+ });
2643
+ return text(`Marked ${made.key ?? made.caseId} Blocked \u2014 ${made.label}. A person sees it on the case with what it needs. Move on to the next case.`);
2644
+ }
2645
+ },
2466
2646
  {
2467
2647
  name: "create_helper",
2468
2648
  title: "Make a helper",
2469
- description: `A helper for things outside the page, as the Helpers screen makes one. config by kind \u2014 mailbox: {kind, host, port?, secure?, username, folder?, match?: {to, from, subject}, extract?: {take: code|link|subject|body, pattern}, waitSeconds?} with secretValues {passwordRef: the mailbox password}. totp: {kind, digits?, period?, algorithm?} with secretValues {secretRef: the base32 secret}. sms: {kind, accountSid, to, match?, extract?} with secretValues {authTokenRef: the Twilio token}. http: {kind, method, url, headers?, secretHeaders?: {name: ""}, body?, expectStatus?, extract?: {take: json|pattern|status|nothing, path, pattern}} with secretValues {"header:<name>": value}. Use {{name}} in fields for inputs a step supplies. Secrets are sealed in the vault and never shown again. Making the same name again updates Claude's earlier one.`,
2649
+ description: `A helper for things outside the page, as the Helpers screen makes one. config by kind \u2014 mailbox: {kind, host, port?, secure?, username, folder?, match?: {to, from, subject}, extract?: {take: code|link|subject|body, pattern}, waitSeconds?} with secretValues {passwordRef: the mailbox password}. totp: {kind, digits?, period?, algorithm?} with secretValues {secretRef: the base32 secret}. sms: {kind, accountSid, to, match?, extract?} with secretValues {authTokenRef: the Twilio token}. http: {kind, method, url, headers?, secretHeaders?: {name: ""}, body?, expectStatus?, extract?: {take: json|pattern|status|nothing, path, pattern}} with secretValues {"header:<name>": value}. command (runs on the tester's computer): {kind, run: {program, args: [..]}, perSystem?: {macos|windows|linux: {program, args}}, folder?, env?, secretEnv?: {NAME: ""}, mode?: run|background, readyPattern?, timeoutSeconds?, expectExit?, extract?: {take: output|pattern|json|nothing, pattern, path}} with secretValues {"env:<NAME>": value}; no shell \u2014 one program, its arguments a list. A command runs only after the person allows it on their computer (flowriki-agent allow "<name>"): when use_helper says it is not allowed, stop and tell them that exact command \u2014 never work around it. Use {{name}} in fields for inputs a step supplies. Secrets are sealed in the vault and never shown again. Making the same name again updates Claude's earlier one.`,
2470
2650
  inputSchema: {
2471
2651
  type: "object",
2472
2652
  properties: {
@@ -2496,7 +2676,7 @@ Use it with start_flow signIn {"profileId": "\u2026", "accountId": "${made.accou
2496
2676
  {
2497
2677
  name: "project_brief",
2498
2678
  title: "Read the project's memory",
2499
- description: "The project's own knowledge, in one call: About the product (product.md, and product/<web|android|ios>.md where a platform differs), memory/decisions.md, what each linked codebase folder was learned as and which platform's code it holds, what each platform looked like when explored (explore/<platform>.md), and the list of feature pages and session summaries. Call it first in every conversation. With platform and/or features, the brief is the slice for that job: that platform's part and exploration, those features' notes, the code tagged for the platform, and who can sign in.",
2679
+ description: "The project's own knowledge, in one call: About the product (product.md, and product/<web|android|ios>.md where a platform differs), memory/decisions.md, what each linked codebase folder was learned as and which platform's code it holds, what each platform looked like when explored (explore/<platform>.md), and the list of feature pages and session summaries. Call it at the start when no brief was given with the request (a given brief IS this). With platform and/or features, the brief is the slice for that job: that platform's part and exploration, those features' notes, the code tagged for the platform, and who can sign in.",
2500
2680
  inputSchema: { type: "object", properties: { projectId: { type: "string" }, platform: { type: "string", enum: ["web", "android", "ios"], description: "The platform the job is about." }, features: { type: "array", items: { type: "string" }, description: "The features (or case folders) the job is about, by name." } }, additionalProperties: false },
2501
2681
  annotations: { readOnlyHint: true },
2502
2682
  run: async (args) => {
@@ -2839,6 +3019,10 @@ async function runMcp(opts) {
2839
3019
  const connection = connectionFor(service);
2840
3020
  const session = connection ? new AuthoringSession(new ServiceClient(connection), opts.headless ? { headless: true } : {}) : null;
2841
3021
  let tools = connection && session ? authoringTools(new ServiceClient(connection), session) : authoringTools(null, null);
3022
+ const only = (process.env["FLOWRIKI_TOOLS"] ?? "").split(",").map((t) => t.trim()).filter(Boolean);
3023
+ if (only.length) tools = tools.filter((tool) => only.includes(tool.name));
3024
+ const except = (process.env["FLOWRIKI_TOOLS_EXCEPT"] ?? "").split(",").map((t) => t.trim()).filter(Boolean);
3025
+ if (except.length) tools = tools.filter((tool) => !except.includes(tool.name));
2842
3026
  if (!connection) {
2843
3027
  const why = `This computer is not connected to Flowriki at ${service}. Run \`flowriki-agent connect\` in a terminal, approve it in your browser, then restart Claude.`;
2844
3028
  tools = tools.map((tool) => ({ ...tool, run: async () => ({ content: [{ type: "text", text: why }], isError: true }) }));
@@ -2868,6 +3052,7 @@ async function runMcp(opts) {
2868
3052
  }
2869
3053
 
2870
3054
  // src/cli.ts
3055
+ var quoteArg = (arg) => /^[\w@%+=:,./-]+$/.test(arg) ? arg : JSON.stringify(arg);
2871
3056
  var paint = (code) => (s) => `\x1B[${code}m${s}\x1B[0m`;
2872
3057
  var bold = paint("1");
2873
3058
  var dim = paint("2");
@@ -2890,6 +3075,11 @@ ${bold("USAGE")}
2890
3075
  needs (Appium and its drivers)
2891
3076
  flowriki-agent mobile doctor is this computer ready to test phones
2892
3077
  flowriki-agent mobile devices the phones, emulators and simulators here
3078
+ flowriki-agent commands command helpers waiting to be allowed here,
3079
+ and the ones already allowed
3080
+ flowriki-agent allow <name> allow a command helper to run on this
3081
+ computer \u2014 it is shown to you first
3082
+ flowriki-agent revoke <name> stop allowing one
2893
3083
  flowriki-agent mcp Flowriki as tools for Claude \u2014 started by
2894
3084
  Claude Code or Claude Desktop, not by you:
2895
3085
  claude mcp add flowriki -- flowriki-agent mcp
@@ -2921,6 +3111,7 @@ async function main(argv) {
2921
3111
  service: { type: "string" },
2922
3112
  json: { type: "boolean" },
2923
3113
  "no-open": { type: "boolean" },
3114
+ yes: { type: "boolean", short: "y" },
2924
3115
  help: { type: "boolean", short: "h" },
2925
3116
  version: { type: "boolean", short: "v" }
2926
3117
  }
@@ -2959,6 +3150,8 @@ Run \`flowriki-agent --help\`.`);
2959
3150
  console.log(`
2960
3151
  ${bold("Approve this computer in your browser")}`);
2961
3152
  console.log(` ${url}
3153
+ `);
3154
+ if (note.detail?.["code"]) console.log(` ${bold("Your code:")} ${bold(String(note.detail["code"]))} ${dim("\u2014 the page asks for it")}
2962
3155
  `);
2963
3156
  if (note.detail?.["canOpen"] === false) {
2964
3157
  console.log(dim(" Open the approval link in your browser.\n"));
@@ -3025,11 +3218,16 @@ Run \`flowriki-agent --help\`.`);
3025
3218
  setInterval(() => void tellIfBehind(), 6 * 60 * 6e4).unref();
3026
3219
  let stopping = false;
3027
3220
  const shutdown = async (signal) => {
3028
- if (stopping) return;
3221
+ if (stopping) {
3222
+ console.log(dim(" stopping now"));
3223
+ process.exit(130);
3224
+ }
3029
3225
  stopping = true;
3030
3226
  console.log(dim(`
3031
- ${signal} \u2014 stopping`));
3032
- await started.stop();
3227
+ ${signal} \u2014 stopping (press Ctrl-C again to stop at once)`));
3228
+ const force = setTimeout(() => process.exit(1), 1e4);
3229
+ force.unref();
3230
+ await started.stop().catch(() => void 0);
3033
3231
  process.exit(0);
3034
3232
  };
3035
3233
  process.on("SIGINT", () => void shutdown("SIGINT"));
@@ -3132,6 +3330,69 @@ Run \`flowriki-agent --help\`.`);
3132
3330
  console.error(`unknown: mobile ${sub} \u2014 use setup, doctor or devices`);
3133
3331
  return 2;
3134
3332
  }
3333
+ // COMMAND HELPERS: written by anybody with the project, run on THIS
3334
+ // computer only once somebody sitting here has seen and allowed them.
3335
+ case "commands": {
3336
+ const { allowed, waiting } = readApprovals();
3337
+ const show = (c) => `${[c.program, ...c.args].map(quoteArg).join(" ")}${c.folder ? dim(` in ${c.folder}`) : ""}${c.mode === "background" ? dim(" (keeps running during the test)") : ""}`;
3338
+ if (values.json) {
3339
+ console.log(JSON.stringify({ allowed: Object.values(allowed), waiting: Object.values(waiting) }, null, 2));
3340
+ return 0;
3341
+ }
3342
+ console.log(bold("Waiting to be allowed"));
3343
+ if (!Object.keys(waiting).length) console.log(dim(" none"));
3344
+ for (const c of Object.values(waiting)) console.log(` ${yellow("\u2022")} ${c.name}
3345
+ ${show(c)}`);
3346
+ console.log(bold("\nAllowed on this computer"));
3347
+ if (!Object.keys(allowed).length) console.log(dim(" none"));
3348
+ for (const c of Object.values(allowed)) console.log(` ${green("\u2713")} ${c.name}
3349
+ ${show(c)}`);
3350
+ return 0;
3351
+ }
3352
+ case "allow":
3353
+ case "revoke": {
3354
+ const which = positionals.slice(1).join(" ").trim();
3355
+ if (!which) {
3356
+ console.error(`say which: flowriki-agent ${command} "<helper name>" (or all)`);
3357
+ return 2;
3358
+ }
3359
+ if (command === "revoke") {
3360
+ const gone = revokeCommands(which);
3361
+ console.log(gone.length ? `${green("no longer allowed:")} ${gone.map((c) => c.name).join(", ")}` : yellow(`nothing allowed matches "${which}"`));
3362
+ return gone.length ? 0 : 1;
3363
+ }
3364
+ const { waiting } = readApprovals();
3365
+ const wanted = which.toLowerCase();
3366
+ const matching = Object.values(waiting).filter((c) => wanted === "all" || c.fingerprint === wanted || c.name.toLowerCase() === wanted);
3367
+ if (!matching.length) {
3368
+ console.log(yellow(`nothing is waiting that matches "${which}". A command is listed here after a test or Try it asks to run it \u2014 see flowriki-agent commands.`));
3369
+ return 1;
3370
+ }
3371
+ for (const c of matching) {
3372
+ console.log(`${bold(c.name)}
3373
+ runs ${[c.program, ...c.args].map(quoteArg).join(" ")}
3374
+ in ${c.folder || "the project's folder"}${c.env.length ? `
3375
+ env ${c.env.join(", ")}` : ""}${c.mode === "background" ? "\n keeps running until the test ends" : ""}`);
3376
+ }
3377
+ if (!values.yes) {
3378
+ if (!process.stdin.isTTY) {
3379
+ console.error("add --yes to allow without being asked");
3380
+ return 2;
3381
+ }
3382
+ const { createInterface } = await import("readline/promises");
3383
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
3384
+ const answer = (await rl.question(`
3385
+ Allow ${matching.length === 1 ? "this command" : `these ${matching.length} commands`} to run on this computer? [y/N] `)).trim().toLowerCase();
3386
+ rl.close();
3387
+ if (answer !== "y" && answer !== "yes") {
3388
+ console.log("Not allowed.");
3389
+ return 1;
3390
+ }
3391
+ }
3392
+ const done = allowCommands(which);
3393
+ console.log(`${green("allowed:")} ${done.map((c) => c.name).join(", ")}. Tests and Try it can run ${done.length === 1 ? "it" : "them"} here now.`);
3394
+ return 0;
3395
+ }
3135
3396
  case "projects": {
3136
3397
  const { projects: found, skipped } = resolveCheckouts(projects.length ? projects : [process.cwd()]);
3137
3398
  for (const p of found) console.log(` ${p.name} ${dim(p.root)}`);