@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
@@ -0,0 +1,172 @@
1
+ import type { AppConfig } from "../config.ts"
2
+
3
+ /** Azure DevOps MCP tool groups ForgeQE loads unless sources.azure_devops.mcp_domains says otherwise: every group the server has. */
4
+ export const ADO_DOMAINS = ["all"]
5
+ export const ROVO_URL = "https://mcp.atlassian.com/v2/mcp"
6
+ /** Also the time allowed for the first start, when npx or uvx downloads the server. */
7
+ const TIMEOUT_MS = 120_000
8
+
9
+ export type ExternalServer = {
10
+ /** The agent runtime's name for the server; its tools are called `<name>_<tool>`. */
11
+ name: string
12
+ label: string
13
+ /** For the agent: what the server is for. */
14
+ about: string
15
+ config: Record<string, unknown>
16
+ /** Values ForgeQE derives from its environment (e.g. base64 of email:token) for {env:} references in config. */
17
+ secrets: Record<string, string>
18
+ }
19
+
20
+ export type ExternalPlan = { servers: ExternalServer[]; skipped: { label: string; why: string }[] }
21
+
22
+ type Which = (bin: string) => string | null
23
+
24
+ const b64 = (s: string) => Buffer.from(s).toString("base64")
25
+
26
+ /** The organization name Microsoft's MCP server takes, or null for Azure DevOps Server (on-premises). */
27
+ export function adoOrganization(orgUrl: string): string | null {
28
+ let u: URL
29
+ try {
30
+ u = new URL(orgUrl)
31
+ } catch {
32
+ return null
33
+ }
34
+ if (u.hostname === "dev.azure.com") return u.pathname.split("/").filter(Boolean)[0] || null
35
+ return u.hostname.match(/^([^.]+)\.visualstudio\.com$/)?.[1] || null
36
+ }
37
+
38
+ /**
39
+ * MCP servers for the systems in forgeqe.yaml: Microsoft's Azure DevOps server, mcp-atlassian or Atlassian's hosted
40
+ * server for Jira, and anything under `mcp:`. Credentials stay in the environment: configs refer to them as {env:NAME}.
41
+ * A system whose server cannot start here is listed in `skipped`; ForgeQE's own API tools still cover it.
42
+ */
43
+ export function externalMcp(cfg: AppConfig, which: Which = Bun.which, env: NodeJS.ProcessEnv = process.env): ExternalPlan {
44
+ const servers: ExternalServer[] = []
45
+ const skipped: ExternalPlan["skipped"] = []
46
+ const { ado, jira, confluence: conf } = cfg.sources
47
+ const confMissing = conf ? [conf.cloud && !env[conf.emailEnv] ? conf.emailEnv : "", env[conf.tokenEnv] ? "" : conf.tokenEnv].filter(Boolean) : []
48
+ const confVars: Record<string, string> | undefined =
49
+ conf && !confMissing.length
50
+ ? {
51
+ CONFLUENCE_URL: conf.url,
52
+ ...(conf.cloud ? { CONFLUENCE_USERNAME: `{env:${conf.emailEnv}}`, CONFLUENCE_API_TOKEN: `{env:${conf.tokenEnv}}` } : { CONFLUENCE_PERSONAL_TOKEN: `{env:${conf.tokenEnv}}` }),
53
+ ...(conf.space ? { CONFLUENCE_SPACES_FILTER: conf.space } : {}),
54
+ }
55
+ : undefined
56
+ let confCovered = false
57
+
58
+ if (ado && ado.mcp !== false) {
59
+ const label = "Azure DevOps MCP"
60
+ const org = adoOrganization(ado.orgUrl)
61
+ const pat = env[ado.tokenEnv]
62
+ const auth = pat ? "pat" : which("az") ? "azcli" : null
63
+ if (!org) skipped.push({ label, why: "Microsoft's server supports Azure DevOps Services only, not Azure DevOps Server" })
64
+ else if (!which("npx")) skipped.push({ label, why: "it needs Node.js (npx)" })
65
+ else if (!auth) skipped.push({ label, why: `set ${ado.tokenEnv}, or sign in with az login` })
66
+ else {
67
+ servers.push({
68
+ name: "azure_devops",
69
+ label,
70
+ about:
71
+ `Microsoft's Azure DevOps MCP server: organization ${org}${ado.project ? `, project ${ado.project}` : ", every project"} (work items, boards, test plans, pipelines, repos, wiki pages, search across code, wikis and work items, security alerts). ` +
72
+ "Always pass project to its tools that take one: without it the server asks the client to pick a project, which this session cannot answer, so the call fails. " +
73
+ "Its wiki tools only work in a wiki that exists and cannot create one; write wiki pages with ForgeQE's write_ado_wiki_page, which creates the project wiki when there is none. " +
74
+ (ado.project
75
+ ? `Use ${ado.project} unless the user names another project.`
76
+ : "Work item ids are unique in the organization, so getting a work item by id works with any project name (from azure_devops_core_list_projects); its System.TeamProject field says which project it is in, so use that project for follow-up calls on it."),
77
+ config: {
78
+ type: "local",
79
+ command: ["npx", "-y", "@azure-devops/mcp", org, "-a", auth, "-d", ...(ado.mcpDomains || ADO_DOMAINS)],
80
+ ...(pat ? { environment: { PERSONAL_ACCESS_TOKEN: "{env:FORGEQE_ADO_MCP_TOKEN}" } } : {}),
81
+ enabled: true,
82
+ timeout: TIMEOUT_MS,
83
+ },
84
+ secrets: pat ? { FORGEQE_ADO_MCP_TOKEN: b64(`forgeqe:${pat}`) } : {},
85
+ })
86
+ }
87
+ }
88
+
89
+ if (jira && jira.mcp !== false) {
90
+ const email = jira.cloud ? env[jira.emailEnv] : undefined
91
+ const token = env[jira.tokenEnv]
92
+ const missing = [jira.cloud && !email ? jira.emailEnv : "", token ? "" : jira.tokenEnv].filter(Boolean)
93
+ if (jira.mcp === "rovo") {
94
+ const label = "Atlassian Rovo MCP"
95
+ if (!jira.cloud) skipped.push({ label, why: "Atlassian's hosted server is for Jira Cloud; use mcp: mcp-atlassian for Server/Data Center" })
96
+ else if (missing.length) skipped.push({ label, why: `set ${missing.join(" and ")}` })
97
+ else {
98
+ confCovered = Boolean(conf?.cloud && new URL(conf.url).host === new URL(jira.url).host)
99
+ servers.push({
100
+ name: "atlassian",
101
+ label,
102
+ about: `Atlassian's Rovo MCP server for ${jira.url} (Jira issues, JQL search, comments, transitions${confCovered ? "; Confluence pages and CQL search" : ""}; pass the cloudId where a tool asks for it)`,
103
+ config: { type: "remote", url: ROVO_URL, headers: { Authorization: "Basic {env:FORGEQE_ATLASSIAN_MCP_AUTH}" }, oauth: false, enabled: true, timeout: TIMEOUT_MS },
104
+ secrets: { FORGEQE_ATLASSIAN_MCP_AUTH: b64(`${email}:${token}`) },
105
+ })
106
+ }
107
+ } else {
108
+ const label = "Jira MCP (mcp-atlassian)"
109
+ if (!which("uvx")) skipped.push({ label, why: "it needs uv (uvx)" })
110
+ else if (missing.length) skipped.push({ label, why: `set ${missing.join(" and ")}` })
111
+ else {
112
+ const credentials = jira.cloud
113
+ ? { JIRA_USERNAME: `{env:${jira.emailEnv}}`, JIRA_API_TOKEN: `{env:${jira.tokenEnv}}` }
114
+ : { JIRA_PERSONAL_TOKEN: `{env:${jira.tokenEnv}}` }
115
+ confCovered = Boolean(confVars)
116
+ servers.push({
117
+ name: "atlassian",
118
+ label,
119
+ about: `mcp-atlassian for Jira ${jira.cloud ? "Cloud" : "Server/Data Center"} at ${jira.url} (issues, JQL search, comments, transitions)${confVars ? ` and Confluence at ${conf!.url} (pages, CQL search)` : ""}`,
120
+ config: {
121
+ type: "local",
122
+ command: ["uvx", "mcp-atlassian"],
123
+ environment: { JIRA_URL: jira.url, ...credentials, ...confVars, TOOLSETS: "default" },
124
+ enabled: true,
125
+ timeout: TIMEOUT_MS,
126
+ },
127
+ secrets: {},
128
+ })
129
+ }
130
+ }
131
+ }
132
+
133
+ if (conf && !confCovered) {
134
+ const label = "Confluence MCP (mcp-atlassian)"
135
+ if (!which("uvx")) skipped.push({ label, why: "it needs uv (uvx)" })
136
+ else if (!confVars) skipped.push({ label, why: `set ${confMissing.join(" and ")}` })
137
+ else {
138
+ servers.push({
139
+ name: "confluence",
140
+ label,
141
+ about: `mcp-atlassian for Confluence ${conf.cloud ? "Cloud" : "Server/Data Center"} at ${conf.url} (pages, CQL search)`,
142
+ config: { type: "local", command: ["uvx", "mcp-atlassian"], environment: { ...confVars, TOOLSETS: "default" }, enabled: true, timeout: TIMEOUT_MS },
143
+ secrets: {},
144
+ })
145
+ }
146
+ }
147
+
148
+ for (const [name, server] of Object.entries(cfg.mcp || {})) {
149
+ const at = servers.findIndex((s) => s.name === name)
150
+ if (at >= 0) servers.splice(at, 1)
151
+ if (server.enabled === false) continue
152
+ servers.push({
153
+ name,
154
+ label: `${name} MCP`,
155
+ about: `${name}: ${server.type === "remote" ? server.url : server.command.join(" ")} (from forgeqe.yaml)`,
156
+ config: { ...server, enabled: true, timeout: server.timeout ?? TIMEOUT_MS },
157
+ secrets: {},
158
+ })
159
+ }
160
+ return { servers, skipped }
161
+ }
162
+
163
+ /** Lines for the agent's session notes. */
164
+ export function externalNotes(plan: ExternalPlan): string[] {
165
+ if (!plan.servers.length && !plan.skipped.length) return []
166
+ return [
167
+ ...(plan.servers.length
168
+ ? ["MCP servers for other systems (tools are named <server>_<tool>; a server whose tools are missing did not connect):", ...plan.servers.map((s) => `- ${s.name}: ${s.about}`)]
169
+ : []),
170
+ ...plan.skipped.map((s) => `${s.label} is not available (${s.why}); use ForgeQE's tools for that system.`),
171
+ ]
172
+ }
package/src/mcp/git.ts ADDED
@@ -0,0 +1,78 @@
1
+ import { withoutTelemetry } from "../privacy.ts"
2
+
3
+ export type Out = { code: number; out: string }
4
+
5
+ export async function sh(cmd: string[], cwd: string, opts: { env?: Record<string, string>; stdin?: string } = {}): Promise<Out> {
6
+ const proc = Bun.spawn(cmd, {
7
+ cwd,
8
+ env: { ...withoutTelemetry(process.env), GIT_TERMINAL_PROMPT: "0", GH_PROMPT_DISABLED: "1", NO_COLOR: "1", ...opts.env },
9
+ stdin: opts.stdin === undefined ? "ignore" : new Blob([opts.stdin]),
10
+ stdout: "pipe",
11
+ stderr: "pipe",
12
+ })
13
+ const [out, err, code] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited])
14
+ return { code, out: [out, err].filter((s) => s.trim()).join("\n").trim() }
15
+ }
16
+
17
+ /** The remote's default branch (origin/HEAD), or null when the clone does not know it. */
18
+ async function defaultBranch(cwd: string, remote: string): Promise<string | null> {
19
+ const head = await sh(["git", "symbolic-ref", "--quiet", "--short", `refs/remotes/${remote}/HEAD`], cwd)
20
+ if (head.code === 0 && head.out) return head.out.replace(`${remote}/`, "")
21
+ const show = await sh(["git", "ls-remote", "--symref", remote, "HEAD"], cwd)
22
+ return show.out.match(/ref: refs\/heads\/(\S+)\s+HEAD/)?.[1] || null
23
+ }
24
+
25
+ /**
26
+ * Push the current commit to `branch` on the remote. Never the remote's default branch, never main or master, and
27
+ * never forced; the agent has no other way to push.
28
+ */
29
+ export async function pushBranch(cwd: string, branch: string, remote = "origin"): Promise<string> {
30
+ const name = branch.trim().replace(/^refs\/heads\//, "")
31
+ const valid = await sh(["git", "check-ref-format", "--branch", name], cwd)
32
+ if (!name || valid.code !== 0 || name.startsWith("-")) throw new Error(`"${branch}" is not a valid branch name.`)
33
+ const main = await defaultBranch(cwd, remote)
34
+ const blocked = new Set(["main", "master", ...(main ? [main] : [])])
35
+ if (blocked.has(name)) throw new Error(`Refusing to push to ${name}: it is the default branch. Push to a new branch instead.`)
36
+ const res = await sh(["git", "push", "--set-upstream", remote, `HEAD:refs/heads/${name}`], cwd)
37
+ if (res.code !== 0) throw new Error(`git push to ${remote}/${name} failed:\n${res.out.slice(-3000)}`)
38
+ const sha = (await sh(["git", "rev-parse", "--short", "HEAD"], cwd)).out
39
+ return `Pushed ${sha} to ${remote}/${name}.${res.out ? `\n${res.out.slice(-1500)}` : ""}`
40
+ }
41
+
42
+ export type SecretTarget = {
43
+ platform: "github" | "azure_devops"
44
+ name: string
45
+ /** github: owner/repo (default: the repo in cwd). */
46
+ repo?: string
47
+ /** azure_devops: pipeline name. */
48
+ pipeline?: string
49
+ org?: string
50
+ project?: string
51
+ }
52
+
53
+ /**
54
+ * Store an environment variable of this process as a pipeline secret. The value goes to gh on stdin, or to az through
55
+ * its AZURE_DEVOPS_EXT_PIPELINE_VAR_ variable; it is never in argv, a result, or an error message.
56
+ */
57
+ export async function setCiSecret(cwd: string, t: SecretTarget): Promise<string> {
58
+ const name = t.name.trim()
59
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) throw new Error(`"${t.name}" is not a valid secret name.`)
60
+ const value = process.env[name]
61
+ if (!value) throw new Error(`${name} is not set in ForgeQE's environment, so there is no value to store. Ask the user to export it and restart ForgeQE.`)
62
+ const scrub = (s: string) => s.split(value).join("***")
63
+
64
+ if (t.platform === "github") {
65
+ const res = await sh(["gh", "secret", "set", name, ...(t.repo ? ["--repo", t.repo] : [])], cwd, { stdin: value })
66
+ if (res.code !== 0) throw new Error(`gh secret set ${name} failed: ${scrub(res.out).slice(-1500)}`)
67
+ return `GitHub Actions secret ${name} set${t.repo ? ` on ${t.repo}` : ""} from the environment.`
68
+ }
69
+
70
+ if (!t.pipeline) throw new Error("Azure DevOps secrets need the pipeline name.")
71
+ const scope = [...(t.org ? ["--org", t.org] : []), ...(t.project ? ["--project", t.project] : [])]
72
+ const env = { [`AZURE_DEVOPS_EXT_PIPELINE_VAR_${name}`]: value }
73
+ const args = (verb: "create" | "update") => ["az", "pipelines", "variable", verb, "--name", name, "--secret", "true", "--pipeline-name", t.pipeline!, ...scope, "--output", "none"]
74
+ let res = await sh(args("create"), cwd, { env })
75
+ if (res.code !== 0 && /already exists/i.test(res.out)) res = await sh(args("update"), cwd, { env })
76
+ if (res.code !== 0) throw new Error(`az pipelines variable ${name} failed: ${scrub(res.out).slice(-1500)}`)
77
+ return `Azure Pipelines secret variable ${name} set on ${t.pipeline} from the environment.`
78
+ }
@@ -0,0 +1,49 @@
1
+ import { NOTIFY_EVENTS } from "../config.ts"
2
+ import { notifiedLine, reportLinks } from "../notify/events.ts"
3
+ import { sendNotice, type Notice, type Status } from "../notify/notice.ts"
4
+ import type { McpTool } from "./tools.ts"
5
+
6
+ const STATUSES: Status[] = ["good", "bad", "warn"]
7
+
8
+ /** Posting to the team's channels on request; what is posted without asking is set per channel in forgeqe.yaml. */
9
+ export const NOTIFY_TOOLS: McpTool[] = [
10
+ {
11
+ name: "notify",
12
+ description:
13
+ `Post a message to the team's notification channels (notify.channels in forgeqe.yaml: Teams channels and email), only when the user asks for it ("post the results to the QA channel", "tell the team the release is NO-GO"). A card with a title, a status colour, one line per point, and an optional link to a report or page. Channels also get some events without asking (${NOTIFY_EVENTS.join(", ")}) from runs outside the shell, so do not post those again.`,
14
+ inputSchema: {
15
+ type: "object",
16
+ properties: {
17
+ title: { type: "string", description: "One line, e.g. Checkout suite: 2 of 14 cases failed" },
18
+ status: { type: "string", enum: STATUSES, description: "good (green), bad (red), or warn (amber)" },
19
+ lines: { type: "array", items: { type: "string" }, description: "The points, one line each: what failed and why, what to do" },
20
+ report: { type: "string", description: "A report file under the reports folder, linked when the folder is published (notify.report_url)" },
21
+ link: { type: "object", properties: { title: { type: "string" }, url: { type: "string" } }, required: ["title", "url"], description: "A page to open, e.g. a pull request or a wiki page" },
22
+ channels: { type: "array", items: { type: "string" }, description: "Channel names from forgeqe.yaml; default every channel" },
23
+ },
24
+ required: ["title", "status", "lines"],
25
+ },
26
+ async run(args, ctx) {
27
+ const all = ctx.config.notify?.channels || []
28
+ if (!all.length) throw new Error("No notification channels: add notify.channels to forgeqe.yaml (a Teams webhook_env or email for each).")
29
+ const names: string[] = Array.isArray(args.channels) && args.channels.length ? args.channels.map(String) : all.map((c) => c.name)
30
+ const unknown = names.filter((n) => !all.some((c) => c.name === n))
31
+ if (unknown.length) throw new Error(`No channel ${unknown.join(", ")}. Channels: ${all.map((c) => c.name).join(", ")}.`)
32
+ const links = reportLinks(ctx.config, args.report ? String(args.report) : undefined)
33
+ const link = args.link?.url ? [{ title: String(args.link.title || "Open"), url: String(args.link.url) }] : []
34
+ const notice: Notice = {
35
+ source: "ForgeQE",
36
+ title: String(args.title),
37
+ status: STATUSES.includes(args.status) ? args.status : "warn",
38
+ subtitle: new Date().toLocaleString(),
39
+ items: (Array.isArray(args.lines) ? args.lines : []).map((l: unknown) => ({ line: String(l) })),
40
+ actions: [...link, ...links.actions],
41
+ ...(links.note ? { note: links.note } : {}),
42
+ }
43
+ const sent = await Promise.all(all.filter((c) => names.includes(c.name)).map(async (c) => ({ channel: c.name, sent: await sendNotice(c, notice) })))
44
+ const text = notifiedLine(sent)
45
+ ctx.emit?.({ type: "info", text, ok: sent.every((s) => s.sent.every((x) => x.ok)) })
46
+ return { notified: text }
47
+ },
48
+ },
49
+ ]
@@ -0,0 +1,261 @@
1
+ import path from "node:path"
2
+
3
+ import { noAgentModel } from "../llm.ts"
4
+ import { existsSync, readFileSync } from "node:fs"
5
+
6
+ import { loadSelection, selectionCard, type TestsRun } from "../code/find.ts"
7
+ import { loadReview, postPrComment, postReview, resolvePr, reviewCard, reviewPullRequest, type Platform } from "../code/prreview.ts"
8
+ import { resultsMarkdown, selectPrTests } from "../code/prtests.ts"
9
+ import { analyzeAreas, type AreaImpact } from "../code/areas.ts"
10
+ import { gitRemotes, parsePrRef } from "../code/prreview.ts"
11
+ import type { Card } from "../card.ts"
12
+ import type { McpTool } from "./tools.ts"
13
+
14
+ /** Review a pull request with Scribe, then post the findings to it once the user agrees. */
15
+ export const PR_TOOLS: McpTool[] = [
16
+ {
17
+ name: "review_pull_request",
18
+ description:
19
+ "Review a pull request in Azure DevOps or GitHub. ForgeQE reads the pull request (description, branches, linked work items or issues), fetches its branches into the local clone and checks the head out in a temporary worktree (the user's branch and files are not touched), and Scribe, the coding agent, reviews the diff with the code around it for bugs, security, requirements, tests, and performance. Shows the findings to the user and returns review_id for post_pr_review; nothing is posted. Takes a few minutes.",
20
+ inputSchema: {
21
+ type: "object",
22
+ properties: {
23
+ pr: { type: "string", description: "The pull request's link, or its number in the working directory's repository (e.g. 42, #42, !42)" },
24
+ platform: { type: "string", enum: ["azure_devops", "github"], description: "For a number, when the clone has both kinds of remote" },
25
+ repo: { type: "string", description: "Local clone of the pull request's repository, relative to the working directory (default: the working directory)" },
26
+ focus: { type: "string", description: "What the user wants the review to concentrate on, in their words" },
27
+ },
28
+ required: ["pr"],
29
+ },
30
+ async run(args, ctx) {
31
+ const noModel = noAgentModel(ctx.config.llm, ctx.config.code.model, "Scribe")
32
+ if (noModel) throw new Error(noModel)
33
+ const pr = String(args.pr || "").trim()
34
+ if (!pr) throw new Error("pr is required.")
35
+ const review = await reviewPullRequest({
36
+ config: ctx.config,
37
+ repo: args.repo ? path.resolve(ctx.cwd, String(args.repo)) : ctx.cwd,
38
+ pr,
39
+ platform: args.platform as Platform | undefined,
40
+ focus: args.focus ? String(args.focus) : undefined,
41
+ signal: ctx.signal,
42
+ onEvent: (event) => ctx.emit?.({ type: "agent", event }),
43
+ open: ctx.openSession as never,
44
+ })
45
+ ctx.emit?.({ type: "card", card: reviewCard(review, ctx.cwd) })
46
+ const inline = review.inline.filter(Boolean).length
47
+ return {
48
+ review_id: review.id,
49
+ pull_request: review.pr.url,
50
+ title: review.pr.title,
51
+ verdict: review.verdict,
52
+ summary: review.summary,
53
+ findings: review.findings,
54
+ files_changed: review.files.length,
55
+ report: path.join(review.dir, "review.md"),
56
+ next_step: review.findings.length
57
+ ? `The user sees the findings. Offer to post them: post_pr_review with this review_id puts ${inline} on their lines and the rest in one summary comment, after the user confirms.`
58
+ : "Tell the user nothing needs fixing; offer post_pr_review to leave the summary on the pull request.",
59
+ }
60
+ },
61
+ },
62
+ {
63
+ name: "post_pr_review",
64
+ description:
65
+ "Post a review from review_pull_request to its pull request after the user confirms: ForgeQE asks the user (Post / Don't post), then adds one comment per finding on its line and one summary comment with the verdict (on GitHub, a review with event COMMENT; it never approves or requests changes). Refuses when the pull request has new commits since the review. Where ForgeQE cannot ask (an MCP client), pass confirmed: true only after the user agreed to post this review.",
66
+ inputSchema: {
67
+ type: "object",
68
+ properties: {
69
+ review_id: { type: "string", description: "From review_pull_request" },
70
+ confirmed: { type: "boolean", description: "Only where ForgeQE cannot ask: the user agreed to post this review" },
71
+ },
72
+ required: ["review_id"],
73
+ },
74
+ async run(args, ctx) {
75
+ const review = loadReview(ctx.config, String(args.review_id))
76
+ if (review.posted) return { posted: false, reason: `Already posted on ${review.posted.at}.`, links: review.posted.links }
77
+ const n = review.findings.length
78
+ const inline = review.inline.filter(Boolean).length
79
+ const what = `${n ? `${n} finding${n === 1 ? "" : "s"} (${inline} on their lines) and ` : ""}a summary comment`
80
+ if (ctx.ask) {
81
+ const choice = await ctx.ask({
82
+ title: `Post the review to pull request ${review.pr.ref.number}?`,
83
+ detail: review.pr.url,
84
+ lines: [`${what} on "${review.pr.title}":`, ...review.findings.slice(0, 15).map((f) => ` • ${f.severity}: ${f.title}${f.file ? ` (${f.file}${f.line ? `:${f.line}` : ""})` : ""}`), ...(n > 15 ? [` and ${n - 15} more`] : [])],
85
+ options: [
86
+ { id: "post", label: "Post review" },
87
+ { id: "no", label: "Don't post" },
88
+ ],
89
+ })
90
+ if (choice !== "post") return { posted: false, reason: "The user chose not to post the review." }
91
+ } else if (args.confirmed !== true) {
92
+ return {
93
+ posted: false,
94
+ needs_confirmation: true,
95
+ pull_request: review.pr.url,
96
+ would_post: what,
97
+ next_step: "Ask the user whether to post this review to the pull request; call post_pr_review again with confirmed: true only after they say yes.",
98
+ }
99
+ }
100
+ ctx.emit?.({ type: "agent", event: { type: "code", kind: "info", text: `Posting the review to ${review.pr.url}…` } })
101
+ const links = await postReview(review, ctx.config)
102
+ ctx.emit?.({ type: "card", card: { status: "ok", title: `Review posted to pull request ${review.pr.ref.number}`, stats: what, body: links } })
103
+ return { posted: true, comments: inline + 1, links }
104
+ },
105
+ },
106
+ {
107
+ name: "select_pr_tests",
108
+ description:
109
+ "Pick the repository's automated tests a pull request can break, to run only those: the test files it changes, the tests that import a changed file (through page objects and helpers), the tests that name a changed class or page object, and the tests mapped to the test cases of the work items it is linked to. With agent (the default when a model is set up), Scribe checks the candidates by reading the tests, adds tests that drive the changed behaviour through the UI, and gives exact list and run commands; without, the impacted Playwright or pytest files are selected as they are. Shows the list and returns selection_id for run_tests. Give pr (a link or number) or base (a branch, to compare the current branch with).",
110
+ inputSchema: {
111
+ type: "object",
112
+ properties: {
113
+ pr: { type: "string", description: "The pull request's link, or its number in this repository" },
114
+ base: { type: "string", description: "Without pr: the branch to compare HEAD with (default: the remote's default branch)" },
115
+ platform: { type: "string", enum: ["azure_devops", "github"] },
116
+ repo: { type: "string", description: "Local clone, relative to the working directory (default: the working directory)" },
117
+ agent: { type: "boolean", description: "false: only the import and name analysis, no model (fast, for a quick look or CI)" },
118
+ },
119
+ },
120
+ async run(args, ctx) {
121
+ const agent = args.agent !== false && !noAgentModel(ctx.config.llm, ctx.config.code.model, "Scribe")
122
+ const out = await selectPrTests({
123
+ config: ctx.config,
124
+ repo: args.repo ? path.resolve(ctx.cwd, String(args.repo)) : ctx.cwd,
125
+ pr: args.pr ? String(args.pr) : undefined,
126
+ base: args.base ? String(args.base) : undefined,
127
+ platform: args.platform as Platform | undefined,
128
+ agent,
129
+ signal: ctx.signal,
130
+ onEvent: (event) => ctx.emit?.({ type: "agent", event }),
131
+ open: ctx.openSession as never,
132
+ runTest: ctx.runTest,
133
+ })
134
+ if (out.selection) ctx.emit?.({ type: "card", card: selectionCard(out.selection, ctx.cwd) })
135
+ return {
136
+ ...(out.pr ? { pull_request: out.pr.url, title: out.pr.title } : {}),
137
+ changed_files: out.impact.changed.length,
138
+ impacted: out.impact.tests,
139
+ no_test_reaches: out.impact.untested.length ? out.impact.untested : undefined,
140
+ selection_id: out.selection?.id,
141
+ count: out.selection?.tests.length || 0,
142
+ run_command: out.selection?.runCommand,
143
+ notes: out.notes.length ? out.notes : undefined,
144
+ next_step: out.selection?.tests.length
145
+ ? `Call run_tests with this selection_id (it asks the user first)${out.pr ? ", then offer post_pr_test_results to put the results on the pull request" : ""}. Mention the changed files no test reaches: they are a coverage gap.`
146
+ : "Say no test was found for these changes, and name the changed files no test reaches as a coverage gap.",
147
+ }
148
+ },
149
+ },
150
+ {
151
+ name: "post_pr_test_results",
152
+ description:
153
+ "Post the results of run_tests for a pull request's selection on the pull request, as one comment (passed and failed counts, each failed test with its message, the command), after the user confirms. Where ForgeQE cannot ask (an MCP client), pass confirmed: true only after the user agreed.",
154
+ inputSchema: {
155
+ type: "object",
156
+ properties: {
157
+ selection_id: { type: "string", description: "From select_pr_tests, after run_tests ran it" },
158
+ pr: { type: "string", description: "The pull request's link or number" },
159
+ platform: { type: "string", enum: ["azure_devops", "github"] },
160
+ confirmed: { type: "boolean" },
161
+ },
162
+ required: ["selection_id", "pr"],
163
+ },
164
+ async run(args, ctx) {
165
+ const sel = loadSelection(ctx.config, String(args.selection_id))
166
+ const file = path.join(sel.dir, "results.json")
167
+ if (!existsSync(file)) return { posted: false, reason: "The selection has not run yet: call run_tests first." }
168
+ const run = JSON.parse(readFileSync(file, "utf8")) as TestsRun
169
+ const { root, ref } = await resolvePr(sel.repo, String(args.pr), args.platform as Platform | undefined)
170
+ const markdown = resultsMarkdown(run, `pull request ${ref.number}`)
171
+ if (ctx.ask) {
172
+ const choice = await ctx.ask({
173
+ title: `Post the test results to pull request ${ref.number}?`,
174
+ lines: markdown.split("\n").slice(0, 20),
175
+ options: [
176
+ { id: "post", label: "Post results" },
177
+ { id: "no", label: "Don't post" },
178
+ ],
179
+ })
180
+ if (choice !== "post") return { posted: false, reason: "The user chose not to post the results." }
181
+ } else if (args.confirmed !== true) {
182
+ return { posted: false, needs_confirmation: true, comment: markdown, next_step: "Show the user the comment; call again with confirmed: true only after they agree." }
183
+ }
184
+ const url = await postPrComment(ref, ctx.config, root, markdown)
185
+ ctx.emit?.({ type: "card", card: { status: run.status === "passed" ? "ok" : "fail", title: `Test results posted to pull request ${ref.number}`, body: [url] } })
186
+ return { posted: true, url }
187
+ },
188
+ },
189
+ {
190
+ name: "analyze_pr_impact",
191
+ description:
192
+ "Which parts of the product a pull request touches, for the product's code repository, which may be another repository than the tests. Reads the pull request from a local clone of the product repository (product_repo, or the working directory when it is one), else through the GitHub or Azure DevOps API. Ties its changes to the product by: the work items it is linked to (with feature, epic, and Area Path); the test ids, ids, labels, texts, and routes its changed code carries, matched against page memory (every element ForgeQE met on each page); with a clone, the pages that import a changed shared component. Then, in the automation repository (the working directory): the tests that visit those pages, the test cases they automate, and the test cases of the impacted requirements nobody automated (manual tests to run). Returns the evidence and shows a card; write the impact report from it.",
193
+ inputSchema: {
194
+ type: "object",
195
+ properties: {
196
+ pr: { type: "string", description: "The pull request's link (or its number when the working directory or product_repo is a clone of its repository)" },
197
+ product_repo: { type: "string", description: "A local clone of the product repository, when it is not the working directory (optional: without one, the API is used)" },
198
+ },
199
+ required: ["pr"],
200
+ },
201
+ async run(args, ctx) {
202
+ ctx.emit?.({ type: "agent", event: { type: "code", kind: "info", text: "Reading the pull request and tying its changes to the product…" } })
203
+ const impact = await analyzeAreas(ctx.config, String(args.pr), { productRepo: args.product_repo ? String(args.product_repo) : undefined, cwd: ctx.cwd })
204
+ ctx.emit?.({ type: "card", card: impactCard(impact) })
205
+ return {
206
+ pull_request: impact.pr.url,
207
+ title: impact.pr.title,
208
+ read_from: impact.via,
209
+ changed_files: impact.files,
210
+ requirements: impact.requirements.map((r) => ({ id: r.id, type: r.type, title: r.title, area: r.area, parent: r.parent, acceptance: r.acceptance || undefined })),
211
+ pages: impact.pages,
212
+ routes: impact.routes.length ? impact.routes : undefined,
213
+ tests: impact.tests,
214
+ manual_test_cases: impact.manual,
215
+ ui_files_not_tied_to_a_page: impact.unmapped.length ? impact.unmapped : undefined,
216
+ notes: impact.notes.length ? impact.notes : undefined,
217
+ next_step:
218
+ "Write the impact report: the product areas this changes (features and epics, Area Paths, pages), each with its evidence and a risk (high for shared components, sign-in, payments, data and migrations, or many pages; low for one page's text), side effects the evidence does not show but the diff suggests, the automated tests to run (select_pr_tests can pick and run them), the manual test cases to run, and the gaps: areas with no test. Offer to post it on the pull request with comment_on_pr.",
219
+ }
220
+ },
221
+ },
222
+ {
223
+ name: "comment_on_pr",
224
+ description: "Post one markdown comment on a pull request (an impact report, a summary) after the user confirms. Where ForgeQE cannot ask (an MCP client), pass confirmed: true only after the user agreed to this text.",
225
+ inputSchema: {
226
+ type: "object",
227
+ properties: { pr: { type: "string", description: "The pull request's link or number" }, markdown: { type: "string" }, confirmed: { type: "boolean" } },
228
+ required: ["pr", "markdown"],
229
+ },
230
+ async run(args, ctx) {
231
+ const ref = parsePrRef(String(args.pr), await gitRemotes(ctx.cwd).catch(() => []))
232
+ const markdown = String(args.markdown)
233
+ if (ctx.ask) {
234
+ const choice = await ctx.ask({ title: `Post this comment on pull request ${ref.number}?`, lines: markdown.split("\n").slice(0, 25), options: [{ id: "post", label: "Post comment" }, { id: "no", label: "Don't post" }] })
235
+ if (choice !== "post") return { posted: false, reason: "The user chose not to post it." }
236
+ } else if (args.confirmed !== true) {
237
+ return { posted: false, needs_confirmation: true, next_step: "Show the user the comment; call again with confirmed: true only after they agree." }
238
+ }
239
+ const url = await postPrComment(ref, ctx.config, ctx.cwd, markdown)
240
+ return { posted: true, url }
241
+ },
242
+ },
243
+ ]
244
+
245
+ /** The evidence as a card: areas, pages, tests, manual cases, and what is not tied to anything. */
246
+ function impactCard(i: AreaImpact): Card {
247
+ const body: string[] = []
248
+ const reqs = i.requirements.filter((r) => !/test case/i.test(r.type))
249
+ if (reqs.length) body.push("Requirements", ...reqs.slice(0, 10).map((r) => ` ${r.type} ${r.id.replace(/^ADO-/, "")} "${r.title}"${r.area ? ` (${r.area})` : ""}`), "")
250
+ if (i.pages.length) body.push("Pages", ...i.pages.slice(0, 12).map((p) => ` ${p.page}${p.title ? ` "${p.title}"` : ""}: ${p.evidence.slice(0, 3).join(", ")}${p.evidence.length > 3 ? `, and ${p.evidence.length - 3} more` : ""}`), "")
251
+ if (i.tests.length) body.push("Automated tests that reach them", ...i.tests.slice(0, 12).map((t) => ` ${t.file}${t.caseId ? ` (${t.caseId})` : ""}: ${t.reasons[0]}`), "")
252
+ if (i.manual.length) body.push("Manual test cases for these requirements", ...i.manual.slice(0, 12).map((m) => ` ${m.id} ${m.title || ""}`.trimEnd()), "")
253
+ if (i.unmapped.length) body.push(`UI files not tied to a page: ${i.unmapped.slice(0, 8).join(", ")}${i.unmapped.length > 8 ? ", …" : ""}`)
254
+ for (const n of i.notes) body.push(n)
255
+ return {
256
+ status: "info",
257
+ title: `Impact of pull request ${i.pr.ref.number}: ${i.pr.title}`,
258
+ stats: [`${i.files.length} files`, `${i.pages.length} pages`, `${i.tests.length} tests`, `${i.manual.length} manual cases`].join(" · "),
259
+ body,
260
+ }
261
+ }