@capagents/forgeqe 1.1.6 → 1.1.8

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.
@@ -1,11 +1,29 @@
1
1
  import { mkdirSync, writeFileSync } from "node:fs"
2
2
  import path from "node:path"
3
3
 
4
+ import { queryApi, type ApiQuery } from "../cases/api.ts"
5
+ import { reportPage } from "../report/html.ts"
6
+ import {
7
+ chooseColumns,
8
+ chooseFilters,
9
+ dedupe,
10
+ describeCall,
11
+ EvidenceLog,
12
+ evidenceList,
13
+ pickEvidence,
14
+ rowsOf,
15
+ type Cell,
16
+ type Column,
17
+ type EvidenceEntry,
18
+ type Row,
19
+ } from "../report/evidence.ts"
4
20
  import type { McpTool } from "./tools.ts"
5
21
 
6
22
  /**
7
- * Reports the agent writes from what it found (release status, open defects, pipeline health): a Markdown file to
8
- * edit or paste, and the same report as a page to open or share. The chat gets the summary and the links.
23
+ * Reports the agent writes from what it found (open defects, release status, pipeline health). Every table in one is
24
+ * built from evidence: the output of tool calls in this session, or a read-only query ForgeQE runs itself. The model
25
+ * writes only the summary. The report is a page (report-ui, with filters, charts, and the sources it came from) and
26
+ * the same report as Markdown; the raw results are kept beside them.
9
27
  */
10
28
 
11
29
  /** "Release status: all projects" → "release-status-all-projects". */
@@ -21,7 +39,7 @@ export function reportSlug(title: string): string {
21
39
 
22
40
  const escapeHtml = (s: string) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;")
23
41
 
24
- /** The report as a page: the Markdown rendered, with raw HTML in it shown as text so a report can never run script. */
42
+ /** A Markdown document as a plain page (forgeqe bi's page without Evidence): the Markdown rendered, with raw HTML in it shown as text so a report can never run script. */
25
43
  export function reportHtml(title: string, markdown: string, written: Date): string {
26
44
  const safe = markdown.replace(/<(?=[A-Za-z/!?])/g, "&lt;")
27
45
  const body = Bun.markdown.html(safe)
@@ -66,36 +84,209 @@ ${body}
66
84
  `
67
85
  }
68
86
 
87
+ /** The summary as HTML, with raw HTML in it shown as text so a report can never run script. */
88
+ export function summaryHtml(markdown: string): string {
89
+ return Bun.markdown.html(markdown.replace(/<(?=[A-Za-z/!?])/g, "&lt;"))
90
+ }
91
+
92
+ const MAX_ROWS = 5000
93
+ const MD_ROWS = 200
94
+
95
+ export type DatasetSpec = { name?: string; from?: string; match?: string; query?: ApiQuery; columns?: string[]; filters?: string[] }
96
+
97
+ export type Dataset = { name: string; columns: Column[]; filters: string[]; rows: Row[]; sources: string[]; total: number; note?: string }
98
+ export type Source = { id: string; tool: string; call: string; at: string; rows: number | null; file: string }
99
+
100
+ /** The rows of one dataset, and the evidence they came from. */
101
+ export async function buildDataset(spec: DatasetSpec, log: EvidenceLog, readApi: (q: ApiQuery) => Promise<unknown>): Promise<{ dataset: Dataset; used: EvidenceEntry[] }> {
102
+ const name = String(spec.name || "Data").trim()
103
+ let used: EvidenceEntry[]
104
+ if (spec.query) {
105
+ const q = spec.query
106
+ const out = await readApi(q)
107
+ const entry = log.record("query_api", { ...q }, typeof out === "string" ? out : JSON.stringify(out))
108
+ if (!entry) throw new Error(`Dataset "${name}": the query returned nothing.`)
109
+ used = [entry]
110
+ } else if (spec.from) {
111
+ used = pickEvidence(log.all(), String(spec.from), spec.match ? String(spec.match) : undefined)
112
+ if (!used.length) throw new Error(`Dataset "${name}": no tool result matches "${spec.from}"${spec.match ? ` with "${spec.match}"` : ""}. Results in this session:\n${evidenceList(log.all())}`)
113
+ } else {
114
+ throw new Error(`Dataset "${name}" needs "from" (a tool result of this session, e.g. E3 or the tool's name) or "query" (a read-only query ForgeQE runs).`)
115
+ }
116
+ const parts = used.map((e) => ({ e, rows: rowsOf(e.output) }))
117
+ const noData = parts.filter((p) => p.rows === null)
118
+ if (noData.length === parts.length) {
119
+ throw new Error(`Dataset "${name}": ${noData.map((p) => p.e.id).join(", ")} returned no data (no JSON records). Results in this session:\n${evidenceList(log.all())}`)
120
+ }
121
+ const all = dedupe(parts.flatMap((p) => p.rows || []))
122
+ const rows = all.slice(0, MAX_ROWS)
123
+ const columns = rows.length ? chooseColumns(rows, spec.columns) : []
124
+ const totals = parts.map((p) => {
125
+ const v = (() => {
126
+ try {
127
+ return JSON.parse(p.e.output) as { total?: unknown; count?: unknown }
128
+ } catch {
129
+ return null
130
+ }
131
+ })()
132
+ return typeof v?.total === "number" ? v.total : 0
133
+ })
134
+ const total = Math.max(all.length, ...totals)
135
+ const notes = [
136
+ all.length > MAX_ROWS ? `The page holds the first ${MAX_ROWS} of ${all.length} rows.` : "",
137
+ total > all.length ? `The query matched ${total}; the first ${all.length} were read.` : "",
138
+ ].filter(Boolean)
139
+ return {
140
+ dataset: { name, columns, filters: chooseFilters(rows, columns, spec.filters), rows, sources: used.map((e) => e.id), total, ...(notes.length ? { note: notes.join(" ") } : {}) },
141
+ used,
142
+ }
143
+ }
144
+
145
+ const mdCell = (v: Cell | undefined) => (v === null || v === undefined ? "" : String(v).replace(/\|/g, "\\|").replace(/\s+/g, " "))
146
+ const csvCell = (v: Cell | undefined) => {
147
+ const s = v === null || v === undefined ? "" : String(v)
148
+ return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s
149
+ }
150
+
151
+ /** Counts of each value of a column, most first. */
152
+ export function countBy(rows: Row[], key: string): [string, number][] {
153
+ const m = new Map<string, number>()
154
+ for (const r of rows) {
155
+ const v = r[key] === null || r[key] === undefined || r[key] === "" ? "(none)" : String(r[key])
156
+ m.set(v, (m.get(v) || 0) + 1)
157
+ }
158
+ return [...m].sort((a, b) => b[1] - a[1])
159
+ }
160
+
161
+ /** The report as Markdown: the summary, each dataset's counts and table, and the sources. */
162
+ export function reportMarkdown(title: string, summary: string, datasets: Dataset[], sources: Source[], written: Date): string {
163
+ const out = [`# ${title}`, "", `_Written by ForgeQE on ${written.toISOString().slice(0, 16).replace("T", " ")} UTC, from ${sources.length === 1 ? "1 source" : `${sources.length} sources`}._`, "", summary, ""]
164
+ for (const d of datasets) {
165
+ out.push(`## ${d.name}`, "", `**${d.rows.length}** ${d.rows.length === 1 ? "row" : "rows"}${d.note ? ` (${d.note})` : ""} · from ${d.sources.join(", ")}`, "")
166
+ for (const f of d.filters.slice(0, 3)) out.push(`- By ${f}: ${countBy(d.rows, f).map(([v, n]) => `${v} ${n}`).join(", ")}`)
167
+ if (d.filters.length) out.push("")
168
+ if (d.rows.length) {
169
+ const cols = d.columns.filter((c) => c.key !== "Link")
170
+ const idCol = cols.find((c) => c.key === "ID" || c.key === "Key")
171
+ out.push(`| ${cols.map((c) => c.label).join(" | ")} |`, `| ${cols.map(() => "---").join(" | ")} |`)
172
+ for (const r of d.rows.slice(0, MD_ROWS)) {
173
+ out.push(`| ${cols.map((c) => (c === idCol && r.Link ? `[${mdCell(r[c.key])}](${r.Link})` : mdCell(r[c.key]))).join(" | ")} |`)
174
+ }
175
+ if (d.rows.length > MD_ROWS) out.push("", `The first ${MD_ROWS} rows; the page and ${csvName(d.name)} have all ${d.rows.length}.`)
176
+ out.push("")
177
+ }
178
+ }
179
+ out.push("## Sources", "", ...sources.map((s) => `- ${s.id} · ${s.call} · ${s.at.slice(0, 16).replace("T", " ")} UTC · ${s.rows === null ? "no data" : `${s.rows} rows`} · [raw](${s.file})`), "")
180
+ return out.join("\n")
181
+ }
182
+
183
+ const csvName = (name: string) => `${reportSlug(name)}.csv`
184
+
69
185
  export const REPORT_TOOLS: McpTool[] = [
70
186
  {
71
187
  name: "write_report",
72
188
  description:
73
- 'Save a report the user asks for ("create a release report", "write up the open defects", "give me a report I can share") as a Markdown file and an HTML page in the reports folder. Gather the facts with your other tools first, then pass the whole report as Markdown: a one-paragraph summary first, then ## sections, tables for per-item status (one row per project, case, or build), and links to builds, work items, and pull requests. Do not invent data you did not read. The user is shown the report paths as links, so reply with three to five lines of summary only: do not repeat the paths or paste the report.',
189
+ 'Save a report the user asks for ("create a defect report for all open defects", "release status for all projects", "a report I can share") as a page with filters, charts, and its sources, and as Markdown. A report is built from evidence: each dataset is the output of tool calls you made in this session (from: the tool\'s name, e.g. wit_get_work_items_batch_by_ids or query_api, or an evidence id like E3; match narrows to calls whose input mentions it), or a read-only query ForgeQE runs itself (query: same fields as query_api; a WIQL query reads up to 1000 work items with project, state, priority, severity, assignee, area, and links, so prefer it for "all" of something). ForgeQE takes the rows from those results, not from you; columns and filters are optional (default: the usual ones in the data, such as Project, State, Priority, Assigned To). Write only the summary yourself: one short paragraph of what the data shows, with no figures the datasets do not hold. The user is shown the report paths as links, so reply with three to five lines of summary only.',
74
190
  inputSchema: {
75
191
  type: "object",
76
192
  properties: {
77
- title: { type: "string", description: "The report's title, e.g. Release status: all projects, 9 October 2026" },
78
- markdown: { type: "string", description: "The report body in Markdown, without the title (it is added as the heading)" },
193
+ title: { type: "string", description: "The report's title, e.g. Open defects: all projects, 13 October 2026" },
194
+ summary: { type: "string", description: "One short Markdown paragraph (or a few bullets) on what the data shows; the tables, counts, and charts are made from the datasets" },
195
+ datasets: {
196
+ type: "array",
197
+ minItems: 1,
198
+ items: {
199
+ type: "object",
200
+ properties: {
201
+ name: { type: "string", description: "e.g. Open defects" },
202
+ from: { type: "string", description: "Evidence ids (E3, or E3,E4) or the name of a tool called in this session; every successful call of it is used" },
203
+ match: { type: "string", description: "With a tool name: only its calls whose input contains this text" },
204
+ query: {
205
+ type: "object",
206
+ description: "A read-only query ForgeQE runs for this dataset (instead of from)",
207
+ properties: {
208
+ system: { type: "string", enum: ["azure_devops", "jira", "xray", "confluence"] },
209
+ wiql: { type: "string", description: "azure_devops: e.g. SELECT [System.Id] FROM WorkItems WHERE [System.WorkItemType] = 'Bug' AND [System.State] NOT IN ('Closed','Done','Removed'). Across projects, leave out @project" },
210
+ project: { type: "string" },
211
+ path: { type: "string", description: "A REST path, as for query_api (e.g. Jira /rest/api/3/search/jql?jql=...&fields=summary,status,priority,assignee,project)" },
212
+ query: { type: "string", description: "xray: a GraphQL query" },
213
+ variables: { type: "object" },
214
+ },
215
+ required: ["system"],
216
+ },
217
+ columns: { type: "array", items: { type: "string" }, description: "Columns to show, in order (default: the usual ones in the data)" },
218
+ filters: { type: "array", items: { type: "string" }, description: "Columns to filter by (default: Project, State, Priority, Assigned To, … when present)" },
219
+ },
220
+ required: ["name"],
221
+ },
222
+ },
79
223
  },
80
- required: ["title", "markdown"],
224
+ required: ["title", "datasets"],
81
225
  },
82
226
  async run(args, ctx) {
83
227
  const title = String(args.title || "Report").trim()
84
- const markdown = String(args.markdown || "").trim()
85
- if (!markdown) throw new Error("The report is empty: pass its body as markdown.")
228
+ const summary = String(args.summary ?? args.markdown ?? "").trim()
229
+ const specs = (Array.isArray(args.datasets) ? args.datasets : []) as DatasetSpec[]
230
+ const log = ctx.evidence || new EvidenceLog()
231
+ if (!specs.length) {
232
+ throw new Error(`A report is built from evidence: pass datasets, each from a tool result of this session or a read-only query. Results in this session:\n${evidenceList(log.all())}`)
233
+ }
234
+ const readApi = (q: ApiQuery) => queryApi(ctx.config.sources, q, { full: true })
235
+ const built: Dataset[] = []
236
+ const used = new Map<string, EvidenceEntry>()
237
+ for (const spec of specs) {
238
+ const { dataset, used: entries } = await buildDataset(spec, log, readApi)
239
+ built.push(dataset)
240
+ for (const e of entries) used.set(e.id, e)
241
+ }
242
+
86
243
  const written = new Date()
87
244
  const stamp = written.toISOString().replace(/[-:]/g, "").replace("T", "-").slice(0, 15)
88
245
  // Beside the run reports, so the shell links it and outDir publishing (notify.report_url) serves it too.
89
246
  const dir = path.resolve(ctx.config.export.outDir, `${reportSlug(title)}-${stamp}`)
90
- mkdirSync(dir, { recursive: true })
247
+ mkdirSync(path.join(dir, "evidence"), { recursive: true })
248
+ const sources: Source[] = [...used.values()].map((e) => {
249
+ const file = `evidence/${e.id}.json`
250
+ writeFileSync(path.join(dir, file), JSON.stringify({ id: e.id, tool: e.tool, input: e.input, at: e.at, output: e.output }, null, 2))
251
+ const rows = rowsOf(e.output)
252
+ return { id: e.id, tool: e.tool, call: describeCall(e), at: e.at, rows: rows ? rows.length : null, file }
253
+ })
254
+ for (const d of built) {
255
+ const cols = d.columns
256
+ writeFileSync(path.join(dir, csvName(d.name)), [cols.map((c) => csvCell(c.label)).join(","), ...d.rows.map((r) => cols.map((c) => csvCell(r[c.key])).join(","))].join("\n") + "\n")
257
+ }
91
258
  const md = path.join(dir, "report.md")
92
259
  const html = path.join(dir, "report.html")
93
- writeFileSync(md, `# ${title}\n\n${markdown}\n`)
94
- writeFileSync(html, reportHtml(title, markdown, written))
260
+ writeFileSync(md, reportMarkdown(title, summary, built, sources, written))
261
+ writeFileSync(
262
+ html,
263
+ reportPage(title, {
264
+ kind: "data",
265
+ title,
266
+ id: path.basename(dir),
267
+ startedAt: written.toISOString(),
268
+ ms: 0,
269
+ stopped: false,
270
+ generatedAt: written.toISOString(),
271
+ cases: [],
272
+ posted: [],
273
+ history: [],
274
+ report: { summary, summaryHtml: summary ? summaryHtml(summary) : "", datasets: built.map((d) => ({ ...d, csv: csvName(d.name) })), sources },
275
+ }),
276
+ )
95
277
  const show = (p: string) => path.relative(process.cwd(), p) || p
278
+ const rows = built.reduce((n, d) => n + d.rows.length, 0)
96
279
  ctx.emit?.({ type: "report", title, html, markdown: md })
97
- ctx.emit?.({ type: "card", card: { status: "ok", title: `Report: ${title}`, body: [`Page ${show(html)}`, `Markdown ${show(md)}`] } })
98
- return { html: show(html), markdown: show(md) }
280
+ ctx.emit?.({
281
+ type: "card",
282
+ card: { status: "ok", title: `Report: ${title}`, body: [`Page ${show(html)}`, `Markdown ${show(md)}`, `Evidence ${rows} rows from ${sources.map((s) => s.id).join(", ")}`] },
283
+ })
284
+ return {
285
+ html: show(html),
286
+ markdown: show(md),
287
+ datasets: built.map((d) => ({ name: d.name, rows: d.rows.length, total: d.total, columns: d.columns.map((c) => c.key), filters: d.filters, counts: Object.fromEntries(d.filters.map((f) => [f, Object.fromEntries(countBy(d.rows, f).slice(0, 8))])), ...(d.note ? { note: d.note } : {}) })),
288
+ sources: sources.map((s) => `${s.id} ${s.call}`),
289
+ }
99
290
  },
100
291
  },
101
292
  ]
package/src/mcp/server.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { createHash, randomBytes, timingSafeEqual } from "node:crypto"
2
2
 
3
+ import { EvidenceLog } from "../report/evidence.ts"
3
4
  import { logEvent } from "../sessionLog.ts"
4
5
  import type { McpTool, ToolContext, ToolEvent } from "./tools.ts"
5
6
 
@@ -24,14 +25,23 @@ export type McpServerOptions = {
24
25
  progressMs?: number
25
26
  }
26
27
 
28
+ /** A ForgeQE tool call that has no line of its own, named for the Activity panel by its tool and its subject. */
29
+ export function callLabel(name: string, args: Record<string, unknown>): string {
30
+ const subject = [args.title, args.goal, args.source, args.sources, args.run_dir, args.command].find((v) => typeof v === "string" && v.trim()) as string | undefined
31
+ const flat = subject?.replace(/\s+/g, " ").trim()
32
+ return `ForgeQE · ${name.replace(/_/g, " ")}${flat ? ` (${flat.length > 70 ? `${flat.slice(0, 69)}…` : flat})` : ""}`
33
+ }
34
+
27
35
  /** JSON-RPC dispatch for MCP: initialize, ping, tools/list, tools/call, and cancellation. */
28
36
  export function createMcpServer(opts: McpServerOptions) {
29
37
  const running = new Map<string | number, AbortController>()
38
+ /** Every result of this server's tools, for write_report; the agent adds the results of the other MCP servers. */
39
+ const evidence = new EvidenceLog()
30
40
 
31
41
  async function callTool(params: Json, id: string | number, send: Send, outer?: AbortSignal): Promise<Json> {
32
42
  const tool = opts.tools.find((t) => t.name === params.name)
33
43
  if (!tool) return { content: [{ type: "text", text: `Unknown tool ${params.name}` }], isError: true }
34
- const base = opts.context()
44
+ const base = { evidence, ...opts.context() }
35
45
  const controller = new AbortController()
36
46
  const abort = () => controller.abort()
37
47
  base.signal?.addEventListener("abort", abort, { once: true })
@@ -63,10 +73,13 @@ export function createMcpServer(opts: McpServerOptions) {
63
73
  }
64
74
  : undefined
65
75
  const callId = `${tool.name}-${String(id)}-${started}`
66
- const said = (ok: boolean) => {
67
- if (tool.line && emit) emit({ type: "call", text: tool.line(params.arguments || {}), ok, id: callId })
68
- }
69
- if (tool.line && emit) emit({ type: "call-start", text: tool.line(params.arguments || {}), id: callId })
76
+ // Every call is said as it starts and ends. A tool with no line of its own shows its result another way (a card,
77
+ // a run), so its call is named for the Activity panel only: "ForgeQE · write report (Open defects, 13 October)".
78
+ const args = params.arguments || {}
79
+ const label = tool.line ? tool.line(args) : callLabel(tool.name, args)
80
+ const quiet = tool.line ? {} : { quiet: true }
81
+ const said = (ok: boolean) => emit?.({ type: "call", text: label, ok, id: callId, ...quiet })
82
+ emit?.({ type: "call-start", text: label, id: callId, ...quiet })
70
83
  let out: unknown
71
84
  try {
72
85
  out = await tool.run(params.arguments || {}, { ...base, emit, signal: controller.signal })
@@ -76,7 +89,9 @@ export function createMcpServer(opts: McpServerOptions) {
76
89
  }
77
90
  if (controller.signal.aborted) return finish({ content: [{ type: "text", text: "Stopped by the user." }], isError: true })
78
91
  said(true)
79
- return finish({ content: [{ type: "text", text: typeof out === "string" ? out : JSON.stringify(out, null, 2) }] })
92
+ const text = typeof out === "string" ? out : JSON.stringify(out, null, 2)
93
+ base.evidence.record(tool.name, args, text)
94
+ return finish({ content: [{ type: "text", text }] })
80
95
  } catch (err) {
81
96
  return finish({ content: [{ type: "text", text: controller.signal.aborted ? "Stopped by the user." : (err as Error).message || String(err) }], isError: true })
82
97
  } finally {
@@ -116,7 +131,7 @@ export function createMcpServer(opts: McpServerOptions) {
116
131
  }
117
132
  }
118
133
 
119
- return { handle, abortAll: () => running.forEach((c) => c.abort()) }
134
+ return { handle, evidence, abortAll: () => running.forEach((c) => c.abort()) }
120
135
  }
121
136
 
122
137
  export type McpServer = ReturnType<typeof createMcpServer>
package/src/mcp/tools.ts CHANGED
@@ -6,6 +6,7 @@ import type { Card } from "../card.ts"
6
6
  import { privateEnvNames } from "../childEnv.ts"
7
7
  import { usageLine } from "../usage.ts"
8
8
  import { queryApi } from "../cases/api.ts"
9
+ import type { EvidenceLog } from "../report/evidence.ts"
9
10
  import { createAdoDashboard, type DashboardWidget } from "../cases/dashboards.ts"
10
11
  import { writeAdoWikiPage } from "../cases/wiki.ts"
11
12
  import { readConfluence } from "../cases/confluence.ts"
@@ -102,9 +103,12 @@ export type ToolEvent =
102
103
  /** write_report saved a report: its page and its Markdown. */
103
104
  | { type: "report"; title: string; html: string; markdown: string }
104
105
  /** A finished call of a tool that has no card of its own, as one line: "Azure DevOps API · QI-Claims/_apis/build/builds (top 5)". */
105
- | { type: "call"; text: string; ok: boolean; id?: string }
106
- /** The same call as it starts, so a long one is visible while it runs; its `call` with the same `id` follows. */
107
- | { type: "call-start"; text: string; id: string }
106
+ | { type: "call"; text: string; ok: boolean; id?: string; quiet?: boolean }
107
+ /**
108
+ * The same call as it starts, so a long one is visible while it runs; its `call` with the same `id` follows.
109
+ * `quiet`: a tool that shows its own result (a card, a run): the call is for the Activity panel only.
110
+ */
111
+ | { type: "call-start"; text: string; id: string; quiet?: boolean }
108
112
 
109
113
  const API_LABEL: Record<string, string> = { azure_devops: "Azure DevOps", jira: "Jira", xray: "Xray", confluence: "Confluence" }
110
114
 
@@ -114,7 +118,12 @@ const API_LABEL: Record<string, string> = { azure_devops: "Azure DevOps", jira:
114
118
  */
115
119
  export function queryLine(args: Record<string, any>): string {
116
120
  const name = `${API_LABEL[String(args.system)] || String(args.system || "API")} API`
117
- if (args.wiql) return `${name} · WIQL (${clip(String(args.wiql).replace(/\s+/g, " ").replace(/^SELECT .*? FROM WorkItems WHERE /i, ""), 90)})`
121
+ if (args.wiql) {
122
+ // The project first: it is what tells one search from the next, and the @project clause then says nothing more.
123
+ const where = String(args.wiql).replace(/\s+/g, " ").replace(/^SELECT .*? FROM WorkItems WHERE /i, "")
124
+ const query = args.project ? where.replace(/^\[System\.TeamProject\] = @project AND /i, "") : where
125
+ return `${name} · WIQL${args.project ? ` · ${args.project}` : ""} (${clip(query, 90)})`
126
+ }
118
127
  if (args.query) return `${name} · GraphQL (${clip(String(args.query).replace(/\s+/g, " "), 90)})`
119
128
  const raw = String(args.path || "")
120
129
  const url = /^https?:\/\//.test(raw) ? raw.replace(/^https?:\/\/[^/]+\/(?:[^/]+\/(?=[^/]+\/_apis\/))?/, "") : raw.replace(/^\//, "")
@@ -156,6 +165,8 @@ export type ToolContext = {
156
165
  openSession?: (o: any) => Promise<OpencodeSession<any>>
157
166
  /** Replaces write_test's confirming run in tests. */
158
167
  runTest?: typeof runTestCommand
168
+ /** The session's tool results, which write_report builds its tables from. */
169
+ evidence?: EvidenceLog
159
170
  }
160
171
 
161
172
  /** `lines`: context shown above the choice (the list being confirmed, sprint dates). */
@@ -705,7 +716,7 @@ export const TOOLS: McpTool[] = [
705
716
  wiql: {
706
717
  type: "string",
707
718
  description:
708
- "azure_devops: a WIQL query to search work items (instead of path), e.g. SELECT [System.Id] FROM WorkItems WHERE [System.TeamProject] = @project AND [System.WorkItemType] = 'User Story' ORDER BY [System.ChangedDate] DESC. Returns the first 50 with id, type, title, state, assignee, changed date, and iteration. Use it to find or list work items; the Analytics (_odata) endpoints need a token with the Analytics scope.",
719
+ "azure_devops: a WIQL query to search work items (instead of path), e.g. SELECT [System.Id] FROM WorkItems WHERE [System.TeamProject] = @project AND [System.WorkItemType] = 'User Story' ORDER BY [System.ChangedDate] DESC. Returns the first 50 with id, project, type, title, state, priority, severity, assignee, area, iteration, tags, created and changed dates, and a link. Use it to find or list work items; the Analytics (_odata) endpoints need a token with the Analytics scope.",
709
720
  },
710
721
  project: { type: "string", description: "azure_devops wiql: the project @project refers to (default: the configured project)" },
711
722
  variables: { type: "object", description: "xray: GraphQL variables" },