@flowriki/agent 0.1.0-beta.36 → 0.1.0-beta.39

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
@@ -20,9 +20,10 @@ import {
20
20
  resolveCheckouts,
21
21
  resolveService,
22
22
  runtimeValue,
23
+ similarity,
23
24
  start,
24
25
  status
25
- } from "./chunk-N3P4O3OH.js";
26
+ } from "./chunk-7CH5XDEE.js";
26
27
 
27
28
  // src/cli.ts
28
29
  import { parseArgs } from "util";
@@ -212,6 +213,102 @@ var ServiceClient = class {
212
213
  buildCase(projectId, spec, dryRun) {
213
214
  return this.call(`/api/agent/cases?projectId=${encodeURIComponent(projectId)}`, { method: "POST", body: JSON.stringify({ ...spec, dryRun }) });
214
215
  }
216
+ /** The test library, on Claude's working branch when it has one. */
217
+ library(projectId, opts = {}) {
218
+ const query = new URLSearchParams({ projectId, ...opts.q ? { q: opts.q } : {}, ...opts.featureId ? { featureId: opts.featureId } : {} });
219
+ return this.call(`/api/agent/library?${query}`);
220
+ }
221
+ /** Test cases written, edited or linked in one commit; duplicates skipped. */
222
+ saveLibrary(projectId, input) {
223
+ return this.call(`/api/agent/library?projectId=${encodeURIComponent(projectId)}`, { method: "POST", body: JSON.stringify(input) });
224
+ }
225
+ // ── Manual cycles and bugs: the dashboard's own routes, behind this
226
+ // machine's credential, so the assistant can do what a person can. ──────
227
+ scoped(path, projectId, extra = {}) {
228
+ const query = new URLSearchParams({ projectId, ...extra });
229
+ return `/api/agent${path}?${query}`;
230
+ }
231
+ cycles(projectId) {
232
+ return this.call(this.scoped("/cycles", projectId));
233
+ }
234
+ cycle(projectId, id) {
235
+ return this.call(this.scoped(`/cycles/${encodeURIComponent(id)}`, projectId));
236
+ }
237
+ createCycle(projectId, body, changeId) {
238
+ return this.call(this.scoped("/cycles", projectId, changeId ? { changeId } : {}), { method: "POST", body: JSON.stringify(body) });
239
+ }
240
+ updateCycle(projectId, id, body, changeId) {
241
+ return this.call(this.scoped(`/cycles/${encodeURIComponent(id)}`, projectId, changeId ? { changeId } : {}), { method: "PATCH", body: JSON.stringify(body) });
242
+ }
243
+ deleteCycle(projectId, id) {
244
+ return this.call(this.scoped(`/cycles/${encodeURIComponent(id)}`, projectId), { method: "DELETE" });
245
+ }
246
+ recordResult(projectId, id, caseId, body) {
247
+ return this.call(this.scoped(`/cycles/${encodeURIComponent(id)}/results/${encodeURIComponent(caseId)}`, projectId), { method: "PUT", body: JSON.stringify(body) });
248
+ }
249
+ addEvidenceLink(projectId, id, caseId, body) {
250
+ return this.call(this.scoped(`/cycles/${encodeURIComponent(id)}/results/${encodeURIComponent(caseId)}/evidence`, projectId), {
251
+ method: "POST",
252
+ body: JSON.stringify({ kind: "link", ...body })
253
+ });
254
+ }
255
+ bugs(projectId) {
256
+ return this.call(this.scoped("/bugs", projectId));
257
+ }
258
+ createBug(projectId, body) {
259
+ return this.call(this.scoped("/bugs", projectId), { method: "POST", body: JSON.stringify(body) });
260
+ }
261
+ updateBug(projectId, id, body) {
262
+ return this.call(this.scoped(`/bugs/${encodeURIComponent(id)}`, projectId), { method: "PATCH", body: JSON.stringify(body) });
263
+ }
264
+ // ── Knowledge, sources, requirements, integrations ──────────────────────
265
+ knowledgePages(projectId) {
266
+ return this.call(this.scoped("/knowledge", projectId));
267
+ }
268
+ knowledgePage(projectId, path) {
269
+ return this.call(this.scoped("/knowledge/page", projectId, { path }));
270
+ }
271
+ writeKnowledge(projectId, body) {
272
+ return this.call(`/api/agent/knowledge?projectId=${encodeURIComponent(projectId)}`, { method: "POST", body: JSON.stringify(body) });
273
+ }
274
+ search(projectId, q) {
275
+ return this.call(this.scoped("/search", projectId, { q }));
276
+ }
277
+ sources(projectId) {
278
+ return this.call(this.scoped("/sources", projectId));
279
+ }
280
+ source(projectId, id) {
281
+ return this.call(this.scoped(`/sources/${encodeURIComponent(id)}`, projectId));
282
+ }
283
+ addSource(projectId, body, sourceId) {
284
+ return this.call(
285
+ this.scoped(sourceId ? `/sources/${encodeURIComponent(sourceId)}/versions` : "/sources", projectId),
286
+ { method: "POST", body: JSON.stringify(body) }
287
+ );
288
+ }
289
+ requirements(projectId, opts = {}) {
290
+ return this.call(
291
+ this.scoped("/requirements", projectId, { ...opts.sourceId ? { sourceId: opts.sourceId } : {}, ...opts.state ? { state: opts.state } : {} })
292
+ );
293
+ }
294
+ deleteSource(projectId, id) {
295
+ return this.call(this.scoped(`/sources/${encodeURIComponent(id)}`, projectId), { method: "DELETE" });
296
+ }
297
+ updateRequirement(projectId, id, body) {
298
+ return this.call(this.scoped(`/requirements/${encodeURIComponent(id)}`, projectId), { method: "PATCH", body: JSON.stringify(body) });
299
+ }
300
+ refreshBug(projectId, id) {
301
+ return this.call(this.scoped(`/bugs/${encodeURIComponent(id)}/refresh`, projectId), { method: "POST" });
302
+ }
303
+ integrations(projectId) {
304
+ return this.call(this.scoped("/integrations", projectId));
305
+ }
306
+ syncIntegration(projectId, id) {
307
+ return this.call(this.scoped(`/integrations/${encodeURIComponent(id)}/sync`, projectId), { method: "POST" });
308
+ }
309
+ pushBug(projectId, bugId, integrationId) {
310
+ return this.call(this.scoped(`/bugs/${encodeURIComponent(bugId)}/push`, projectId), { method: "POST", body: JSON.stringify({ integrationId }) });
311
+ }
215
312
  /** Gather Claude's open branches here into one. */
216
313
  gatherChanges(projectId, opts = {}) {
217
314
  return this.call(
@@ -263,6 +360,26 @@ function labelTree(tree, fields) {
263
360
  for (const f of fields) out = out.split(`- ${f.role} ${JSON.stringify(f.hint)}`).join(`- ${f.role} ${JSON.stringify(f.label)}`);
264
361
  return out;
265
362
  }
363
+ function numberDuplicates(tree) {
364
+ const line = /^(\s*- )([\w-]+) "((?:[^"\\]|\\.)*)"(.*)$/;
365
+ const totals = /* @__PURE__ */ new Map();
366
+ const lines = tree.split("\n");
367
+ for (const l of lines) {
368
+ const m = line.exec(l);
369
+ if (m) totals.set(`${m[2]}|${m[3]}`, (totals.get(`${m[2]}|${m[3]}`) ?? 0) + 1);
370
+ }
371
+ const seen = /* @__PURE__ */ new Map();
372
+ return lines.map((l) => {
373
+ const m = line.exec(l);
374
+ if (!m) return l;
375
+ const key = `${m[2]}|${m[3]}`;
376
+ const total = totals.get(key) ?? 1;
377
+ if (total < 2) return l;
378
+ const at = seen.get(key) ?? 0;
379
+ seen.set(key, at + 1);
380
+ return `${l} [nth ${at} of ${total} with this name]`;
381
+ }).join("\n");
382
+ }
266
383
  function describeRows(rows) {
267
384
  return rows.map((r) => [
268
385
  `Row ${r.row}: "${r.flow}" (${r.browser})`,
@@ -270,7 +387,7 @@ function describeRows(rows) {
270
387
  ].join("\n")).join("\n");
271
388
  }
272
389
  function describe(step) {
273
- const target = step.name ? `${step.role || "element"} "${step.name}"` : step.role || "";
390
+ const target = (step.name ? `${step.role || "element"} "${step.name}"` : step.role || "") + (step.nth !== void 0 ? ` (nth ${step.nth})` : "");
274
391
  const where = step.scope ? ` in "${step.scope}"` : step.scopeRole ? ` in the ${step.scopeRole}` : "";
275
392
  switch (step.type) {
276
393
  case "fill":
@@ -421,10 +538,10 @@ var AuthoringSession = class {
421
538
  async view(full) {
422
539
  const { page, draft } = this.requireOpen();
423
540
  await page.waitForLoadState("domcontentloaded", { timeout: 1e4 }).catch(() => void 0);
424
- const tree = labelTree(
541
+ const tree = numberDuplicates(labelTree(
425
542
  await page.locator("body").ariaSnapshot({ timeout: 8e3 }).catch((e) => `(could not read the page: ${e.message})`),
426
543
  await fieldNames(page)
427
- );
544
+ ));
428
545
  const title = await page.title().catch(() => "");
429
546
  const head = `Page: ${page.url()}${title ? ` \u2014 "${title}"` : ""}
430
547
  Steps so far: ${draft.steps.length}
@@ -473,6 +590,7 @@ ${removed.join("\n")}` : "",
473
590
  type: input.action,
474
591
  ...target,
475
592
  ...scopeOf(input.within),
593
+ ...input.nth !== void 0 ? { nth: input.nth } : {},
476
594
  value: secret ? "" : input.action === "check" ? input.value === "false" ? "false" : "true" : input.value ?? "",
477
595
  secret,
478
596
  ...secret ? { secretRef: `${TYPED}${id}` } : {},
@@ -494,7 +612,9 @@ ${removed.join("\n")}` : "",
494
612
  });
495
613
  } catch (error) {
496
614
  if (secret) this.typed.delete(`${TYPED}${id}`);
497
- throw new Error(`That did not work, and was not kept: ${error.message.split("\n")[0]}
615
+ const said = error.message.split("\n")[0] ?? "";
616
+ const several = /strict mode violation|resolved to \d+ elements|\d+ elements match/i.test(said) ? "\nSeveral elements share this role and name. Pass nth (0 = the first, in the order read_page lists them \u2014 each shared name is marked [nth N of M]) to say which one." : "";
617
+ throw new Error(`That did not work, and was not kept: ${said}${several}
498
618
 
499
619
  ${await this.view(false)}`);
500
620
  }
@@ -515,7 +635,7 @@ ${await this.view(false)}`;
515
635
  value: input.value,
516
636
  enabled: true,
517
637
  ...input.op && input.op !== "equals" ? { op: input.op } : {},
518
- ...input.role ? { target: { ...asLabelled({ role: input.role, name: input.name ?? "" }, await fieldNames(page)), ...scopeOf(input.within) } } : {}
638
+ ...input.role ? { target: { ...asLabelled({ role: input.role, name: input.name ?? "" }, await fieldNames(page)), ...scopeOf(input.within), ...input.nth !== void 0 ? { nth: input.nth } : {} } } : {}
519
639
  });
520
640
  const filled = (text2) => text2.replace(/\{\{([a-zA-Z][a-zA-Z0-9_]*)\}\}/g, (_, name) => runtimeValue(this.values, name));
521
641
  const expected = Array.isArray(assertion.value) ? assertion.value.map(filled) : filled(assertion.value);
@@ -646,7 +766,7 @@ ${lines.join("\n")}`;
646
766
  const note = others.length ? `
647
767
  Claude has ${others.length} other open branch${others.length === 1 ? "" : "es"} on this project \u2014 gather_branches puts them into this one, for one review.` : "";
648
768
  const after = draft.after?.length ? ` It starts after ${draft.after.map((a) => `"${a.name}"`).join(", ")}.` : "";
649
- return `${what} ${where_}.${after}${vault} Its ${checkCount} check${checkCount === 1 ? " is" : "s are"} saved switched off \u2014 a test case switches on the ones it is about. Nothing is published yet.
769
+ return `${what} ${where_}.${after}${vault} Its ${checkCount} check${checkCount === 1 ? " is" : "s are"} saved switched off \u2014 an automation switches on the ones it is about. Nothing is published yet.
650
770
  Review and publish it here: ${review}${note}`;
651
771
  }
652
772
  /** A test case built from flows, without saving: its rows and every check,
@@ -729,7 +849,7 @@ THE MODEL \u2014 get this right before recording anything:
729
849
  something different.
730
850
  - A flow records checks (add_check) that prove each thing happened, but they
731
851
  are SAVED SWITCHED OFF: which ones matter depends on the test.
732
- - A TEST CASE is the test. It strings flows together as rows (in the same
852
+ - An AUTOMATION is the test. It strings flows together as rows (in the same
733
853
  browser, or a new one for a second person) and switches on the checks it is
734
854
  about. Build it with preview_case, then save_case \u2014 which runs it first.
735
855
  - SIGN-IN is shared setup: a flow or case that needs somebody signed in uses a
@@ -740,6 +860,10 @@ THE MODEL \u2014 get this right before recording anything:
740
860
  login flow types into its fields; the account supplies the values.
741
861
  - HELPERS (an inbox for email codes, an authenticator, SMS, an API call) are made
742
862
  with create_helper when the catalog has none that fits.
863
+ - SEVERAL ELEMENTS WITH THE SAME NAME (two "Proceed to Checkout" buttons, twelve
864
+ "Select & Customize") are told apart by POSITION, like an XPath index: read_page
865
+ marks each one [nth N of M], and act / add_check take nth (0 = the first). Use it
866
+ as soon as a name is shared \u2014 there is no need to look for another way round.
743
867
  - PASSWORDS AND KEYS never go into a flow or a reply. Typed into a password
744
868
  field, a value is kept as a secret and saved to the project's vault; given to
745
869
  create_account or create_helper, it is sealed there too and never shown back.
@@ -750,11 +874,52 @@ THE MODEL \u2014 get this right before recording anything:
750
874
  "Ordering \u2013 Pizza builder", "Checkout \u2013 Payment". Flows (features) and the
751
875
  cases built from them use the same names. Keep one area per folder.
752
876
 
877
+ THE TEST LIBRARY is what the team checks, written down: test cases (TC-1,
878
+ TC-2\u2026) with steps, expected results, priority and status. An automation is how
879
+ a test case is checked by a machine, and each test case lists the automations
880
+ that check it. Read it with library_cases before deciding what to automate \u2014
881
+ the important cases nothing checks yet come first. Write cases with
882
+ save_test_cases (a case the library already has is skipped, so writing the
883
+ same thing twice is harmless), and after save_case, link the automation to the
884
+ test cases it proves with link_test_case.
885
+
886
+ KNOWLEDGE: project_brief is the project's memory \u2014 read it first. Search
887
+ everything (knowledge pages, sources, requirements, test cases) with
888
+ knowledge_search; read a page with knowledge_read; read source documents with
889
+ sources. Keep what you learn: write_knowledge writes product.md, glossary.md,
890
+ features/<name>.md, memory/decisions.md or a session summary, on Claude's
891
+ branch for review \u2014 never a password, key or token.
892
+
893
+ FROM DOCUMENTS TO TESTS: sources are PRDs, CRs, Jira stories, Figma frames.
894
+ requirements lists what was found in them and what the latest version changed
895
+ (new / changed / removed). For a new requirement: write test cases that cover
896
+ it (save_test_cases with covers: [requirement id]) \u2014 but first check
897
+ find_similar_cases, and link an existing case instead of writing a duplicate.
898
+ For a changed one: update the cases that cover it. For a removed one: mark
899
+ cases that only covered it deprecated. Never invent requirements.
900
+
901
+ AUTOMATING A TEST CASE: first write the plan with save_automation_plan \u2014 flows
902
+ to reuse, flows to record, which checks prove each expected result, accounts
903
+ and helpers it needs, and anything that cannot be automated and why. Show it
904
+ and wait for the person to approve before recording. Then record, save_case,
905
+ and link_test_case.
906
+
907
+ INTEGRATIONS: integrations lists Jira, Linear, GitHub, Figma and Confluence
908
+ connections; sync_integration reads them as sources. push_bug raises a bug in
909
+ a tracker \u2014 it reaches outside Flowriki, so only after the person says to.
910
+
911
+ MANUAL TESTING: a test cycle is a set of library cases a person runs by hand
912
+ ("Release 4.2 regression"); each case is marked passed, failed, blocked or
913
+ skipped, step by step, with a bug raised from the step that broke. Read them
914
+ with test_cycles and bugs; make and change cycles with manage_cycle; record
915
+ what happened with record_result; raise or update bugs with save_bug. Never
916
+ mark a case passed on a person's behalf without being told it passed.
917
+
753
918
  HOW TO WORK:
754
919
  1. Call project_catalog. Reuse what exists \u2014 published, and on Claude's working
755
920
  branch \u2014 rather than writing it again.
756
921
  2. Before recording, say the plan to the person: the folders, the building-block
757
- flows (and what each starts after), and the test cases (rows + what each
922
+ flows (and what each starts after), and the automations (rows + what each
758
923
  checks). Then record the blocks, earliest first.
759
924
  3. Record with start_flow (pass "after" for the flows it continues from), read
760
925
  the page with read_page, and act using the role and name EXACTLY as the page
@@ -765,7 +930,7 @@ HOW TO WORK:
765
930
  catalog, then type {{its saveAs name}} into the field that needs it.
766
931
  5. Add checks for what each step proves (a heading, a message, a value, the
767
932
  URL). replay_flow, then save_flow. A flow must record at least one check.
768
- 6. Build each test case: preview_case lists its rows and every check, numbered;
933
+ 6. Build each automation: preview_case lists its rows and every check, numbered;
769
934
  save_case with the numbers to switch on runs the whole case and saves it only
770
935
  if it passed. Give the person the review link.
771
936
  7. Saving the same name again replaces it on the working branch. Old branches
@@ -780,7 +945,7 @@ A test that types a fixed new email passes once and fails every run after.`;
780
945
  var CASE_INPUT = {
781
946
  type: "object",
782
947
  properties: {
783
- name: { type: "string", description: 'The test case: the scenario it proves, e.g. "Wrong password is refused".' },
948
+ name: { type: "string", description: 'The automation: the scenario it proves, e.g. "Wrong password is refused".' },
784
949
  folder: { type: "string", description: 'The case folder \u2014 area and topic, e.g. "Account \u2013 Sign in". Made if new.' },
785
950
  description: { type: "string" },
786
951
  rows: {
@@ -979,6 +1144,7 @@ ${page}`);
979
1144
  role: { type: "string", description: "The element's role as read_page shows it, e.g. button, link, textbox, checkbox, combobox." },
980
1145
  name: { type: "string", description: "The element's accessible name, exactly as read_page shows it." },
981
1146
  within: { type: "string", description: "When the name is not unique: the name of the region, dialog, form or navigation that holds it." },
1147
+ 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." },
982
1148
  value: { type: "string" },
983
1149
  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." }
984
1150
  },
@@ -992,6 +1158,7 @@ ${page}`);
992
1158
  ...str(args["role"]) ? { role: str(args["role"]) } : {},
993
1159
  ...str(args["name"]) !== void 0 ? { name: str(args["name"]) } : {},
994
1160
  ...str(args["within"]) ? { within: str(args["within"]) } : {},
1161
+ ...Number.isInteger(args["nth"]) && args["nth"] >= 0 ? { nth: args["nth"] } : {},
995
1162
  ...str(args["value"]) !== void 0 ? { value: str(args["value"]) } : {},
996
1163
  ...typeof args["secret"] === "boolean" ? { secret: args["secret"] } : {}
997
1164
  }));
@@ -1009,7 +1176,8 @@ ${page}`);
1009
1176
  op: { type: "string", enum: ["equals", "contains", "matches"] },
1010
1177
  role: { type: "string" },
1011
1178
  name: { type: "string" },
1012
- within: { type: "string" }
1179
+ within: { type: "string" },
1180
+ 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])." }
1013
1181
  },
1014
1182
  required: ["kind", "value"],
1015
1183
  additionalProperties: false
@@ -1019,7 +1187,8 @@ ${page}`);
1019
1187
  value: str(args["value"]) ?? "",
1020
1188
  ...str(args["op"]) ? { op: str(args["op"]) } : {},
1021
1189
  ...str(args["role"]) ? { role: str(args["role"]), name: str(args["name"]) ?? "" } : {},
1022
- ...str(args["within"]) ? { within: str(args["within"]) } : {}
1190
+ ...str(args["within"]) ? { within: str(args["within"]) } : {},
1191
+ ...Number.isInteger(args["nth"]) && args["nth"] >= 0 ? { nth: args["nth"] } : {}
1023
1192
  }))
1024
1193
  },
1025
1194
  {
@@ -1143,8 +1312,8 @@ ${page}`);
1143
1312
  },
1144
1313
  {
1145
1314
  name: "preview_case",
1146
- title: "Preview a test case",
1147
- description: "Build a test case from flows without saving or running it: its rows (each flow with what it starts after folded in, and which browser it runs in) and every check, numbered, with which are on. Use the numbers in save_case.",
1315
+ title: "Preview an automation",
1316
+ description: "Build an automation from flows without saving or running it: its rows (each flow with what it starts after folded in, and which browser it runs in) and every check, numbered, with which are on. Use the numbers in save_case.",
1148
1317
  inputSchema: CASE_INPUT,
1149
1318
  annotations: { readOnlyHint: true },
1150
1319
  run: async (args) => {
@@ -1154,8 +1323,8 @@ ${page}`);
1154
1323
  },
1155
1324
  {
1156
1325
  name: "save_case",
1157
- title: "Run and save a test case",
1158
- description: "Build the test case, run it from the start in a fresh browser exactly as a test run will, and \u2014 only if every row passed \u2014 save it onto Claude's working branch. Saving the same name in the same folder again replaces it. Refused if it switches on no checks.",
1326
+ title: "Run and save an automation",
1327
+ description: "Build the automation, run it from the start in a fresh browser exactly as a test run will, and \u2014 only if every row passed \u2014 save it onto Claude's working branch. Saving the same name in the same folder again replaces it. Refused if it switches on no checks.",
1159
1328
  inputSchema: CASE_INPUT,
1160
1329
  annotations: { openWorldHint: true },
1161
1330
  run: async (args) => {
@@ -1168,7 +1337,7 @@ ${page}`);
1168
1337
  {
1169
1338
  name: "create_sign_in",
1170
1339
  title: "Make a sign-in profile",
1171
- description: "Turn a saved login flow into a sign-in profile that flows and test cases can start signed in with. The flow must not itself sign in or start after other flows, and must end with a check proving the login worked \u2014 its last check is switched on here. Returns the fields an account fills in. Making the same name again replaces Claude's earlier one.",
1340
+ description: "Turn a saved login flow into a sign-in profile that flows and automations can start signed in with. The flow must not itself sign in or start after other flows, and must end with a check proving the login worked \u2014 its last check is switched on here. Returns the fields an account fills in. Making the same name again replaces Claude's earlier one.",
1172
1341
  inputSchema: {
1173
1342
  type: "object",
1174
1343
  properties: {
@@ -1200,7 +1369,7 @@ Next: create_account for each person who signs in this way.`);
1200
1369
  {
1201
1370
  name: "create_account",
1202
1371
  title: "Make a sign-in account",
1203
- description: "An account for a sign-in profile: who signs in, and the values its login flow types, by field label (create_sign_in and project_catalog list them). Secret fields are sealed in the project's vault and never shown again. Then start_flow / test cases can use signIn {profileId, accountId}. Making the same name again updates Claude's earlier one.",
1372
+ description: "An account for a sign-in profile: who signs in, and the values its login flow types, by field label (create_sign_in and project_catalog list them). Secret fields are sealed in the project's vault and never shown again. Then start_flow / automations can use signIn {profileId, accountId}. Making the same name again updates Claude's earlier one.",
1204
1373
  inputSchema: {
1205
1374
  type: "object",
1206
1375
  properties: {
@@ -1259,6 +1428,595 @@ Use it with start_flow signIn {"profileId": "\u2026", "accountId": "${made.accou
1259
1428
  return text(`${made.replaced ? "Updated" : "Made"} the ${made.kind} helper "${made.name}" (id ${made.helperId}) on Claude's working branch ${made.branch}.${made.inputs.length ? ` A step supplies ${made.inputs.map((n) => `{{${n}}}`).join(", ")}.` : ""} Use it with use_helper.`);
1260
1429
  }
1261
1430
  },
1431
+ {
1432
+ name: "library_cases",
1433
+ title: "Read the test library",
1434
+ description: "The project's test cases \u2014 what the team checks \u2014 with their key (TC-n), priority, status, steps and expected results, and which automations check each one. Optional q (title, key or tag) and featureId narrow it. Read before planning automations: cases with priority P0/P1 and no automation are the gaps.",
1435
+ inputSchema: {
1436
+ type: "object",
1437
+ properties: {
1438
+ projectId: { type: "string" },
1439
+ q: { type: "string", description: "Words in the title, a key like TC-12, or a tag." },
1440
+ featureId: { type: "string", description: "Only this feature's cases, e.g. f_checkout." }
1441
+ },
1442
+ additionalProperties: false
1443
+ },
1444
+ annotations: { readOnlyHint: true },
1445
+ run: async (args) => {
1446
+ const projectId = await projectFor(args["projectId"]);
1447
+ const { cases, total } = await service.library(projectId, {
1448
+ ...str(args["q"]) ? { q: str(args["q"]) } : {},
1449
+ ...str(args["featureId"]) ? { featureId: str(args["featureId"]) } : {}
1450
+ });
1451
+ if (cases.length === 0) return text(total ? "No test case matches." : "The test library is empty. Write cases with save_test_cases.");
1452
+ const lines = cases.map((c) => {
1453
+ const automated = c.automation.links.length ? `automated by ${c.automation.links.map((l) => l.automationId).join(", ")}${c.automation.links.some((l) => l.caseRevision < c.revision) ? " (case changed since \u2014 check it still matches)" : ""}` : c.automation.state === "not-automatable" ? `manual only: ${c.automation.reason ?? ""}` : c.automation.state === "planned" ? "automation planned" : "NOT AUTOMATED";
1454
+ const steps = c.steps.map((st, i) => ` ${i + 1}. ${st.action}${st.data ? ` [${st.data}]` : ""}${st.expected ? ` \u2192 ${st.expected}` : ""}`).join("\n");
1455
+ return `${c.key} (id ${c.id}, revision ${c.revision}) ${c.title} \u2014 ${c.featureId}, ${c.priority}, ${c.status}, ${automated}${c.preconditions.length ? `
1456
+ Preconditions: ${c.preconditions.join("; ")}` : ""}${steps ? `
1457
+ ${steps}` : ""}`;
1458
+ });
1459
+ return text(`${cases.length} of ${total} test cases:
1460
+ ${lines.join("\n")}`);
1461
+ }
1462
+ },
1463
+ {
1464
+ name: "save_test_cases",
1465
+ title: "Write test cases",
1466
+ description: `Add test cases to the library, or edit existing ones, in one commit on Claude's working branch. create: [{feature: "Checkout" (or featureId), title, priority?: P0|P1|P2|P3, type?: functional|negative|edge|regression|smoke|accessibility|other, status?: draft|ready|deprecated, preconditions?: [..], steps: [{action, data?, expected?}], tags?: [..], description?}]. update: [{id, revision (from library_cases), ...only the fields to change}]. A new case the library already has is skipped and named in the answer \u2014 never duplicated.`,
1467
+ inputSchema: {
1468
+ type: "object",
1469
+ properties: {
1470
+ projectId: { type: "string" },
1471
+ create: { type: "array", items: { type: "object" } },
1472
+ update: { type: "array", items: { type: "object" } },
1473
+ branch: { type: "string", description: "Optional: the id of one of Claude's open branches to write to (list_my_branches). Default: the working branch." }
1474
+ },
1475
+ additionalProperties: false
1476
+ },
1477
+ run: async (args) => {
1478
+ const projectId = await projectFor(args["projectId"]);
1479
+ const featureOf = (item) => {
1480
+ const id = str(item["featureId"]);
1481
+ if (id) return id;
1482
+ const name = str(item["feature"]) ?? "General";
1483
+ return `f_${name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "general"}`;
1484
+ };
1485
+ const create = (Array.isArray(args["create"]) ? args["create"] : []).map((raw) => {
1486
+ const { feature: _feature, ...item } = raw;
1487
+ return { ...item, featureId: featureOf(raw) };
1488
+ });
1489
+ const update = (Array.isArray(args["update"]) ? args["update"] : []).map((raw) => {
1490
+ const item = raw;
1491
+ if (item["feature"] === void 0) return item;
1492
+ const { feature: _feature, ...rest } = item;
1493
+ return { ...rest, featureId: featureOf(item) };
1494
+ });
1495
+ const saved = await service.saveLibrary(projectId, {
1496
+ create,
1497
+ update,
1498
+ message: "Test cases written with Claude",
1499
+ ...str(args["branch"]) ? { changeId: str(args["branch"]) } : {}
1500
+ });
1501
+ const made = saved.created.map((c) => `${c.key} ${c.title} (id ${c.id})`);
1502
+ const edited = saved.updated.map((c) => `${c.key} ${c.title}`);
1503
+ return text([
1504
+ made.length ? `Added ${made.length}: ${made.join("; ")}.` : "",
1505
+ edited.length ? `Updated ${edited.length}: ${edited.join("; ")}.` : "",
1506
+ saved.duplicates.length ? `Skipped ${saved.duplicates.length} already in the library: ${saved.duplicates.map((d) => `"${d.title}" is ${d.matches}`).join("; ")}.` : "",
1507
+ `On Claude's working branch ${saved.branch}.`
1508
+ ].filter(Boolean).join(" "));
1509
+ }
1510
+ },
1511
+ {
1512
+ name: "link_test_case",
1513
+ title: "Link an automation to a test case",
1514
+ description: "Record that an automation (a case id from save_case or project_catalog) checks a library test case (its key like TC-12, or its id). unlink: true removes the link. Do this after save_case for every test case the automation proves.",
1515
+ inputSchema: {
1516
+ type: "object",
1517
+ properties: {
1518
+ projectId: { type: "string" },
1519
+ testCase: { type: "string", description: "TC-12, or the test case id." },
1520
+ automationId: { type: "string" },
1521
+ unlink: { type: "boolean" },
1522
+ branch: { type: "string" }
1523
+ },
1524
+ required: ["testCase", "automationId"],
1525
+ additionalProperties: false
1526
+ },
1527
+ run: async (args) => {
1528
+ const projectId = await projectFor(args["projectId"]);
1529
+ const wanted = str(args["testCase"]) ?? "";
1530
+ const automationId = str(args["automationId"]) ?? "";
1531
+ const { cases } = await service.library(projectId);
1532
+ const target = cases.find((c) => c.id === wanted || c.key.toLowerCase() === wanted.toLowerCase());
1533
+ if (!target) return text(`No test case ${wanted} in the library \u2014 library_cases lists them.`);
1534
+ const has = target.automation.links.some((l) => l.automationId === automationId);
1535
+ const unlink = args["unlink"] === true;
1536
+ if (has !== unlink) return text(`${target.key} is ${unlink ? "not" : "already"} linked to ${automationId}.`);
1537
+ const links = unlink ? target.automation.links.filter((l) => l.automationId !== automationId) : [...target.automation.links, { automationId, caseRevision: target.revision + 1, at: (/* @__PURE__ */ new Date()).toISOString(), by: "Claude" }];
1538
+ const state = links.length ? "automated" : target.automation.state === "automated" ? "none" : target.automation.state;
1539
+ const saved = await service.saveLibrary(projectId, {
1540
+ update: [{ id: target.id, revision: target.revision, automation: { ...target.automation, state, links } }],
1541
+ message: `${unlink ? "Unlink" : "Link"} ${target.key} ${unlink ? "from" : "to"} ${automationId}`,
1542
+ ...str(args["branch"]) ? { changeId: str(args["branch"]) } : {}
1543
+ });
1544
+ return text(`${unlink ? "Unlinked" : "Linked"} ${target.key} "${target.title}" ${unlink ? "from" : "to"} automation ${automationId}, on Claude's working branch ${saved.branch}.`);
1545
+ }
1546
+ },
1547
+ {
1548
+ name: "project_brief",
1549
+ title: "Read the project's memory",
1550
+ description: "The project's own knowledge, in one call: product.md, glossary.md, memory/decisions.md, the list of feature pages and session summaries, the codebase summary's header, and the sources and integrations it has. Call it first in every conversation.",
1551
+ inputSchema: { type: "object", properties: { projectId: { type: "string" } }, additionalProperties: false },
1552
+ annotations: { readOnlyHint: true },
1553
+ run: async (args) => {
1554
+ const projectId = await projectFor(args["projectId"]);
1555
+ const [{ pages }, { sources }, { integrations }] = await Promise.all([
1556
+ service.knowledgePages(projectId),
1557
+ service.sources(projectId).catch(() => ({ sources: [] })),
1558
+ service.integrations(projectId).catch(() => ({ integrations: [] }))
1559
+ ]);
1560
+ const read = async (path) => pages.some((p) => p.path === path) ? (await service.knowledgePage(projectId, path)).content : null;
1561
+ const [product, glossary, decisions, codebase] = await Promise.all(["product.md", "glossary.md", "memory/decisions.md", "codebase.json"].map(read));
1562
+ const parts = [
1563
+ product ? `## product.md
1564
+ ${product}` : "No product.md yet \u2014 write one with write_knowledge once you understand the product.",
1565
+ glossary ? `## glossary.md
1566
+ ${glossary}` : "",
1567
+ decisions ? `## memory/decisions.md
1568
+ ${decisions}` : "",
1569
+ codebase ? `## codebase.json (first 1500 characters)
1570
+ ${codebase.slice(0, 1500)}` : "No codebase summary yet.",
1571
+ `## Other pages
1572
+ ${pages.filter((p) => !["product.md", "glossary.md", "memory/decisions.md", "codebase.json"].includes(p.path)).map((p) => `- ${p.path}`).join("\n") || "none"}`,
1573
+ `## Sources
1574
+ ${sources.map((s) => `- ${s.title} (id ${s.id}, v${s.version}, ${s.kind}) \u2014 requirements: ${s.requirements["new"] ?? 0} new, ${s.requirements["changed"] ?? 0} changed, ${s.requirements["active"] ?? 0} unchanged, ${s.requirements["removed"] ?? 0} removed`).join("\n") || "none"}`,
1575
+ `## Integrations
1576
+ ${integrations.map((i) => `- ${i.name} (${i.platform}, id ${i.id}, ${i.status}${i.lastSyncAt ? `, last read ${i.lastSyncAt}` : ""})`).join("\n") || "none"}`
1577
+ ];
1578
+ return text(parts.filter(Boolean).join("\n\n"));
1579
+ }
1580
+ },
1581
+ {
1582
+ name: "knowledge_search",
1583
+ title: "Search everything the project knows",
1584
+ description: "Search knowledge pages, source documents, requirements and test cases at once. Returns where each hit is, so it can be cited ([[knowledge:path]], [[source:id@vN]], [[requirement:id]], [[testCase:TC-n]]).",
1585
+ inputSchema: { type: "object", properties: { projectId: { type: "string" }, q: { type: "string" } }, required: ["q"], additionalProperties: false },
1586
+ annotations: { readOnlyHint: true },
1587
+ run: async (args) => {
1588
+ const projectId = await projectFor(args["projectId"]);
1589
+ const { hits } = await service.search(projectId, str(args["q"]) ?? "");
1590
+ if (!hits.length) return text("Nothing found.");
1591
+ return text(hits.map((h) => `[[${h.kind}:${h.ref}]] ${h.title}
1592
+ ${h.snippet}`).join("\n"));
1593
+ }
1594
+ },
1595
+ {
1596
+ name: "knowledge_read",
1597
+ title: "Read a knowledge page",
1598
+ description: "Read one knowledge page by path (product.md, glossary.md, features/<name>.md, memory/\u2026, codebase.json, index.json).",
1599
+ inputSchema: { type: "object", properties: { projectId: { type: "string" }, path: { type: "string" } }, required: ["path"], additionalProperties: false },
1600
+ annotations: { readOnlyHint: true },
1601
+ run: async (args) => {
1602
+ const projectId = await projectFor(args["projectId"]);
1603
+ const page = await service.knowledgePage(projectId, str(args["path"]) ?? "");
1604
+ return text(page.content);
1605
+ }
1606
+ },
1607
+ {
1608
+ name: "write_knowledge",
1609
+ title: "Write a knowledge page",
1610
+ description: "Write what the project should remember, on Claude's working branch for review: product.md (what the app is, roles, environments), glossary.md, features/<name>.md (screens, rules, quirks), memory/decisions.md (decisions and conventions), memory/sessions/<yyyy-mm-dd>-<topic>.md (what a job did), codebase.json (the codebase summary: routes, screens, forms, roles, APIs, and the commit read). append: true adds to the end instead of replacing. Never a password, key or token \u2014 those are refused.",
1611
+ inputSchema: {
1612
+ type: "object",
1613
+ properties: { projectId: { type: "string" }, path: { type: "string" }, content: { type: "string" }, append: { type: "boolean" }, branch: { type: "string" } },
1614
+ required: ["path", "content"],
1615
+ additionalProperties: false
1616
+ },
1617
+ run: async (args) => {
1618
+ const projectId = await projectFor(args["projectId"]);
1619
+ const saved = await service.writeKnowledge(projectId, {
1620
+ path: str(args["path"]) ?? "",
1621
+ content: String(args["content"] ?? ""),
1622
+ ...args["append"] === true ? { append: true } : {},
1623
+ ...str(args["branch"]) ? { changeId: str(args["branch"]) } : {}
1624
+ });
1625
+ return text(`Wrote ${saved.path} (${saved.length} characters) on Claude's working branch ${saved.branch}.`);
1626
+ }
1627
+ },
1628
+ {
1629
+ name: "sources",
1630
+ title: "Read source documents",
1631
+ description: "Without sourceId: every source (PRDs, CRs, feature docs, Jira stories, Figma files\u2026) with its version and how many requirements are new, changed, unchanged or removed. With sourceId: the latest version's text.",
1632
+ inputSchema: { type: "object", properties: { projectId: { type: "string" }, sourceId: { type: "string" } }, additionalProperties: false },
1633
+ annotations: { readOnlyHint: true },
1634
+ run: async (args) => {
1635
+ const projectId = await projectFor(args["projectId"]);
1636
+ const id = str(args["sourceId"]);
1637
+ if (id) {
1638
+ const source = await service.source(projectId, id);
1639
+ return text(`# ${source.title} (v${source.version})
1640
+
1641
+ ${source.text.slice(0, 6e4)}`);
1642
+ }
1643
+ const { sources } = await service.sources(projectId);
1644
+ if (!sources.length) return text("No sources yet. add_source adds a document by text or link; the Knowledge screen takes files.");
1645
+ return text(sources.map((s) => `${s.title} (id ${s.id}, v${s.version}, ${s.kind}${s.externalUrl ? `, ${s.externalUrl}` : ""}) \u2014 ${s.requirements["new"] ?? 0} new, ${s.requirements["changed"] ?? 0} changed, ${s.requirements["active"] ?? 0} unchanged, ${s.requirements["removed"] ?? 0} removed`).join("\n"));
1646
+ }
1647
+ },
1648
+ {
1649
+ name: "add_source",
1650
+ title: "Add a source document",
1651
+ description: "Add a document tests are written from \u2014 as text (Markdown is best: headings and lists are how requirements are found) or as a public link \u2014 or a new version of one (sourceId). The same text again is not a new version. Requirements are found and compared at once.",
1652
+ inputSchema: {
1653
+ type: "object",
1654
+ properties: { projectId: { type: "string" }, title: { type: "string" }, text: { type: "string" }, url: { type: "string" }, sourceId: { type: "string" } },
1655
+ additionalProperties: false
1656
+ },
1657
+ run: async (args) => {
1658
+ const projectId = await projectFor(args["projectId"]);
1659
+ const made = await service.addSource(projectId, {
1660
+ ...str(args["title"]) ? { title: str(args["title"]) } : {},
1661
+ ...typeof args["text"] === "string" ? { text: args["text"] } : {},
1662
+ ...str(args["url"]) ? { url: str(args["url"]) } : {}
1663
+ }, str(args["sourceId"]));
1664
+ const r = made.requirements;
1665
+ return text(made.changed ? `${made.source.title} is now v${made.source.version} (id ${made.source.id}). Requirements: ${r["added"] ?? 0} new, ${r["changed"] ?? 0} changed, ${r["unchanged"] ?? 0} unchanged, ${r["removed"] ?? 0} removed.` : `${made.source.title} has not changed since v${made.source.version}; nothing new.`);
1666
+ }
1667
+ },
1668
+ {
1669
+ name: "delete_source",
1670
+ title: "Delete a source document",
1671
+ description: "Delete a source and everything found in it, for good. Only after the person has said to.",
1672
+ inputSchema: { type: "object", properties: { projectId: { type: "string" }, sourceId: { type: "string" } }, required: ["sourceId"], additionalProperties: false },
1673
+ annotations: { destructiveHint: true },
1674
+ run: async (args) => {
1675
+ const projectId = await projectFor(args["projectId"]);
1676
+ await service.deleteSource(projectId, str(args["sourceId"]) ?? "");
1677
+ return text(`Deleted the source ${str(args["sourceId"])} and its requirements.`);
1678
+ }
1679
+ },
1680
+ {
1681
+ name: "update_requirement",
1682
+ title: "File or correct a requirement",
1683
+ description: "Set which feature a requirement belongs to (featureId like f_checkout), or correct its wording where extraction got it wrong.",
1684
+ inputSchema: { type: "object", properties: { projectId: { type: "string" }, requirementId: { type: "string" }, featureId: { type: "string" }, text: { type: "string" } }, required: ["requirementId"], additionalProperties: false },
1685
+ run: async (args) => {
1686
+ const projectId = await projectFor(args["projectId"]);
1687
+ const saved = await service.updateRequirement(projectId, str(args["requirementId"]) ?? "", {
1688
+ ...typeof args["featureId"] === "string" ? { featureId: args["featureId"] } : {},
1689
+ ...str(args["text"]) ? { text: str(args["text"]) } : {}
1690
+ });
1691
+ return text(`${saved.id}${saved.featureId ? ` is filed under ${saved.featureId}` : ""}: ${saved.text}`);
1692
+ }
1693
+ },
1694
+ {
1695
+ name: "requirements",
1696
+ title: "Read requirements",
1697
+ description: "Requirements found in sources, each with where it is (heading path), its state as of the source's latest version \u2014 new, changed (with what it said before), active (unchanged) or removed \u2014 and its id for covers. Optional sourceId and state narrow it.",
1698
+ inputSchema: { type: "object", properties: { projectId: { type: "string" }, sourceId: { type: "string" }, state: { type: "string", enum: ["new", "changed", "active", "removed"] } }, additionalProperties: false },
1699
+ annotations: { readOnlyHint: true },
1700
+ run: async (args) => {
1701
+ const projectId = await projectFor(args["projectId"]);
1702
+ const [{ requirements }, { cases }] = await Promise.all([
1703
+ service.requirements(projectId, { ...str(args["sourceId"]) ? { sourceId: str(args["sourceId"]) } : {}, ...str(args["state"]) ? { state: str(args["state"]) } : {} }),
1704
+ service.library(projectId)
1705
+ ]);
1706
+ if (!requirements.length) return text("No requirements match.");
1707
+ return text(requirements.map((r) => {
1708
+ const covering = cases.filter((c) => c.covers.includes(r.id)).map((c) => c.key);
1709
+ return `${r.id} [${r.state}] ${r.sourceTitle} \u203A ${r.anchor}: ${r.text}${r.state === "changed" && r.history.length ? `
1710
+ was: ${r.history.at(-1).text}` : ""}
1711
+ covered by: ${covering.join(", ") || "nothing yet"}`;
1712
+ }).join("\n"));
1713
+ }
1714
+ },
1715
+ {
1716
+ name: "find_similar_cases",
1717
+ title: "Find test cases like this one",
1718
+ description: "Before writing a test case, find existing ones that may already check the same thing \u2014 by title and steps. You judge whether one is really the same; if it is, link the requirement to it (covers) instead of writing a duplicate.",
1719
+ inputSchema: { type: "object", properties: { projectId: { type: "string" }, text: { type: "string", description: "The case you are about to write: its title and steps." } }, required: ["text"], additionalProperties: false },
1720
+ annotations: { readOnlyHint: true },
1721
+ run: async (args) => {
1722
+ const projectId = await projectFor(args["projectId"]);
1723
+ const wanted = String(args["text"] ?? "");
1724
+ const { cases } = await service.library(projectId);
1725
+ const scored = cases.map((c) => ({ c, score: similarity(wanted, [c.title, ...c.steps.flatMap((st) => [st.action, st.expected ?? ""])].join(" ")) })).filter((x) => x.score >= 0.2).sort((a, b) => b.score - a.score).slice(0, 8);
1726
+ if (!scored.length) return text("Nothing similar in the library.");
1727
+ return text(scored.map(({ c, score }) => `${c.key} (id ${c.id}, revision ${c.revision}, ${Math.round(score * 100)}% alike) ${c.title}
1728
+ ${c.steps.map((st, i) => ` ${i + 1}. ${st.action}${st.expected ? ` \u2192 ${st.expected}` : ""}`).join("\n")}`).join("\n"));
1729
+ }
1730
+ },
1731
+ {
1732
+ name: "save_automation_plan",
1733
+ title: "Write the plan to automate a test case",
1734
+ description: "Save the plan for automating a test case (TC key or id) before recording anything: flows to reuse, flows to record, which check proves each expected result, accounts and helpers it needs, and what cannot be automated and why. Marks it planned. approved: true only when the person has said the plan is right.",
1735
+ inputSchema: {
1736
+ type: "object",
1737
+ properties: { projectId: { type: "string" }, testCase: { type: "string" }, plan: { type: "string" }, approved: { type: "boolean" }, branch: { type: "string" } },
1738
+ required: ["testCase", "plan"],
1739
+ additionalProperties: false
1740
+ },
1741
+ run: async (args) => {
1742
+ const projectId = await projectFor(args["projectId"]);
1743
+ const ref = str(args["testCase"]) ?? "";
1744
+ const { cases } = await service.library(projectId);
1745
+ const target = cases.find((c) => c.id === ref || c.key.toLowerCase() === ref.toLowerCase());
1746
+ if (!target) return text(`No test case ${ref} in the library.`);
1747
+ const saved = await service.saveLibrary(projectId, {
1748
+ update: [{
1749
+ id: target.id,
1750
+ revision: target.revision,
1751
+ automation: {
1752
+ ...target.automation,
1753
+ plan: String(args["plan"] ?? ""),
1754
+ planApproved: args["approved"] === true,
1755
+ state: target.automation.links.length ? target.automation.state : "planned"
1756
+ }
1757
+ }],
1758
+ message: `Automation plan for ${target.key}`,
1759
+ ...str(args["branch"]) ? { changeId: str(args["branch"]) } : {}
1760
+ });
1761
+ return text(`Saved the ${args["approved"] === true ? "approved " : ""}automation plan for ${target.key} "${target.title}" on Claude's working branch ${saved.branch}.`);
1762
+ }
1763
+ },
1764
+ {
1765
+ name: "integrations",
1766
+ title: "Read integrations",
1767
+ description: "The project's connections to Jira, Linear, GitHub, Figma and Confluence: what each is, whether it works, when it was last read, and which take bugs.",
1768
+ inputSchema: { type: "object", properties: { projectId: { type: "string" } }, additionalProperties: false },
1769
+ annotations: { readOnlyHint: true },
1770
+ run: async (args) => {
1771
+ const projectId = await projectFor(args["projectId"]);
1772
+ const { integrations } = await service.integrations(projectId);
1773
+ if (!integrations.length) return text("No integrations. An owner connects them on the Knowledge screen.");
1774
+ return text(integrations.map((i) => `${i.name} (${i.platform}, id ${i.id}) \u2014 ${i.status}${i.lastSyncAt ? `, last read ${i.lastSyncAt}` : ", never read"}${["jira", "linear", "github"].includes(i.platform) ? ", takes bugs" : ""}`).join("\n"));
1775
+ }
1776
+ },
1777
+ {
1778
+ name: "sync_integration",
1779
+ title: "Read an integration's items as sources",
1780
+ description: "Read what an integration holds (Jira stories, Linear and GitHub issues, Confluence pages, Figma frames) as sources. Items unchanged since the last read stay as they are.",
1781
+ inputSchema: { type: "object", properties: { projectId: { type: "string" }, integrationId: { type: "string" } }, required: ["integrationId"], additionalProperties: false },
1782
+ run: async (args) => {
1783
+ const projectId = await projectFor(args["projectId"]);
1784
+ const done = await service.syncIntegration(projectId, str(args["integrationId"]) ?? "");
1785
+ return text(`Read ${done.items} item${done.items === 1 ? "" : "s"}: ${done.newSources} new, ${done.newVersions} changed, ${done.unchanged} unchanged. Requirements: ${done.requirements["added"] ?? 0} new, ${done.requirements["changed"] ?? 0} changed, ${done.requirements["removed"] ?? 0} removed.`);
1786
+ }
1787
+ },
1788
+ {
1789
+ name: "push_bug",
1790
+ title: "Raise a bug in a tracker",
1791
+ description: "Raise a Flowriki bug (BUG-n or its id) in Jira, Linear or GitHub through one of the project's integrations. This reaches outside Flowriki: only after the person has said to.",
1792
+ inputSchema: { type: "object", properties: { projectId: { type: "string" }, bug: { type: "string" }, integrationId: { type: "string" } }, required: ["bug", "integrationId"], additionalProperties: false },
1793
+ annotations: { destructiveHint: true },
1794
+ run: async (args) => {
1795
+ const projectId = await projectFor(args["projectId"]);
1796
+ const ref = str(args["bug"]) ?? "";
1797
+ const { bugs } = await service.bugs(projectId);
1798
+ const bug = bugs.find((b) => b.id === ref || b.key.toLowerCase() === ref.toLowerCase());
1799
+ if (!bug) return text(`No bug ${ref}.`);
1800
+ const pushed = await service.pushBug(projectId, bug.id, str(args["integrationId"]) ?? "");
1801
+ return text(`${bug.key} is now ${pushed.externalKey} in ${pushed.tracker}: ${pushed.url}`);
1802
+ }
1803
+ },
1804
+ {
1805
+ name: "test_cycles",
1806
+ title: "Read manual test cycles",
1807
+ description: "Manual test cycles and how they are going. Without cycleId: every cycle with its counts (passed, failed, blocked, untested). With cycleId: each case in it \u2014 key, title, steps, the outcome of each step, notes, evidence and bugs.",
1808
+ inputSchema: {
1809
+ type: "object",
1810
+ properties: { projectId: { type: "string" }, cycleId: { type: "string" } },
1811
+ additionalProperties: false
1812
+ },
1813
+ annotations: { readOnlyHint: true },
1814
+ run: async (args) => {
1815
+ const projectId = await projectFor(args["projectId"]);
1816
+ const counts = (s) => `${s.passed} passed, ${s.failed} failed, ${s.blocked} blocked, ${s.skipped} skipped, ${s.untested} untested of ${s.total}`;
1817
+ const id = str(args["cycleId"]);
1818
+ if (!id) {
1819
+ const { cycles } = await service.cycles(projectId);
1820
+ if (!cycles.length) return text("No test cycles yet. manage_cycle with action create makes one from library cases.");
1821
+ return text(cycles.map((c) => `${c.name} (id ${c.id}, ${c.status}) \u2014 ${counts(c.summary)}`).join("\n"));
1822
+ }
1823
+ const cycle = await service.cycle(projectId, id);
1824
+ const lines = cycle.entries.map((e) => {
1825
+ const r = e.result;
1826
+ const steps = e.steps.map((st, i) => {
1827
+ const got = r?.steps.find((x) => x.stepId === st.id);
1828
+ return ` ${i + 1}. ${st.action}${st.expected ? ` \u2192 ${st.expected}` : ""}: ${got?.outcome ?? "untested"}${got?.actual ? ` (actual: ${got.actual})` : ""}`;
1829
+ }).join("\n");
1830
+ return `${e.key} ${e.title} [${e.priority}] \u2014 ${r?.outcome ?? "untested"}${e.assignee ? `, assigned to ${e.assignee}` : ""}${r?.note ? `
1831
+ Note: ${r.note}` : ""}${r?.bugIds.length ? `
1832
+ Bugs: ${r.bugIds.join(", ")}` : ""}${r?.evidence.length ? `
1833
+ Evidence: ${r.evidence.map((ev) => ev.url ? `${ev.name} <${ev.url}>` : ev.name).join(", ")}` : ""}${steps ? `
1834
+ ${steps}` : ""}`;
1835
+ });
1836
+ return text(`${cycle.name} (${cycle.status}) \u2014 ${counts(cycle.summary)}
1837
+ ${lines.join("\n")}`);
1838
+ }
1839
+ },
1840
+ {
1841
+ name: "manage_cycle",
1842
+ title: "Make or change a test cycle",
1843
+ description: "action create: {name, description?, testCases: [TC keys or ids]} \u2014 cases are taken from the library as they are now (on Claude's working branch when it has one). action update: {cycleId, name?, description?, addTestCases?, removeTestCases?, assign?: {testCase, assignee}}. action close / reopen: {cycleId}. action delete: {cycleId} \u2014 deletes the cycle and its results for good; only after the person has said to.",
1844
+ inputSchema: {
1845
+ type: "object",
1846
+ properties: {
1847
+ projectId: { type: "string" },
1848
+ action: { type: "string", enum: ["create", "update", "close", "reopen", "delete"] },
1849
+ cycleId: { type: "string" },
1850
+ name: { type: "string" },
1851
+ description: { type: "string" },
1852
+ testCases: { type: "array", items: { type: "string" } },
1853
+ addTestCases: { type: "array", items: { type: "string" } },
1854
+ removeTestCases: { type: "array", items: { type: "string" } },
1855
+ assign: { type: "object", properties: { testCase: { type: "string" }, assignee: { type: "string" } }, additionalProperties: false }
1856
+ },
1857
+ required: ["action"],
1858
+ additionalProperties: false
1859
+ },
1860
+ run: async (args) => {
1861
+ const projectId = await projectFor(args["projectId"]);
1862
+ const action = str(args["action"]);
1863
+ const cycleId = str(args["cycleId"]);
1864
+ const resolve = async (refs) => {
1865
+ const wanted = Array.isArray(refs) ? refs.map(String) : [];
1866
+ if (!wanted.length) return { ids: [], changeId: void 0 };
1867
+ const lib = await service.library(projectId);
1868
+ const ids = wanted.map((ref) => lib.cases.find((c) => c.id === ref || c.key.toLowerCase() === ref.toLowerCase())?.id);
1869
+ const missing = wanted.filter((_, i) => !ids[i]);
1870
+ if (missing.length) throw new Error(`no test case ${missing.join(", ")} in the library \u2014 library_cases lists them`);
1871
+ return { ids, changeId: lib.changeId };
1872
+ };
1873
+ if (action === "create") {
1874
+ const name = str(args["name"]);
1875
+ if (!name) return text("A cycle needs a name.");
1876
+ const { ids, changeId } = await resolve(args["testCases"]);
1877
+ const made = await service.createCycle(projectId, { name, ...str(args["description"]) ? { description: str(args["description"]) } : {}, testCaseIds: ids }, changeId);
1878
+ return text(`Made the cycle "${made.name}" (id ${made.id}) with ${made.entries.length} case${made.entries.length === 1 ? "" : "s"}: ${made.entries.map((e) => e.key).join(", ")}.`);
1879
+ }
1880
+ if (!cycleId) return text("Say which cycle (cycleId \u2014 test_cycles lists them).");
1881
+ if (action === "delete") {
1882
+ await service.deleteCycle(projectId, cycleId);
1883
+ return text(`Deleted the cycle ${cycleId} and its results.`);
1884
+ }
1885
+ if (action === "close" || action === "reopen") {
1886
+ const saved2 = await service.updateCycle(projectId, cycleId, { status: action === "close" ? "closed" : "open" });
1887
+ return text(`${saved2.name} is ${saved2.status}.`);
1888
+ }
1889
+ const current = await service.cycle(projectId, cycleId);
1890
+ const inCycle = (ref) => current.entries.find((e) => e.testCaseId === ref || e.key.toLowerCase() === ref.toLowerCase())?.testCaseId;
1891
+ const added = await resolve(args["addTestCases"]);
1892
+ const body = {
1893
+ ...str(args["name"]) ? { name: str(args["name"]) } : {},
1894
+ ...typeof args["description"] === "string" ? { description: args["description"] } : {},
1895
+ ...added.ids.length ? { addCaseIds: added.ids } : {},
1896
+ ...Array.isArray(args["removeTestCases"]) ? { removeCaseIds: args["removeTestCases"].map(String).map((r) => inCycle(r) ?? r) } : {}
1897
+ };
1898
+ const assign = args["assign"];
1899
+ if (assign?.testCase) body["assign"] = { testCaseId: inCycle(assign.testCase) ?? assign.testCase, assignee: assign.assignee ?? "" };
1900
+ const saved = await service.updateCycle(projectId, cycleId, body, added.changeId);
1901
+ return text(`Updated "${saved.name}": ${saved.entries.length} case${saved.entries.length === 1 ? "" : "s"} (${saved.entries.map((e) => e.key).join(", ")}).`);
1902
+ }
1903
+ },
1904
+ {
1905
+ name: "record_result",
1906
+ title: "Record how a case went",
1907
+ description: "Record a manual result for one case in a cycle: steps [{step: its number (1, 2\u2026), outcome: passed|failed|blocked|skipped|untested, actual?: what really happened}], or a whole outcome, a note, links as evidence ([{name, url}]). Any failed step fails the case. retest: true keeps the earlier attempt in the case's history. Only record what the person tells you happened.",
1908
+ inputSchema: {
1909
+ type: "object",
1910
+ properties: {
1911
+ projectId: { type: "string" },
1912
+ cycleId: { type: "string" },
1913
+ testCase: { type: "string", description: "TC-12, or the test case id." },
1914
+ steps: { type: "array", items: { type: "object" } },
1915
+ outcome: { type: "string", enum: ["passed", "failed", "blocked", "skipped", "untested"] },
1916
+ note: { type: "string" },
1917
+ retest: { type: "boolean" },
1918
+ evidenceLinks: { type: "array", items: { type: "object", properties: { name: { type: "string" }, url: { type: "string" } }, additionalProperties: false } }
1919
+ },
1920
+ required: ["cycleId", "testCase"],
1921
+ additionalProperties: false
1922
+ },
1923
+ run: async (args) => {
1924
+ const projectId = await projectFor(args["projectId"]);
1925
+ const cycleId = str(args["cycleId"]) ?? "";
1926
+ const cycle = await service.cycle(projectId, cycleId);
1927
+ const ref = str(args["testCase"]) ?? "";
1928
+ const entry = cycle.entries.find((e) => e.testCaseId === ref || e.key.toLowerCase() === ref.toLowerCase());
1929
+ if (!entry) return text(`${ref} is not in "${cycle.name}" \u2014 test_cycles lists its cases.`);
1930
+ const steps = (Array.isArray(args["steps"]) ? args["steps"] : []).map((raw) => {
1931
+ const item = raw;
1932
+ const at = typeof item.step === "number" ? entry.steps[item.step - 1]?.id : void 0;
1933
+ return { stepId: String(item.stepId ?? at ?? ""), outcome: String(item.outcome ?? ""), ...typeof item.actual === "string" ? { actual: item.actual } : {} };
1934
+ });
1935
+ const saved = await service.recordResult(projectId, cycleId, entry.testCaseId, {
1936
+ steps,
1937
+ ...str(args["outcome"]) ? { outcome: str(args["outcome"]) } : {},
1938
+ ...typeof args["note"] === "string" ? { note: args["note"] } : {},
1939
+ ...args["retest"] === true ? { retest: true } : {}
1940
+ });
1941
+ for (const link of Array.isArray(args["evidenceLinks"]) ? args["evidenceLinks"] : []) {
1942
+ const { name, url } = link;
1943
+ if (url) await service.addEvidenceLink(projectId, cycleId, entry.testCaseId, { name: name || "Evidence", url });
1944
+ }
1945
+ const now = saved.entries.find((e) => e.testCaseId === entry.testCaseId);
1946
+ return text(`${entry.key} is ${now.result?.outcome ?? "untested"} in "${saved.name}". The cycle: ${saved.summary.passed} passed, ${saved.summary.failed} failed, ${saved.summary.blocked} blocked, ${saved.summary.untested} untested.`);
1947
+ }
1948
+ },
1949
+ {
1950
+ name: "bugs",
1951
+ title: "Read bugs",
1952
+ description: "The project's bugs: key (BUG-n), title, severity, status, and the test cases and cycle step they were found at. Optional status narrows it (open, in-progress, fixed, verified, closed, wont-fix).",
1953
+ inputSchema: { type: "object", properties: { projectId: { type: "string" }, status: { type: "string" } }, additionalProperties: false },
1954
+ annotations: { readOnlyHint: true },
1955
+ run: async (args) => {
1956
+ const projectId = await projectFor(args["projectId"]);
1957
+ const { bugs } = await service.bugs(projectId);
1958
+ const shown = str(args["status"]) ? bugs.filter((b) => b.status === str(args["status"])) : bugs;
1959
+ if (!shown.length) return text("No bugs.");
1960
+ return text(shown.map((b) => `${b.key} (id ${b.id}) ${b.title} \u2014 ${b.severity}, ${b.status}${b.testCaseIds.length ? `, test cases ${b.testCaseIds.join(", ")}` : ""}${b.description ? `
1961
+ ${b.description.slice(0, 300)}` : ""}`).join("\n"));
1962
+ }
1963
+ },
1964
+ {
1965
+ name: "save_bug",
1966
+ title: "Raise or update a bug",
1967
+ description: "New: {title, description?, severity?: critical|major|minor|trivial, cycleId?, testCase?, step? (its number)} \u2014 found in a cycle, it is attached to that case's result there. Update: {bugId, title?, description?, severity?, status?: open|in-progress|fixed|verified|closed|wont-fix}. {bugId, refresh: true} reads a pushed bug's status back from its tracker.",
1968
+ inputSchema: {
1969
+ type: "object",
1970
+ properties: {
1971
+ projectId: { type: "string" },
1972
+ bugId: { type: "string" },
1973
+ title: { type: "string" },
1974
+ description: { type: "string" },
1975
+ severity: { type: "string" },
1976
+ status: { type: "string" },
1977
+ cycleId: { type: "string" },
1978
+ testCase: { type: "string" },
1979
+ step: { type: "number" },
1980
+ refresh: { type: "boolean" }
1981
+ },
1982
+ additionalProperties: false
1983
+ },
1984
+ run: async (args) => {
1985
+ const projectId = await projectFor(args["projectId"]);
1986
+ const bugId = str(args["bugId"]);
1987
+ if (bugId && args["refresh"] === true) {
1988
+ const fresh = await service.refreshBug(projectId, bugId);
1989
+ return text(`${fresh.key} is ${fresh.status} in its tracker.`);
1990
+ }
1991
+ if (bugId) {
1992
+ const body2 = Object.fromEntries(["title", "description", "severity", "status"].filter((k) => typeof args[k] === "string").map((k) => [k, args[k]]));
1993
+ const saved = await service.updateBug(projectId, bugId, body2);
1994
+ return text(`${saved.key} "${saved.title}" is ${saved.status} (${saved.severity}).`);
1995
+ }
1996
+ const title = str(args["title"]);
1997
+ if (!title) return text("A bug needs a title.");
1998
+ const body = {
1999
+ title,
2000
+ ...typeof args["description"] === "string" ? { description: args["description"] } : {},
2001
+ ...str(args["severity"]) ? { severity: str(args["severity"]) } : {}
2002
+ };
2003
+ const cycleId = str(args["cycleId"]);
2004
+ const ref = str(args["testCase"]);
2005
+ if (cycleId && ref) {
2006
+ const cycle = await service.cycle(projectId, cycleId);
2007
+ const entry = cycle.entries.find((e) => e.testCaseId === ref || e.key.toLowerCase() === ref.toLowerCase());
2008
+ if (!entry) return text(`${ref} is not in that cycle.`);
2009
+ const step = typeof args["step"] === "number" ? entry.steps[args["step"] - 1]?.id : void 0;
2010
+ body["foundAt"] = { cycleId, testCaseId: entry.testCaseId, ...step ? { stepId: step } : {} };
2011
+ } else if (ref) {
2012
+ const lib = await service.library(projectId);
2013
+ const found = lib.cases.find((c) => c.id === ref || c.key.toLowerCase() === ref.toLowerCase());
2014
+ if (found) body["testCaseIds"] = [found.id];
2015
+ }
2016
+ const made = await service.createBug(projectId, body);
2017
+ return text(`Raised ${made.key} "${made.title}" (${made.severity}).`);
2018
+ }
2019
+ },
1262
2020
  {
1263
2021
  name: "close_browser",
1264
2022
  title: "Close the browser",