@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.
@@ -0,0 +1,313 @@
1
+ /**
2
+ * Evidence for reports: every tool result of the session, kept as it came back, so a report's tables are built from
3
+ * what the tools returned and not from what the model wrote. A dataset names the calls it comes from (or a read-only
4
+ * query ForgeQE runs itself); its rows are taken from their output, flattened into columns.
5
+ */
6
+
7
+ export type EvidenceEntry = {
8
+ /** E1, E2, …: how a report cites it. */
9
+ id: string
10
+ tool: string
11
+ input: Record<string, unknown>
12
+ output: string
13
+ at: string
14
+ }
15
+
16
+ const MAX_ENTRIES = 300
17
+ const MAX_OUTPUT = 4_000_000
18
+
19
+ /** Tools whose results are never data for a report: files, the shell, the agent's own bookkeeping. */
20
+ const NOT_DATA = new Set(["write_report", "read", "edit", "write", "patch", "multiedit", "bash", "glob", "grep", "list", "todowrite", "todoread", "skill", "task", "StructuredOutput"])
21
+
22
+ export class EvidenceLog {
23
+ private entries: EvidenceEntry[] = []
24
+ private n = 0
25
+
26
+ /** Keeps a successful call's result; files, the shell, and empty results are not evidence. */
27
+ record(tool: string, input: Record<string, unknown>, output: string): EvidenceEntry | null {
28
+ if (NOT_DATA.has(tool) || NOT_DATA.has(bareTool(tool)) || !output.trim()) return null
29
+ const entry = { id: `E${++this.n}`, tool, input, output: output.length > MAX_OUTPUT ? output.slice(0, MAX_OUTPUT) : output, at: new Date().toISOString() }
30
+ this.entries.push(entry)
31
+ if (this.entries.length > MAX_ENTRIES) this.entries.shift()
32
+ return entry
33
+ }
34
+
35
+ all(): EvidenceEntry[] {
36
+ return [...this.entries]
37
+ }
38
+ }
39
+
40
+ /** A tool's name without its MCP server prefix: forgeqe_query_api → query_api, azure_devops_wit_my_work_items → wit_my_work_items. */
41
+ export function bareTool(tool: string): string {
42
+ for (const prefix of ["forgeqe_", "azure_devops_", "atlassian_", "confluence_", "github_", "jira_"]) if (tool.startsWith(prefix)) return tool.slice(prefix.length)
43
+ return tool
44
+ }
45
+
46
+ export type Cell = string | number | boolean | null
47
+ export type Row = Record<string, Cell>
48
+ export type ColumnKind = "text" | "number" | "date" | "link"
49
+ export type Column = { key: string; label: string; kind: ColumnKind }
50
+
51
+ /** Field names as people read them; anything else keeps its own name, split into words. */
52
+ const LABELS: Record<string, string> = {
53
+ id: "ID",
54
+ key: "Key",
55
+ teamproject: "Project",
56
+ project: "Project",
57
+ workitemtype: "Type",
58
+ issuetype: "Type",
59
+ type: "Type",
60
+ title: "Title",
61
+ summary: "Title",
62
+ name: "Name",
63
+ state: "State",
64
+ status: "Status",
65
+ assignedto: "Assigned To",
66
+ assignee: "Assigned To",
67
+ priority: "Priority",
68
+ severity: "Severity",
69
+ areapath: "Area",
70
+ area: "Area",
71
+ iterationpath: "Iteration",
72
+ iteration: "Iteration",
73
+ sprint: "Sprint",
74
+ reason: "Reason",
75
+ tags: "Tags",
76
+ labels: "Tags",
77
+ createddate: "Created",
78
+ created: "Created",
79
+ changeddate: "Changed",
80
+ changed: "Changed",
81
+ updated: "Changed",
82
+ resolveddate: "Resolved",
83
+ resolved: "Resolved",
84
+ closeddate: "Closed",
85
+ createdby: "Created By",
86
+ reporter: "Reported By",
87
+ resolution: "Resolution",
88
+ components: "Components",
89
+ fixversions: "Fix Versions",
90
+ result: "Result",
91
+ outcome: "Result",
92
+ buildnumber: "Build",
93
+ sourcebranch: "Branch",
94
+ definition: "Pipeline",
95
+ pipeline: "Pipeline",
96
+ requestedfor: "Requested For",
97
+ starttime: "Started",
98
+ finishtime: "Finished",
99
+ queuetime: "Queued",
100
+ }
101
+
102
+ /** Columns a reader filters by first, in this order, when the data has them. */
103
+ const FILTER_FIRST = ["Project", "State", "Status", "Type", "Priority", "Severity", "Assigned To", "Area", "Iteration", "Sprint", "Result", "Pipeline", "Branch", "Reason", "Resolution", "Components"]
104
+ /** Columns read first in the table. */
105
+ const COLUMN_FIRST = ["ID", "Key", "Title", "Name", "Project", "Type", "State", "Status", "Priority", "Severity", "Assigned To", "Result", "Area", "Iteration", "Created", "Changed"]
106
+ /** Fields that are plumbing, not data. */
107
+ const DROP = new Set(["_links", "url", "self", "rev", "commentversionref", "relations", "expand", "avatarurls", "iconurl", "watermark", "boardcolumndone", "nodeid", "authorizedas", "authorizeddate", "reviseddate", "reviseby", "revisedby", "personid", "descriptor", "imageurl", "uniquename"])
108
+
109
+ const isPlain = (v: unknown): v is Record<string, unknown> => !!v && typeof v === "object" && !Array.isArray(v)
110
+
111
+ /** The last part of a dotted field name: System.TeamProject → TeamProject, Microsoft.VSTS.Common.Priority → Priority. */
112
+ function fieldName(k: string): string {
113
+ const last = k.includes(".") ? k.slice(k.lastIndexOf(".") + 1) : k
114
+ return last.replace(/^custom_?/i, "")
115
+ }
116
+
117
+ export function columnLabel(k: string): string {
118
+ const name = fieldName(k)
119
+ const known = LABELS[name.toLowerCase().replace(/[\s_-]/g, "")]
120
+ if (known) return known
121
+ const words = name.replace(/_/g, " ").replace(/([a-z])([A-Z])/g, "$1 $2").trim()
122
+ return words.charAt(0).toUpperCase() + words.slice(1)
123
+ }
124
+
125
+ /** A value as one cell: a person by their name, a status by its name, a list joined. */
126
+ function cell(v: unknown): Cell | undefined {
127
+ if (v === null || v === undefined || v === "") return undefined
128
+ if (typeof v === "string" || typeof v === "number" || typeof v === "boolean") return v
129
+ if (Array.isArray(v)) {
130
+ const parts = v.map((x) => cell(x)).filter((x) => x !== undefined && x !== null)
131
+ return parts.length ? parts.join(", ") : undefined
132
+ }
133
+ if (isPlain(v)) {
134
+ for (const k of ["displayName", "name", "value", "key", "title", "label"]) if (typeof v[k] === "string" || typeof v[k] === "number") return v[k] as string | number
135
+ }
136
+ return undefined
137
+ }
138
+
139
+ /** A web page for the item, when its record says where: Azure DevOps, Jira, GitHub, or a plain web URL. */
140
+ function webLink(o: Record<string, unknown>, idValue: Cell | undefined): string | undefined {
141
+ const links = isPlain(o._links) ? o._links : undefined
142
+ const html = links && isPlain(links.html) ? links.html.href : undefined
143
+ if (typeof html === "string") return html
144
+ for (const k of ["html_url", "webUrl", "web_url", "webLink"]) if (typeof o[k] === "string") return o[k] as string
145
+ if (typeof o.url === "string") {
146
+ // https://dev.azure.com/acme/<project id>/_apis/wit/workItems/123 → https://dev.azure.com/acme/_workitems/edit/123
147
+ const ado = /^(https:\/\/[^/]+(?:\/[^/_][^/]*)?)\/(?:[^/]+\/)?_apis\/wit\/workItems\/(\d+)/i.exec(o.url)
148
+ if (ado) return `${ado[1]}/_workitems/edit/${ado[2]}`
149
+ if (!/\/_apis\/|\/rest\//.test(o.url)) return o.url
150
+ }
151
+ if (typeof o.self === "string" && typeof idValue === "string" && /^[A-Z][A-Z0-9_]+-\d+$/.test(idValue)) {
152
+ const m = /^(https?:\/\/[^/]+(?:\/[^/]+)*?)\/rest\/api\//.exec(o.self)
153
+ if (m) return `${m[1]}/browse/${idValue}`
154
+ }
155
+ return undefined
156
+ }
157
+
158
+ /** One record as a row: its own fields and those under `fields` (Azure DevOps, Jira), named for people. */
159
+ export function flattenRow(o: Record<string, unknown>): Row {
160
+ const row: Row = {}
161
+ const put = (k: string, v: unknown) => {
162
+ if (DROP.has(k.toLowerCase()) || DROP.has(fieldName(k).toLowerCase())) return
163
+ const c = cell(v)
164
+ if (c === undefined) return
165
+ const label = columnLabel(k)
166
+ if (!(label in row)) row[label] = c
167
+ }
168
+ for (const [k, v] of Object.entries(o)) if (k !== "fields") put(k, v)
169
+ if (isPlain(o.fields)) for (const [k, v] of Object.entries(o.fields)) put(k, v)
170
+ // Jira's record id is numeric and its key is what people use.
171
+ if ("Key" in row && "ID" in row && typeof o.key === "string") delete row.ID
172
+ const link = webLink(o, row.Key ?? row.ID)
173
+ if (link) row.Link = link
174
+ return row
175
+ }
176
+
177
+ /** The JSON in a tool's output: the whole of it, or the first object or list in its text. */
178
+ export function parseOutput(output: string): unknown {
179
+ const text = output.trim()
180
+ try {
181
+ return JSON.parse(text)
182
+ } catch {}
183
+ const start = text.search(/[[{]/)
184
+ if (start < 0) return undefined
185
+ try {
186
+ return JSON.parse(text.slice(start))
187
+ } catch {
188
+ return undefined
189
+ }
190
+ }
191
+
192
+ /** The records in a result: the largest list of objects in it, looked for a few levels down. */
193
+ export function findRecords(value: unknown, depth = 0): Record<string, unknown>[] {
194
+ if (typeof value === "string" && depth === 0) return findRecords(parseOutput(value), 1)
195
+ if (Array.isArray(value)) {
196
+ const objs = value.filter(isPlain)
197
+ if (objs.length && objs.length >= value.length / 2) return objs
198
+ return []
199
+ }
200
+ if (!isPlain(value) || depth > 4) return []
201
+ // An MCP result wraps its JSON in content[].text.
202
+ if (Array.isArray(value.content) && value.content.every((c) => isPlain(c) && typeof c.text === "string")) {
203
+ return findRecords((value.content as { text: string }[]).map((c) => c.text).join("\n"), 0)
204
+ }
205
+ let best: Record<string, unknown>[] = []
206
+ for (const v of Object.values(value)) {
207
+ const found = findRecords(typeof v === "string" && /^\s*[[{]/.test(v) ? parseOutput(v) : v, depth + 1)
208
+ if (found.length > best.length) best = found
209
+ }
210
+ return best
211
+ }
212
+
213
+ /** The rows in a tool's output; null when it holds no data (text, an error). An empty list is data: nothing matched. */
214
+ export function rowsOf(output: string): Row[] | null {
215
+ const value = parseOutput(output)
216
+ if (value === undefined || typeof value !== "object" || value === null) return null
217
+ const records = findRecords(value, 1)
218
+ if (records.length) return records.map(flattenRow)
219
+ if (Array.isArray(value)) return value.length ? null : []
220
+ // A list that came back empty ({ count: 0, items: [] }) or a single record (one pipeline run).
221
+ const lists = Object.values(value).filter(Array.isArray)
222
+ if (lists.length && lists.every((l) => !l.length)) return []
223
+ return [flattenRow(value as Record<string, unknown>)]
224
+ }
225
+
226
+ const ISO_DATE = /^\d{4}-\d{2}-\d{2}(?:[T ]\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?)?$/
227
+
228
+ function kindOf(rows: Row[], key: string): ColumnKind {
229
+ const vals = rows.map((r) => r[key]).filter((v) => v !== null && v !== undefined)
230
+ if (!vals.length) return "text"
231
+ if (vals.every((v) => typeof v === "number")) return "number"
232
+ if (vals.every((v) => typeof v === "string" && ISO_DATE.test(v))) return "date"
233
+ if (vals.every((v) => typeof v === "string" && /^https?:\/\//.test(v))) return "link"
234
+ return "text"
235
+ }
236
+
237
+ /** The columns to show: those asked for, or the ones people read first and then the rest, up to 12. */
238
+ export function chooseColumns(rows: Row[], asked?: string[]): Column[] {
239
+ const present: string[] = []
240
+ for (const r of rows) for (const k of Object.keys(r)) if (!present.includes(k)) present.push(k)
241
+ const find = (name: string) => present.find((k) => k.toLowerCase() === name.toLowerCase()) || present.find((k) => k.toLowerCase() === columnLabel(name).toLowerCase())
242
+ let keys: string[]
243
+ if (asked?.length) {
244
+ keys = asked.map(find).filter((k): k is string => !!k)
245
+ if (!keys.length) throw new Error(`None of the columns ${asked.join(", ")} is in the data. Its columns: ${present.join(", ")}.`)
246
+ } else {
247
+ // A column with the same value in every row (Type: Bug) says nothing in a table; the filters and counts still have it.
248
+ const varies = (k: string) => rows.length < 2 || ["ID", "Key", "Title", "Name"].includes(k) || new Set(rows.map((r) => r[k] ?? null)).size > 1
249
+ const first = COLUMN_FIRST.filter((k) => present.includes(k) && varies(k))
250
+ const rest = present.filter((k) => k !== "Link" && !first.includes(k) && varies(k) && rows.some((r) => typeof r[k] !== "string" || String(r[k]).length <= 200))
251
+ keys = [...first, ...rest].slice(0, 10)
252
+ }
253
+ if (present.includes("Link") && !keys.includes("Link")) keys.push("Link")
254
+ return keys.map((key) => ({ key, label: key, kind: key === "Link" ? "link" : kindOf(rows, key) }))
255
+ }
256
+
257
+ /** Columns worth a filter: those asked for, or the usual ones (project, state, priority, …) and other short lists of values. */
258
+ export function chooseFilters(rows: Row[], columns: Column[], asked?: string[]): string[] {
259
+ const distinct = (k: string) => new Set(rows.map((r) => r[k]).filter((v) => v !== null && v !== undefined && v !== "")).size
260
+ const text = columns.filter((c) => c.kind === "text").map((c) => c.key)
261
+ if (asked?.length) return asked.map((a) => columns.find((c) => c.key.toLowerCase() === a.toLowerCase() || c.key.toLowerCase() === columnLabel(a).toLowerCase())?.key).filter((k): k is string => !!k)
262
+ // Priority and severity are numbers in Azure DevOps, and still categories.
263
+ const known = FILTER_FIRST.filter((k) => columns.some((c) => c.key === k && c.kind !== "date" && c.kind !== "link") && distinct(k) >= 2 && distinct(k) <= 60)
264
+ const other = text.filter((k) => !known.includes(k) && !["ID", "Key", "Title", "Name"].includes(k)).filter((k) => {
265
+ const n = distinct(k)
266
+ return n >= 2 && n <= 15 && n <= rows.length / 2
267
+ })
268
+ return [...known, ...other].slice(0, 6)
269
+ }
270
+
271
+ /** Rows that are the same record (the same ID or key) from more than one call, kept once. */
272
+ export function dedupe(rows: Row[]): Row[] {
273
+ const seen = new Set<string>()
274
+ return rows.filter((r) => {
275
+ const id = r.Key ?? r.ID
276
+ if (id === undefined || id === null) return true
277
+ const k = String(id)
278
+ if (seen.has(k)) return false
279
+ seen.add(k)
280
+ return true
281
+ })
282
+ }
283
+
284
+ /** What a call was, in one line, for the list of sources. */
285
+ export function describeCall(e: Pick<EvidenceEntry, "tool" | "input">): string {
286
+ const i = e.input
287
+ const what = [i.wiql, i.jql, i.query, i.path, i.project, i.ids, i.searchText, i.title].find((v) => (typeof v === "string" && v.trim()) || Array.isArray(v))
288
+ // A long list of ids as its count and ends: "42 ids: 1000, 1001, … 1041".
289
+ const text = Array.isArray(what) ? (what.length > 6 ? `${what.length} ids: ${what.slice(0, 2).join(", ")}, … ${what.at(-1)}` : what.join(", ")) : String(what || "")
290
+ const flat = text.replace(/\s+/g, " ").trim()
291
+ return `${e.tool}${flat ? `: ${flat.length > 160 ? `${flat.slice(0, 159)}…` : flat}` : ""}`
292
+ }
293
+
294
+ /** The evidence a dataset names: an id (E3), or every successful call of a tool, optionally those whose input mentions `match`. */
295
+ export function pickEvidence(log: EvidenceEntry[], from: string, match?: string): EvidenceEntry[] {
296
+ const ids = from.split(/[\s,]+/).filter(Boolean)
297
+ if (ids.length && ids.every((x) => /^E\d+$/i.test(x))) return log.filter((e) => ids.some((x) => x.toUpperCase() === e.id))
298
+ const want = bareTool(from)
299
+ const calls = log.filter((e) => e.tool === from || bareTool(e.tool) === want)
300
+ return match ? calls.filter((e) => JSON.stringify(e.input).toLowerCase().includes(match.toLowerCase())) : calls
301
+ }
302
+
303
+ /** The calls a report could use, for the error that says none was found. */
304
+ export function evidenceList(log: EvidenceEntry[]): string {
305
+ if (!log.length) return "No tool has returned data in this session yet."
306
+ return log
307
+ .slice(-25)
308
+ .map((e) => {
309
+ const rows = rowsOf(e.output)
310
+ return `${e.id} ${describeCall(e)} (${rows ? `${rows.length} rows` : "no data"})`
311
+ })
312
+ .join("\n")
313
+ }
@@ -87,11 +87,15 @@ const TEMPLATE = new URL("./template.html", import.meta.url)
87
87
 
88
88
  /** A self-contained report page: the report-ui app with this run's data (and screenshots) embedded. */
89
89
  export function renderReport(p: ReportInput, opts?: DataOptions): string {
90
- const data = buildReportData({ kind: p.kind || "suite", ...p }, opts)
90
+ return reportPage(p.title, buildReportData({ kind: p.kind || "suite", ...p }, opts))
91
+ }
92
+
93
+ /** The report-ui app with `data` embedded: a run, a suite, or a data report written by write_report. */
94
+ export function reportPage(title: string, data: unknown): string {
91
95
  const json = JSON.stringify(data).replace(/</g, "\\u003c").replace(/>/g, "\\u003e").replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029")
92
- const title = p.title.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
96
+ const safe = title.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
93
97
  return readFileSync(TEMPLATE, "utf8")
94
- .replace(/<title>[^<]*<\/title>/, () => `<title>${title}</title>`)
98
+ .replace(/<title>[^<]*<\/title>/, () => `<title>${safe}</title>`)
95
99
  .replace("<head>", () => `<head>\n<script>window.__FORGEQE_REPORT__=${json}</script>`)
96
100
  }
97
101