@capagents/forgeqe 0.0.0-stage → 1.0.0

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.
Files changed (220) hide show
  1. package/HOW_TO_USE.md +1639 -0
  2. package/README.md +338 -2
  3. package/bin/forgeqe.d.ts +3 -0
  4. package/bin/forgeqe.js +188 -0
  5. package/docs/ARCHITECTURE.md +408 -0
  6. package/docs/security-data-flow.html +1870 -0
  7. package/docs/user-guide.html +2094 -0
  8. package/evals/domain/access-role-change.yaml +37 -0
  9. package/evals/domain/banking-payment-transfer.yaml +41 -0
  10. package/evals/domain/gdpr-account-erasure.yaml +30 -0
  11. package/evals/domain/insurance-claim-fnol.yaml +30 -0
  12. package/evals/domain/insurance-policy-renewal.yaml +37 -0
  13. package/evals/domain/lending-loan-application.yaml +36 -0
  14. package/forgeqe-architecture.html +718 -0
  15. package/forgeqe.example.yaml +203 -0
  16. package/knowledge/README.md +18 -0
  17. package/knowledge/business-rules.md +10 -0
  18. package/knowledge/compliance.md +16 -0
  19. package/knowledge/environments.md +5 -0
  20. package/knowledge/glossary.md +5 -0
  21. package/knowledge/product.md +16 -0
  22. package/knowledge/roles.md +9 -0
  23. package/knowledge/test-data.md +7 -0
  24. package/llms.example.json +51 -0
  25. package/package.json +63 -4
  26. package/prompts/README.md +34 -0
  27. package/prompts/api-scenarios.md +21 -0
  28. package/prompts/api-tests.md +39 -0
  29. package/prompts/brain.md +166 -0
  30. package/prompts/browser-capture.md +7 -0
  31. package/prompts/browser-case-report.md +7 -0
  32. package/prompts/browser-checks.md +14 -0
  33. package/prompts/browser-efficiency.md +7 -0
  34. package/prompts/browser-error-recovery.md +15 -0
  35. package/prompts/browser-extra.md +5 -0
  36. package/prompts/browser-goal-verification.md +13 -0
  37. package/prompts/browser-skill-select.md +6 -0
  38. package/prompts/domain-rule.md +13 -0
  39. package/prompts/eval-judge.md +19 -0
  40. package/prompts/explore-charters.md +27 -0
  41. package/prompts/find-tests.md +28 -0
  42. package/prompts/heal.md +40 -0
  43. package/prompts/pr-review.md +29 -0
  44. package/prompts/skill-rule.md +6 -0
  45. package/prompts/test-agent.md +34 -0
  46. package/prompts/test-reviewer.md +30 -0
  47. package/prompts/test-rules.md +28 -0
  48. package/py/forgeqe_engine/__init__.py +3 -0
  49. package/py/forgeqe_engine/__main__.py +3 -0
  50. package/py/forgeqe_engine/brand.py +453 -0
  51. package/py/forgeqe_engine/brand_assets.py +6 -0
  52. package/py/forgeqe_engine/browser.py +196 -0
  53. package/py/forgeqe_engine/capture.py +538 -0
  54. package/py/forgeqe_engine/checks.py +98 -0
  55. package/py/forgeqe_engine/cli.py +188 -0
  56. package/py/forgeqe_engine/diagnostics.py +609 -0
  57. package/py/forgeqe_engine/llm.py +195 -0
  58. package/py/forgeqe_engine/logins.py +251 -0
  59. package/py/forgeqe_engine/loops.py +224 -0
  60. package/py/forgeqe_engine/memory.py +1004 -0
  61. package/py/forgeqe_engine/network.py +244 -0
  62. package/py/forgeqe_engine/prompts.py +20 -0
  63. package/py/forgeqe_engine/replay.py +240 -0
  64. package/py/forgeqe_engine/report.py +173 -0
  65. package/py/forgeqe_engine/service.py +1335 -0
  66. package/py/forgeqe_engine/settings.py +93 -0
  67. package/py/forgeqe_engine/skills.py +88 -0
  68. package/py/forgeqe_engine/summary.py +239 -0
  69. package/py/forgeqe_engine/urls.py +81 -0
  70. package/py/forgeqe_engine/workflow_yaml.py +689 -0
  71. package/skills/dotnet-nunit-playwright/SKILL.md +34 -0
  72. package/skills/dynamics-365/SKILL.md +21 -0
  73. package/skills/playwright-typescript/SKILL.md +35 -0
  74. package/skills/writing-skills/SKILL.md +30 -0
  75. package/src/api/plan.ts +246 -0
  76. package/src/api/run.ts +204 -0
  77. package/src/api/scenario.ts +502 -0
  78. package/src/api/schema.ts +170 -0
  79. package/src/api/spec.ts +624 -0
  80. package/src/api/store.ts +232 -0
  81. package/src/api/traffic.ts +281 -0
  82. package/src/api/types.ts +163 -0
  83. package/src/api/write.ts +272 -0
  84. package/src/auth/signin.ts +494 -0
  85. package/src/auth/store.ts +170 -0
  86. package/src/brain/index.ts +189 -0
  87. package/src/brain/prompt.ts +19 -0
  88. package/src/capabilities/docs.ts +101 -0
  89. package/src/capabilities/guide.ts +455 -0
  90. package/src/capabilities/registry.ts +343 -0
  91. package/src/capabilities/render.ts +106 -0
  92. package/src/capabilities/status.ts +137 -0
  93. package/src/card.ts +11 -0
  94. package/src/cases/ado.ts +325 -0
  95. package/src/cases/api.ts +117 -0
  96. package/src/cases/bugs.ts +223 -0
  97. package/src/cases/confluence.ts +111 -0
  98. package/src/cases/context.ts +223 -0
  99. package/src/cases/dashboards.ts +137 -0
  100. package/src/cases/failure.ts +97 -0
  101. package/src/cases/files.ts +295 -0
  102. package/src/cases/goal.ts +53 -0
  103. package/src/cases/http.ts +111 -0
  104. package/src/cases/index.ts +131 -0
  105. package/src/cases/jira.ts +122 -0
  106. package/src/cases/qtest.ts +108 -0
  107. package/src/cases/quality.ts +190 -0
  108. package/src/cases/rag.ts +100 -0
  109. package/src/cases/readiness.ts +284 -0
  110. package/src/cases/save.ts +149 -0
  111. package/src/cases/sprints.ts +148 -0
  112. package/src/cases/testrail.ts +176 -0
  113. package/src/cases/types.ts +107 -0
  114. package/src/cases/wiki.ts +105 -0
  115. package/src/cases/xray.ts +206 -0
  116. package/src/cases/zephyr.ts +153 -0
  117. package/src/childEnv.ts +86 -0
  118. package/src/cli/args.ts +114 -0
  119. package/src/cli/help.ts +167 -0
  120. package/src/cli/index.ts +1142 -0
  121. package/src/cli/terminal.ts +187 -0
  122. package/src/code/areas.ts +337 -0
  123. package/src/code/attention.ts +214 -0
  124. package/src/code/find.ts +540 -0
  125. package/src/code/handoff.ts +541 -0
  126. package/src/code/history.ts +170 -0
  127. package/src/code/impact.ts +231 -0
  128. package/src/code/index.ts +34 -0
  129. package/src/code/livebrowser.ts +67 -0
  130. package/src/code/loop.ts +603 -0
  131. package/src/code/notes.ts +75 -0
  132. package/src/code/profile.ts +226 -0
  133. package/src/code/prompt.ts +303 -0
  134. package/src/code/prreview.ts +649 -0
  135. package/src/code/prtests.ts +103 -0
  136. package/src/code/replay.ts +772 -0
  137. package/src/code/review.ts +202 -0
  138. package/src/code/runtime.ts +757 -0
  139. package/src/code/slices.ts +159 -0
  140. package/src/code/testrun.ts +187 -0
  141. package/src/code/traceability.ts +100 -0
  142. package/src/code/worktree.ts +153 -0
  143. package/src/commands.ts +101 -0
  144. package/src/config.ts +970 -0
  145. package/src/detail.ts +65 -0
  146. package/src/domain.ts +19 -0
  147. package/src/engine/bridge.ts +359 -0
  148. package/src/engine/mock.ts +102 -0
  149. package/src/engine/setup.ts +298 -0
  150. package/src/entra.ts +154 -0
  151. package/src/env.ts +24 -0
  152. package/src/evals/domain.ts +338 -0
  153. package/src/explore/charters.ts +151 -0
  154. package/src/explore/run.ts +280 -0
  155. package/src/explore/story.ts +106 -0
  156. package/src/guard.ts +51 -0
  157. package/src/heal/loop.ts +357 -0
  158. package/src/heal/prompt.ts +143 -0
  159. package/src/heal/report.ts +95 -0
  160. package/src/index.ts +12 -0
  161. package/src/init.ts +102 -0
  162. package/src/knowledge.ts +114 -0
  163. package/src/legacyEnv.ts +9 -0
  164. package/src/llm.ts +61 -0
  165. package/src/locale.ts +25 -0
  166. package/src/logins.ts +148 -0
  167. package/src/managed.ts +101 -0
  168. package/src/mcp/apitools.ts +335 -0
  169. package/src/mcp/captools.ts +25 -0
  170. package/src/mcp/casetools.ts +223 -0
  171. package/src/mcp/commands.ts +421 -0
  172. package/src/mcp/exploretools.ts +129 -0
  173. package/src/mcp/external.ts +172 -0
  174. package/src/mcp/git.ts +78 -0
  175. package/src/mcp/notifytools.ts +49 -0
  176. package/src/mcp/prtools.ts +261 -0
  177. package/src/mcp/qualitytools.ts +154 -0
  178. package/src/mcp/repotests.ts +286 -0
  179. package/src/mcp/server.ts +234 -0
  180. package/src/mcp/tools.ts +810 -0
  181. package/src/memory.ts +250 -0
  182. package/src/money.ts +39 -0
  183. package/src/monitor/alerts.ts +94 -0
  184. package/src/monitor/background.ts +68 -0
  185. package/src/monitor/run.ts +263 -0
  186. package/src/monitor/state.ts +105 -0
  187. package/src/notify/events.ts +144 -0
  188. package/src/notify/notice.ts +177 -0
  189. package/src/prices.ts +175 -0
  190. package/src/print.ts +54 -0
  191. package/src/privacy.ts +35 -0
  192. package/src/prompts.ts +165 -0
  193. package/src/quality/budgets.ts +249 -0
  194. package/src/quality/history.ts +149 -0
  195. package/src/quality/visual.ts +201 -0
  196. package/src/report/data.ts +475 -0
  197. package/src/report/html.ts +106 -0
  198. package/src/report/template.html +84 -0
  199. package/src/run.ts +243 -0
  200. package/src/sessionLog.ts +216 -0
  201. package/src/sessionLogins.ts +94 -0
  202. package/src/skills/registry.ts +217 -0
  203. package/src/skills/scaffold.ts +65 -0
  204. package/src/skills/select.ts +37 -0
  205. package/src/skills/view.ts +44 -0
  206. package/src/suite/report.ts +135 -0
  207. package/src/suite/run.ts +303 -0
  208. package/src/summary.ts +188 -0
  209. package/src/tools.ts +130 -0
  210. package/src/tui/App.tsx +983 -0
  211. package/src/tui/clipboard.ts +36 -0
  212. package/src/tui/components.tsx +263 -0
  213. package/src/tui/dialogs.ts +65 -0
  214. package/src/tui/main.tsx +20 -0
  215. package/src/tui/text.ts +28 -0
  216. package/src/tui/theme.ts +104 -0
  217. package/src/tui/transcript.tsx +344 -0
  218. package/src/types.ts +136 -0
  219. package/src/update.ts +311 -0
  220. package/src/usage.ts +158 -0
package/src/memory.ts ADDED
@@ -0,0 +1,250 @@
1
+ import { existsSync, readdirSync, readFileSync, rmSync } from "node:fs"
2
+ import path from "node:path"
3
+
4
+ /** Readers for the engine's page memory (pages/<host>/*.json, flows/*.json) behind `forgeqe memory` and /memory. */
5
+
6
+ export type MemoryLocator = {
7
+ type: string
8
+ value: string
9
+ playwright: string
10
+ confidence: number
11
+ seen?: number
12
+ missed?: number
13
+ success?: number
14
+ failure?: number
15
+ }
16
+
17
+ export type MemoryElement = {
18
+ key: string
19
+ tag: string
20
+ role?: string | null
21
+ name?: string | null
22
+ text?: string | null
23
+ attributes?: Record<string, string>
24
+ locators: MemoryLocator[]
25
+ best?: string | null
26
+ confidence: number
27
+ visits?: number
28
+ interactions?: number
29
+ success?: number
30
+ failure?: number
31
+ first_seen?: string
32
+ last_seen?: string
33
+ last_used?: string
34
+ last_updated?: string
35
+ }
36
+
37
+ export type MemoryPage = {
38
+ host: string
39
+ path: string
40
+ title?: string
41
+ visits?: number
42
+ first_seen?: string
43
+ last_seen?: string
44
+ sample_urls?: string[]
45
+ elements: Record<string, MemoryElement>
46
+ file: string
47
+ }
48
+
49
+ export type MemoryFlow = {
50
+ goal: string
51
+ start_url?: string
52
+ host?: string
53
+ runs?: number
54
+ successes?: number
55
+ failures?: number
56
+ replays?: number
57
+ llm_steps?: number
58
+ replayed_steps?: number
59
+ last_success?: string
60
+ last_run?: string
61
+ last_status?: string
62
+ steps: { action: string; line?: string }[]
63
+ file: string
64
+ }
65
+
66
+ function readJson<T>(file: string): T | null {
67
+ try {
68
+ return JSON.parse(readFileSync(file, "utf8")) as T
69
+ } catch {
70
+ return null
71
+ }
72
+ }
73
+
74
+ function jsonFiles(dir: string): string[] {
75
+ if (!existsSync(dir)) return []
76
+ return readdirSync(dir)
77
+ .filter((f) => f.endsWith(".json"))
78
+ .map((f) => path.join(dir, f))
79
+ }
80
+
81
+ export function readPages(dir: string): MemoryPage[] {
82
+ const root = path.join(dir, "pages")
83
+ if (!existsSync(root)) return []
84
+ const pages: MemoryPage[] = []
85
+ for (const host of readdirSync(root)) {
86
+ for (const file of jsonFiles(path.join(root, host))) {
87
+ const page = readJson<Omit<MemoryPage, "file">>(file)
88
+ if (page && page.host && page.elements) pages.push({ ...page, file })
89
+ }
90
+ }
91
+ return pages
92
+ }
93
+
94
+ export function readFlows(dir: string): MemoryFlow[] {
95
+ const flows: MemoryFlow[] = []
96
+ for (const file of jsonFiles(path.join(dir, "flows"))) {
97
+ const flow = readJson<Omit<MemoryFlow, "file">>(file)
98
+ if (flow && flow.goal && Array.isArray(flow.steps)) flows.push({ ...flow, file })
99
+ }
100
+ return flows.sort((a, b) => String(b.last_run || "").localeCompare(String(a.last_run || "")))
101
+ }
102
+
103
+ const when = (iso?: string) => (iso ? iso.slice(0, 16).replace("T", " ") : "—")
104
+ const clip = (s: string, n: number) => (s.length <= n ? s : `${s.slice(0, n - 1)}…`)
105
+
106
+ function table(rows: string[][], right: number[] = []): string[] {
107
+ const widths = rows[0].map((_, i) => Math.max(...rows.map((r) => r[i].length)))
108
+ return rows.map((r) =>
109
+ r
110
+ .map((cell, i) => (i === r.length - 1 ? cell : right.includes(i) ? cell.padStart(widths[i]) : cell.padEnd(widths[i])))
111
+ .join(" ")
112
+ .trimEnd(),
113
+ )
114
+ }
115
+
116
+ function hostMatches(host: string, target: string): boolean {
117
+ const h = host.toLowerCase()
118
+ const t = target.toLowerCase().replace(/^www\./, "")
119
+ return h === t || h === `www.${t}` || h.endsWith(`.${t}`)
120
+ }
121
+
122
+ function pathPattern(template: string): RegExp {
123
+ const escaped = template.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[^/]+")
124
+ return new RegExp(`^${escaped}$`)
125
+ }
126
+
127
+ /** The page file for a URL: same host (and port), and a path that fits the stored template. */
128
+ export function findPage(pages: MemoryPage[], url: string): MemoryPage | undefined {
129
+ let u: URL
130
+ try {
131
+ u = new URL(url.includes("://") ? url : `https://${url}`)
132
+ } catch {
133
+ return undefined
134
+ }
135
+ const host = u.port ? `${u.hostname}:${u.port}` : u.hostname
136
+ const route = u.hash.startsWith("#/") || u.hash.startsWith("#!/") ? u.hash.split("?")[0] : ""
137
+ const target = (u.pathname.replace(/\/+$/, "") || "/") + route
138
+ const candidates = pages.filter((p) => p.host === host || hostMatches(p.host, u.hostname))
139
+ return candidates.find((p) => p.path === target) || candidates.find((p) => pathPattern(p.path).test(target))
140
+ }
141
+
142
+ export function memoryOverview(dir: string): string {
143
+ const pages = readPages(dir)
144
+ const flows = readFlows(dir)
145
+ if (!pages.length && !flows.length) return `No page memory yet in ${dir}\nIt fills up as live runs visit pages.`
146
+ const hosts = new Map<string, { pages: number; elements: number; flows: number; last: string }>()
147
+ for (const p of pages) {
148
+ const h = hosts.get(p.host) || { pages: 0, elements: 0, flows: 0, last: "" }
149
+ h.pages++
150
+ h.elements += Object.keys(p.elements).length
151
+ if ((p.last_seen || "") > h.last) h.last = p.last_seen || ""
152
+ hosts.set(p.host, h)
153
+ }
154
+ for (const f of flows) {
155
+ const h = hosts.get(f.host || "?") || { pages: 0, elements: 0, flows: 0, last: "" }
156
+ h.flows++
157
+ hosts.set(f.host || "?", h)
158
+ }
159
+ const rows = [["HOST", "PAGES", "ELEMENTS", "FLOWS", "LAST SEEN"]]
160
+ for (const [host, h] of [...hosts].sort((a, b) => b[1].last.localeCompare(a[1].last))) {
161
+ rows.push([host, String(h.pages), String(h.elements), String(h.flows), when(h.last)])
162
+ }
163
+ return [`Memory ${dir}`, "", ...table(rows, [1, 2, 3]), "", "forgeqe memory show <host|url> · forgeqe memory flows"].join("\n")
164
+ }
165
+
166
+ function elementLabel(e: MemoryElement): string {
167
+ const label = e.name || e.text || e.attributes?.placeholder || e.attributes?.["aria-label"] || ""
168
+ return label ? `${e.tag} "${clip(label, 36)}"` : e.tag
169
+ }
170
+
171
+ function pageDetail(page: MemoryPage, opts: { locators: boolean; limit: number }): string {
172
+ const elements = Object.values(page.elements).sort(
173
+ (a, b) => (b.interactions || 0) - (a.interactions || 0) || b.confidence - a.confidence,
174
+ )
175
+ const shown = opts.limit > 0 ? elements.slice(0, opts.limit) : elements
176
+ const lines = [
177
+ `${page.host}${page.path} ${page.title || ""}`.trimEnd(),
178
+ `${page.visits || 0} visits · ${elements.length} elements · first seen ${when(page.first_seen)} · last seen ${when(page.last_seen)}`,
179
+ "",
180
+ ]
181
+ const rows = [["CONF", "ELEMENT", "USED", "LAST SEEN", "BEST LOCATOR"]]
182
+ for (const e of shown) {
183
+ const used = e.interactions ? `${e.interactions}× ${e.failure ? `${e.failure} failed` : "ok"}` : "—"
184
+ rows.push([`${e.confidence}%`, elementLabel(e), used, when(e.last_seen), e.best || "—"])
185
+ }
186
+ const body = table(rows, [0])
187
+ if (!opts.locators) {
188
+ lines.push(...body)
189
+ } else {
190
+ lines.push(body[0])
191
+ shown.forEach((e, i) => {
192
+ lines.push(body[i + 1])
193
+ for (const l of e.locators) {
194
+ const evidence = `seen ${l.seen || 0}${l.missed ? `, missed ${l.missed}` : ""}${l.success ? `, ok ${l.success}` : ""}${l.failure ? `, failed ${l.failure}` : ""}`
195
+ lines.push(` ${String(l.confidence).padStart(2)}% ${l.type.padEnd(11)} ${l.playwright} (${evidence})`)
196
+ }
197
+ })
198
+ }
199
+ if (shown.length < elements.length) lines.push("", `${elements.length - shown.length} more; pass --all to list every element.`)
200
+ return lines.join("\n")
201
+ }
202
+
203
+ export function memoryShow(dir: string, target: string, opts: { locators?: boolean; limit?: number } = {}): string {
204
+ const pages = readPages(dir)
205
+ const detail = { locators: Boolean(opts.locators), limit: opts.limit ?? 40 }
206
+ const looksLikeUrl = target.includes("://") || target.includes("/")
207
+ if (looksLikeUrl) {
208
+ const page = findPage(pages, target)
209
+ return page ? pageDetail(page, detail) : `No memory for ${target} yet.`
210
+ }
211
+ const onHost = pages.filter((p) => hostMatches(p.host, target))
212
+ if (!onHost.length) return `No memory for ${target} yet.`
213
+ if (onHost.length === 1) return pageDetail(onHost[0], detail)
214
+ const rows = [["PATH", "VISITS", "ELEMENTS", "LAST SEEN", "TITLE"]]
215
+ for (const p of onHost.sort((a, b) => (b.visits || 0) - (a.visits || 0))) {
216
+ rows.push([p.path, String(p.visits || 0), String(Object.keys(p.elements).length), when(p.last_seen), clip(p.title || "", 50)])
217
+ }
218
+ const host = onHost[0].host
219
+ return [`${host} (${onHost.length} pages)`, "", ...table(rows, [1, 2]), "", `forgeqe memory show https://${host}<path> for a page's elements`].join("\n")
220
+ }
221
+
222
+ export function memoryFlows(dir: string, opts: { steps?: boolean } = {}): string {
223
+ const flows = readFlows(dir)
224
+ if (!flows.length) return "No recorded flows yet. A goal is recorded after its first successful live run."
225
+ const rows = [["PASSED", "STEPS", "REPLAYS", "LAST PASSED", "GOAL"]]
226
+ for (const f of flows) {
227
+ rows.push([`${f.successes || 0}/${f.runs || 0}`, String(f.steps.length), String(f.replays || 0), when(f.last_success), clip(f.goal, 70)])
228
+ }
229
+ const lines = table(rows, [1, 2])
230
+ if (opts.steps) {
231
+ for (const f of flows) {
232
+ lines.push("", f.goal)
233
+ f.steps.forEach((s, i) => lines.push(` ${String(i + 1).padStart(2)}. ${s.line || s.action}`))
234
+ }
235
+ }
236
+ return lines.join("\n")
237
+ }
238
+
239
+ /** Files that `clear` would delete: everything, or one host's pages and flows. */
240
+ export function memoryFilesFor(dir: string, host?: string): string[] {
241
+ if (!host) return [...readPages(dir).map((p) => p.file), ...readFlows(dir).map((f) => f.file)]
242
+ return [
243
+ ...readPages(dir).filter((p) => hostMatches(p.host, host)).map((p) => p.file),
244
+ ...readFlows(dir).filter((f) => f.host && hostMatches(f.host, host)).map((f) => f.file),
245
+ ]
246
+ }
247
+
248
+ export function clearMemory(files: string[]): void {
249
+ for (const file of files) rmSync(file, { force: true })
250
+ }
package/src/money.ts ADDED
@@ -0,0 +1,39 @@
1
+ /** Region (ISO 3166-1 alpha-2) to the currency people are paid in there. */
2
+ const REGION_CURRENCY: Record<string, string> = {
3
+ AD: "EUR", AE: "AED", AF: "AFN", AG: "XCD", AI: "XCD", AL: "ALL", AM: "AMD", AO: "AOA", AR: "ARS", AS: "USD", AT: "EUR", AU: "AUD", AW: "AWG", AX: "EUR", AZ: "AZN",
4
+ BA: "BAM", BB: "BBD", BD: "BDT", BE: "EUR", BF: "XOF", BG: "EUR", BH: "BHD", BI: "BIF", BJ: "XOF", BL: "EUR", BM: "BMD", BN: "BND", BO: "BOB", BQ: "USD", BR: "BRL", BS: "BSD", BT: "BTN", BW: "BWP", BY: "BYN", BZ: "BZD",
5
+ CA: "CAD", CC: "AUD", CD: "CDF", CF: "XAF", CG: "XAF", CH: "CHF", CI: "XOF", CK: "NZD", CL: "CLP", CM: "XAF", CN: "CNY", CO: "COP", CR: "CRC", CU: "CUP", CV: "CVE", CW: "ANG", CX: "AUD", CY: "EUR", CZ: "CZK",
6
+ DE: "EUR", DJ: "DJF", DK: "DKK", DM: "XCD", DO: "DOP", DZ: "DZD", EC: "USD", EE: "EUR", EG: "EGP", EH: "MAD", ER: "ERN", ES: "EUR", ET: "ETB", FI: "EUR", FJ: "FJD", FK: "FKP", FM: "USD", FO: "DKK", FR: "EUR",
7
+ GA: "XAF", GB: "GBP", GD: "XCD", GE: "GEL", GF: "EUR", GG: "GBP", GH: "GHS", GI: "GIP", GL: "DKK", GM: "GMD", GN: "GNF", GP: "EUR", GQ: "XAF", GR: "EUR", GT: "GTQ", GU: "USD", GW: "XOF", GY: "GYD",
8
+ HK: "HKD", HN: "HNL", HR: "EUR", HT: "HTG", HU: "HUF", ID: "IDR", IE: "EUR", IL: "ILS", IM: "GBP", IN: "INR", IO: "USD", IQ: "IQD", IR: "IRR", IS: "ISK", IT: "EUR", JE: "GBP", JM: "JMD", JO: "JOD", JP: "JPY",
9
+ KE: "KES", KG: "KGS", KH: "KHR", KI: "AUD", KM: "KMF", KN: "XCD", KP: "KPW", KR: "KRW", KW: "KWD", KY: "KYD", KZ: "KZT", LA: "LAK", LB: "LBP", LC: "XCD", LI: "CHF", LK: "LKR", LR: "LRD", LS: "LSL", LT: "EUR", LU: "EUR", LV: "EUR", LY: "LYD",
10
+ MA: "MAD", MC: "EUR", MD: "MDL", ME: "EUR", MF: "EUR", MG: "MGA", MH: "USD", MK: "MKD", ML: "XOF", MM: "MMK", MN: "MNT", MO: "MOP", MP: "USD", MQ: "EUR", MR: "MRU", MS: "XCD", MT: "EUR", MU: "MUR", MV: "MVR", MW: "MWK", MX: "MXN", MY: "MYR", MZ: "MZN",
11
+ NA: "NAD", NC: "XPF", NE: "XOF", NF: "AUD", NG: "NGN", NI: "NIO", NL: "EUR", NO: "NOK", NP: "NPR", NR: "AUD", NU: "NZD", NZ: "NZD", OM: "OMR", PA: "PAB", PE: "PEN", PF: "XPF", PG: "PGK", PH: "PHP", PK: "PKR", PL: "PLN", PM: "EUR", PN: "NZD", PR: "USD", PS: "ILS", PT: "EUR", PW: "USD", PY: "PYG",
12
+ QA: "QAR", RE: "EUR", RO: "RON", RS: "RSD", RU: "RUB", RW: "RWF", SA: "SAR", SB: "SBD", SC: "SCR", SD: "SDG", SE: "SEK", SG: "SGD", SH: "SHP", SI: "EUR", SJ: "NOK", SK: "EUR", SL: "SLE", SM: "EUR", SN: "XOF", SO: "SOS", SR: "SRD", SS: "SSP", ST: "STN", SV: "USD", SX: "ANG", SY: "SYP", SZ: "SZL",
13
+ TC: "USD", TD: "XAF", TG: "XOF", TH: "THB", TJ: "TJS", TK: "NZD", TL: "USD", TM: "TMT", TN: "TND", TO: "TOP", TR: "TRY", TT: "TTD", TV: "AUD", TW: "TWD", TZ: "TZS", UA: "UAH", UG: "UGX", US: "USD", UY: "UYU", UZ: "UZS", VA: "EUR", VC: "XCD", VE: "VES", VG: "USD", VI: "USD", VN: "VND", VU: "VUV",
14
+ WF: "XPF", WS: "WST", YE: "YER", YT: "EUR", ZA: "ZAR", ZM: "ZMW", ZW: "ZWG",
15
+ }
16
+
17
+ /** ISO 4217 currency for a locale (en-GB → GBP). Unknown regions fall back to USD. */
18
+ export function currencyForLocale(locale?: string): string {
19
+ const tag = locale || Intl.DateTimeFormat().resolvedOptions().locale || "en-US"
20
+ try {
21
+ const region = new Intl.Locale(tag).maximize().region
22
+ if (region && REGION_CURRENCY[region]) return REGION_CURRENCY[region]
23
+ } catch {
24
+ /* not a locale tag */
25
+ }
26
+ return "USD"
27
+ }
28
+
29
+ /** An ISO currency code Intl can format, or undefined. */
30
+ export function currencyCode(raw: string): string | undefined {
31
+ const code = raw.trim().toUpperCase()
32
+ if (!/^[A-Z]{3}$/.test(code)) return undefined
33
+ try {
34
+ Intl.NumberFormat("en", { style: "currency", currency: code }).format(0)
35
+ return code
36
+ } catch {
37
+ return undefined
38
+ }
39
+ }
@@ -0,0 +1,94 @@
1
+ import type { MonitorAlerts } from "../config.ts"
2
+ import { failureShot, noticeEmail, sendNotice, teamsCard, type Notice, type SendDeps, type Sent } from "../notify/notice.ts"
3
+ import type { AlertItem } from "./state.ts"
4
+
5
+ export type { Sent }
6
+
7
+ /** One alert per round, covering every check that started failing, is still failing, or recovered. */
8
+ export type AlertMessage = {
9
+ title: string
10
+ items: AlertItem[]
11
+ /** The round: when it ran, how many checks passed, and where its report is. */
12
+ at: string
13
+ passed: number
14
+ total: number
15
+ report?: string
16
+ reportUrl?: string
17
+ test?: boolean
18
+ }
19
+
20
+ const MAX_ATTACHMENTS = 5
21
+
22
+ function firstLine(s: string, max = 300): string {
23
+ const line = s.split("\n").find((l) => l.trim()) || ""
24
+ return line.length <= max ? line.trim() : `${line.trim().slice(0, max - 1)}…`
25
+ }
26
+
27
+ function clock(iso: string): string {
28
+ const d = new Date(iso)
29
+ return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`
30
+ }
31
+
32
+ /** "45m", "2h 10m", "3d 4h": how long since `from`. */
33
+ export function elapsed(from: string, to: string): string {
34
+ const mins = Math.max(0, Math.round((Date.parse(to) - Date.parse(from)) / 60_000))
35
+ if (mins < 60) return `${mins}m`
36
+ const h = Math.floor(mins / 60)
37
+ if (h < 24) return `${h}h${mins % 60 ? ` ${mins % 60}m` : ""}`
38
+ return `${Math.floor(h / 24)}d${h % 24 ? ` ${h % 24}h` : ""}`
39
+ }
40
+
41
+ const failing = (i: AlertItem) => i.kind !== "recovered"
42
+
43
+ export function alertTitle(items: AlertItem[]): string {
44
+ const down = items.filter(failing)
45
+ const up = items.filter((i) => !failing(i))
46
+ if (down.length && up.length) return `${down.length} failing, ${up.length} recovered`
47
+ if (down.length) return down.length === 1 ? `${down[0].title} is failing` : `${down.length} checks are failing`
48
+ return up.length === 1 ? `${up[0].title} recovered` : `${up.length} checks recovered`
49
+ }
50
+
51
+ /** One line per item, e.g. "✗ SHOP-12 Checkout: failing since 14:15 (3 rounds)". */
52
+ export function itemLine(i: AlertItem, at: string): string {
53
+ const name = `${i.id} ${i.title}`
54
+ if (i.kind === "recovered") return `✓ ${name}: recovered after ${elapsed(i.since, at)}`
55
+ const rounds = i.failures > 1 ? ` (${i.failures} rounds)` : ""
56
+ return `✗ ${name}: ${i.kind === "failing" ? "failing" : "still failing"} since ${clock(i.since)}${rounds}`
57
+ }
58
+
59
+ function itemDetail(i: AlertItem): string[] {
60
+ if (i.kind === "recovered") return []
61
+ const step = i.result?.stepResults?.find((v) => v.status === "failed")
62
+ return [firstLine(i.summary), step ? `Step ${step.step} failed${step.actual ? `: ${firstLine(step.actual, 200)}` : ""}` : ""].filter(Boolean)
63
+ }
64
+
65
+ /** The round as a notice: the same card and email every other ForgeQE notification uses. */
66
+ export function monitorNotice(m: AlertMessage): Notice {
67
+ return {
68
+ source: "ForgeQE monitor",
69
+ title: m.title,
70
+ status: m.items.some(failing) ? "bad" : "good",
71
+ subtitle: `${m.test ? "Test alert · " : ""}Round at ${clock(m.at)} · ${m.passed}/${m.total} checks passing`,
72
+ items: m.items.map((i) => ({
73
+ line: itemLine(i, m.at),
74
+ status: failing(i) ? "bad" : "good",
75
+ details: itemDetail(i),
76
+ ...(i.result?.case.url ? { links: [{ title: "Test case", url: i.result.case.url }] } : {}),
77
+ })),
78
+ ...(m.reportUrl ? { actions: [{ title: "Open report", url: m.reportUrl }] } : {}),
79
+ ...(m.report && !m.reportUrl ? { note: `Report: ${m.report}` } : {}),
80
+ attachments: m.items
81
+ .filter(failing)
82
+ .map((i) => failureShot(i.result?.runDir, i.id))
83
+ .filter((a): a is { filename: string; path: string } => Boolean(a))
84
+ .slice(0, MAX_ATTACHMENTS),
85
+ }
86
+ }
87
+
88
+ export const teamsPayload = (m: AlertMessage) => teamsCard(monitorNotice(m))
89
+ export const emailContent = (m: AlertMessage) => noticeEmail(monitorNotice(m))
90
+
91
+ /** Send the round to every configured channel; a channel that fails does not stop the others. */
92
+ export function sendAlerts(alerts: MonitorAlerts, m: AlertMessage, deps: SendDeps = {}): Promise<Sent[]> {
93
+ return sendNotice(alerts, monitorNotice(m), deps)
94
+ }
@@ -0,0 +1,68 @@
1
+ import type { Card } from "../card.ts"
2
+ import { lockMonitor, roundCard, runMonitor, type MonitorOptions, type RoundResult } from "./run.ts"
3
+
4
+ /** A monitor running inside this process (the shell, or `forgeqe mcp`) until it is stopped or the process ends. */
5
+ export type BackgroundMonitor = {
6
+ outDir: string
7
+ checks: string[]
8
+ everyMs: number
9
+ startedAt: Date
10
+ rounds: number
11
+ last?: RoundResult
12
+ nextAt?: Date
13
+ error?: string
14
+ stop(): void
15
+ done: Promise<number>
16
+ }
17
+
18
+ const running = new Map<string, BackgroundMonitor>()
19
+
20
+ export function backgroundMonitors(): BackgroundMonitor[] {
21
+ return [...running.values()]
22
+ }
23
+
24
+ /** Start rounds in the background; each round is reported as a card. Throws when a monitor already uses the folder. */
25
+ export function startBackgroundMonitor(opts: MonitorOptions & { onCard?: (card: Card) => void; cwd?: string }): BackgroundMonitor {
26
+ const outDir = opts.config.monitor.outDir
27
+ const other = running.get(outDir)
28
+ if (other) throw new Error(`A monitor is already running in ${outDir} (${other.checks.join(" ")}). Stop it first.`)
29
+ lockMonitor(outDir)()
30
+ const controller = new AbortController()
31
+ const bg: BackgroundMonitor = {
32
+ outDir,
33
+ checks: opts.checks,
34
+ everyMs: opts.config.monitor.everyMs,
35
+ startedAt: new Date(),
36
+ rounds: 0,
37
+ stop: () => controller.abort(),
38
+ done: Promise.resolve(0),
39
+ }
40
+ bg.done = runMonitor({
41
+ ...opts,
42
+ signal: controller.signal,
43
+ onRound: (r, nextAt) => {
44
+ bg.rounds++
45
+ bg.last = r
46
+ bg.nextAt = nextAt
47
+ opts.onCard?.(roundCard(r, nextAt, opts.cwd))
48
+ },
49
+ })
50
+ .catch((err) => {
51
+ bg.error = (err as Error).message || String(err)
52
+ opts.onCard?.({ status: "fail", title: "Monitor stopped", body: [bg.error] })
53
+ return 1
54
+ })
55
+ .finally(() => {
56
+ if (running.get(outDir) === bg) running.delete(outDir)
57
+ })
58
+ running.set(outDir, bg)
59
+ return bg
60
+ }
61
+
62
+ /** Stop the monitor on `outDir`, or every one; resolves once their rounds have wound down. */
63
+ export async function stopBackgroundMonitors(outDir?: string): Promise<BackgroundMonitor[]> {
64
+ const picked = outDir ? backgroundMonitors().filter((m) => m.outDir === outDir) : backgroundMonitors()
65
+ for (const m of picked) m.stop()
66
+ await Promise.all(picked.map((m) => m.done))
67
+ return picked
68
+ }