@capagents/forgeqe 1.3.2 → 2.0.1

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 (115) hide show
  1. package/HOW_TO_USE.md +373 -9
  2. package/README.md +55 -4
  3. package/docs/ARCHITECTURE.md +62 -1
  4. package/docs/CONTAINERS.md +227 -0
  5. package/docs/COST_ARCHITECTURE.md +32 -0
  6. package/docs/DESKTOP-CI.md +87 -0
  7. package/docs/DESKTOP.md +387 -0
  8. package/docs/MOBILE.md +228 -0
  9. package/docs/feature-test-prompts.md +25 -0
  10. package/docs/security-data-flow.html +440 -3
  11. package/docs/user-guide.html +797 -415
  12. package/env.example.yaml +67 -0
  13. package/forgeqe.example.yaml +56 -1
  14. package/llms.example.json +7 -0
  15. package/package.json +4 -1
  16. package/prompts/brain.md +31 -0
  17. package/prompts/find-tests.md +1 -0
  18. package/py/forgeqe_desktop/__init__.py +1 -0
  19. package/py/forgeqe_desktop/__main__.py +3 -0
  20. package/py/forgeqe_desktop/apps.py +80 -0
  21. package/py/forgeqe_desktop/checks.py +168 -0
  22. package/py/forgeqe_desktop/cli.py +90 -0
  23. package/py/forgeqe_desktop/handhooks.py +293 -0
  24. package/py/forgeqe_desktop/handrecord.py +239 -0
  25. package/py/forgeqe_desktop/heal.py +107 -0
  26. package/py/forgeqe_desktop/models.py +208 -0
  27. package/py/forgeqe_desktop/pywinauto_driver.py +217 -0
  28. package/py/forgeqe_desktop/record.py +150 -0
  29. package/py/forgeqe_desktop/replay.py +345 -0
  30. package/py/forgeqe_desktop/service.py +356 -0
  31. package/py/forgeqe_desktop/session.py +60 -0
  32. package/py/forgeqe_desktop/ufo_bridge.py +86 -0
  33. package/py/forgeqe_desktop/ufo_runner.py +305 -0
  34. package/py/forgeqe_desktop/visual.py +112 -0
  35. package/py/forgeqe_desktop/workflow.py +338 -0
  36. package/py/forgeqe_engine/llm.py +54 -4
  37. package/py/forgeqe_engine/model_params.py +169 -0
  38. package/py/forgeqe_mobile/__init__.py +1 -0
  39. package/py/forgeqe_mobile/__main__.py +3 -0
  40. package/py/forgeqe_mobile/appium_driver.py +342 -0
  41. package/py/forgeqe_mobile/apps.py +84 -0
  42. package/py/forgeqe_mobile/cli.py +70 -0
  43. package/py/forgeqe_mobile/explore.py +203 -0
  44. package/py/forgeqe_mobile/hyperexecute.py +261 -0
  45. package/py/forgeqe_mobile/maestro.py +321 -0
  46. package/py/forgeqe_mobile/service.py +332 -0
  47. package/skills/testcontainers/SKILL.md +42 -0
  48. package/src/agents.ts +11 -2
  49. package/src/api/plan.ts +2 -0
  50. package/src/api/write.ts +2 -0
  51. package/src/auth/copilot.ts +130 -0
  52. package/src/auth/signin.ts +54 -1
  53. package/src/brain/index.ts +26 -4
  54. package/src/capabilities/guide.ts +2 -0
  55. package/src/capabilities/registry.ts +110 -2
  56. package/src/capabilities/status.ts +15 -0
  57. package/src/cases/rca.ts +2 -0
  58. package/src/childEnv.ts +4 -1
  59. package/src/cli/args.ts +3 -2
  60. package/src/cli/help.ts +24 -3
  61. package/src/cli/index.ts +47 -3
  62. package/src/code/find.ts +26 -0
  63. package/src/code/loop.ts +4 -1
  64. package/src/code/prompt.ts +2 -1
  65. package/src/code/prreview.ts +2 -0
  66. package/src/code/review.ts +6 -2
  67. package/src/code/runtime.ts +34 -3
  68. package/src/commands.ts +6 -1
  69. package/src/config.ts +159 -3
  70. package/src/containers/command.ts +68 -0
  71. package/src/containers/compose.ts +173 -0
  72. package/src/containers/config.ts +283 -0
  73. package/src/containers/mock.ts +156 -0
  74. package/src/containers/run.ts +362 -0
  75. package/src/containers/tests.ts +80 -0
  76. package/src/context.ts +33 -6
  77. package/src/copilot.ts +155 -0
  78. package/src/desktop/bridge.ts +229 -0
  79. package/src/desktop/command.ts +350 -0
  80. package/src/desktop/edit.ts +217 -0
  81. package/src/desktop/junit.ts +35 -0
  82. package/src/desktop/report.ts +155 -0
  83. package/src/desktop/run.ts +252 -0
  84. package/src/desktop/setup.ts +134 -0
  85. package/src/engine/bridge.ts +12 -5
  86. package/src/engine/setup.ts +3 -3
  87. package/src/environments.ts +566 -0
  88. package/src/evals/domain.ts +1 -0
  89. package/src/explore/charters.ts +2 -0
  90. package/src/guardrails.ts +145 -0
  91. package/src/heal/loop.ts +4 -1
  92. package/src/heal/prompt.ts +2 -1
  93. package/src/init.ts +5 -2
  94. package/src/llm.ts +23 -2
  95. package/src/mcp/attachtools.ts +16 -4
  96. package/src/mcp/captools.ts +27 -0
  97. package/src/mcp/containertools.ts +212 -0
  98. package/src/mcp/desktoptools.ts +334 -0
  99. package/src/mcp/mobiletools.ts +132 -0
  100. package/src/mcp/reporttools.ts +17 -2
  101. package/src/mcp/repotests.ts +22 -3
  102. package/src/mcp/routing.ts +11 -5
  103. package/src/mcp/scope.ts +210 -0
  104. package/src/mcp/server.ts +80 -5
  105. package/src/mcp/tools.ts +38 -1
  106. package/src/mobile/command.ts +245 -0
  107. package/src/mobile/run.ts +226 -0
  108. package/src/pack/pipeline.ts +44 -2
  109. package/src/refine.ts +3 -0
  110. package/src/report/html.ts +3 -0
  111. package/src/report/template.html +48 -48
  112. package/src/run.ts +7 -3
  113. package/src/tokenomics/ledger.ts +3 -1
  114. package/src/tui/App.tsx +23 -0
  115. package/src/usage.ts +1 -1
@@ -0,0 +1,42 @@
1
+ ---
2
+ name: testcontainers
3
+ description: "Use when a test needs a real database, queue, cache or other service, or when the repository already uses Testcontainers (Java org.testcontainers, .NET Testcontainers, Node testcontainers, Python testcontainers, Go testcontainers-go): starting the service from the test itself with a pinned image, waiting until it is ready, wiring its address into the app under test, sharing one container across tests, and cleaning up."
4
+ agents: [scribe]
5
+ ---
6
+
7
+ # Testcontainers
8
+
9
+ ## Read the setup first
10
+ - **Find what the repository already uses.** Look for the library in the build file:
11
+ - `pom.xml` or `build.gradle`: `org.testcontainers`
12
+ - `.csproj`: `Testcontainers.*`
13
+ - `package.json`: `testcontainers` or `@testcontainers/*`
14
+ - `pyproject.toml` or `requirements*.txt`: `testcontainers`
15
+ - `go.mod`: `github.com/testcontainers/testcontainers-go`
16
+
17
+ Then find its existing containers: a base class, a fixture, `@Testcontainers`/`@Container`, an `IAsyncLifetime` or `[OneTimeSetUp]`, a Jest `globalSetup`, or a pytest fixture with `scope="session"`. Reuse them. Never start a second container for a service a fixture already gives.
18
+ - **Use the repository's own modules** (`PostgreSQLContainer`, `PostgreSqlBuilder`, `PostgreSqlContainer`, `RedisContainer`, `KafkaContainer`) over a generic container. Use a generic one only when no module exists.
19
+ - **Adding the library:** only when the test needs a real service and nothing in the repository gives one. Pin the version the build file uses for its other test dependencies, or the latest release. Then say so in your summary.
20
+
21
+ ## Images
22
+ - Pin every image to an exact version: `postgres:16.4`, `redis:7.4.1`, `confluentinc/cp-kafka:7.7.1`. Never `latest` or no tag.
23
+ - Use the version the app runs in production when the repository says it (`docker-compose.yml`, deployment files, a README). Otherwise use the newest version of the major the app's driver supports.
24
+
25
+ ## Ready and addresses
26
+ - **Wait for readiness the module's way, or with an explicit wait strategy:** a log line, a health check, or an HTTP path answering 200. Never sleep for a fixed time.
27
+ - **Use the mapped port and host the library gives** (`getMappedPort`, `GetMappedPublicPort`, `getPort()`, `get_exposed_port`, `container.Host`). Never assume a fixed port of this machine: tests run in parallel and on CI.
28
+ - **Hand the address to the app under test** through its own configuration: the connection string or URL setting it already reads. In Spring, use `@DynamicPropertySource` or `@ServiceConnection`. In ASP.NET, use `WebApplicationFactory` with `ConfigureAppConfiguration`. Elsewhere, set an environment variable before the app starts.
29
+ - **Seed data** with the repository's migrations or seed scripts (`withInitScript`, `WithResourceMapping`, running the migration tool against the container). Don't use SQL copied into the test when the repository has migrations.
30
+
31
+ ## Lifetime and speed
32
+ - **Start one container per test class or test session**, not per test, unless tests must not share data. Reset data between tests the way the repository does: transactions rolled back, truncating tables, or a fresh schema.
33
+ - **Leave cleanup to the library** (Ryuk, `await using`, `@Container`, a fixture's teardown). Never call `docker` from a test, and never run `docker rm`.
34
+
35
+ ## What a test must not do
36
+ - **No privileged containers**, host networking (`withNetworkMode("host")`, `NetworkMode = host`), mounting the Docker socket into a container, or `withPrivilegedMode`.
37
+ - **No real credentials.** Use the module's defaults or fixed test values. A secret the app needs comes from the environment the test reads already.
38
+ - **No containers for things the test doesn't use.** A unit test stays a unit test.
39
+
40
+ ## Run one test
41
+ - Docker must be running on the machine, or in CI with the runner's Docker. Run the one test as the repository's own instructions say (`mvn -Dtest=OrderRepositoryIT test`, `dotnet test --filter FullyQualifiedName~OrderRepositoryTests`, `npx jest orders.int.test.ts`, `pytest tests/test_orders.py -k create`, `go test ./... -run TestOrders`).
42
+ - The first run pulls the images, so allow for a slow first run before you judge a timeout.
package/src/agents.ts CHANGED
@@ -19,12 +19,21 @@ export type AgentEntry = {
19
19
  skills?: SkillAgent
20
20
  /** Where the code defines it. */
21
21
  defined: string
22
- /** Most tokens its fixed context (system prompt, and for ForgeQE's agent its tool list) may take. */
22
+ /**
23
+ * Most tokens its fixed context may take: the system prompt, and for ForgeQE's agent the capability index and the
24
+ * core capabilities' tools; each other capability is held to CAPABILITY_TOOL_BUDGET.
25
+ */
23
26
  budget: number
24
27
  }
25
28
 
29
+ /**
30
+ * Most tokens one capability's tool definitions may take: a turn of ForgeQE's agent carries the core and the tools of
31
+ * the capabilities it picks (src/mcp/scope.ts), so each capability, not the whole list, is what has to stay small.
32
+ */
33
+ export const CAPABILITY_TOOL_BUDGET = 2500
34
+
26
35
  export const AGENTS: AgentEntry[] = [
27
- { name: "forgeqe", title: "ForgeQE's agent (shell, forgeqe run, forgeqe mcp)", kind: "agent-runtime", prompts: ["brain"], skills: "forgeqe", defined: "src/brain/index.ts", budget: 26000 },
36
+ { name: "forgeqe", title: "ForgeQE's agent (shell, forgeqe run, forgeqe mcp)", kind: "agent-runtime", prompts: ["brain"], skills: "forgeqe", defined: "src/brain/index.ts", budget: 10000 },
28
37
  { name: "forgeqe-test", title: "Scribe: writes a test from a verified run", kind: "agent-runtime", prompts: ["test-agent"], skills: "scribe", defined: "src/code/runtime.ts", budget: 4900 },
29
38
  { name: "forgeqe-read", title: "Scribe, reading the repository before the run arrives", kind: "agent-runtime", prompts: ["test-agent"], skills: "scribe", defined: "src/code/runtime.ts", budget: 4900 },
30
39
  { name: "forgeqe-review", title: "Reviewer of Scribe's tests", kind: "agent-runtime", prompts: ["test-reviewer"], skills: "scribe", defined: "src/code/review.ts", budget: 4900 },
package/src/api/plan.ts CHANGED
@@ -9,6 +9,7 @@ import { knowledgeBlock, readKnowledge, relevantKnowledge } from "../knowledge.t
9
9
  import type { Contract, ContractSet, KeyField, Scenario, ScenarioStep } from "./types.ts"
10
10
  import { prompt } from "../prompts.ts"
11
11
  import { domainNotes } from "../domain.ts"
12
+ import { guardrailsNotes } from "../guardrails.ts"
12
13
 
13
14
  /**
14
15
  * Scenarios planned by a model from test cases (Azure DevOps, Jira, Xray, TestRail, files) or plain English, against
@@ -221,6 +222,7 @@ export async function planScenarios(opts: {
221
222
  repo: opts.cwd,
222
223
  profile: opts.config.llm,
223
224
  domain: domainNotes(opts.config),
225
+ guardrails: guardrailsNotes(opts.config),
224
226
  model: opts.config.code.model,
225
227
  bin: opts.config.code.opencode,
226
228
  readDirs: [opts.cwd],
package/src/api/write.ts CHANGED
@@ -17,6 +17,7 @@ import { briefMarkdown, scenarioMarkdown } from "./store.ts"
17
17
  import type { ContractSet, Scenario } from "./types.ts"
18
18
  import { prompt } from "../prompts.ts"
19
19
  import { domainNotes } from "../domain.ts"
20
+ import { guardrailsNotes } from "../guardrails.ts"
20
21
 
21
22
  /**
22
23
  * The coding agent writes API and contract tests for a contract set in the repository's own framework, and ForgeQE
@@ -157,6 +158,7 @@ export async function writeApiTests(opts: {
157
158
  repo,
158
159
  profile: config.llm,
159
160
  domain: domainNotes(config),
161
+ guardrails: guardrailsNotes(config),
160
162
  model: config.code.model,
161
163
  bin: config.code.opencode,
162
164
  readDirs: [repo, opts.setDir],
@@ -0,0 +1,130 @@
1
+ import { mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs"
2
+ import os from "node:os"
3
+ import path from "node:path"
4
+
5
+ import { createOpencodeClient } from "@opencode-ai/sdk/v2"
6
+
7
+ import { allowListEnv } from "../childEnv.ts"
8
+ import { startServer } from "../code/runtime.ts"
9
+ import { COPILOT_PROVIDER_ID, parseCopilotAuth, type CopilotAuth } from "../copilot.ts"
10
+
11
+ /**
12
+ * The GitHub Copilot device sign-in, run by the agent runtime's own Copilot provider (the sign-in GitHub supports for
13
+ * it). The runtime runs in a throwaway folder, so its auth.json exists only until the sign-in is read back and moved
14
+ * to ForgeQE's credential store.
15
+ */
16
+
17
+ export type DeviceLoginIO = {
18
+ /** The one-time code and the page to enter it on. */
19
+ showCode(code: string, url: string): void
20
+ signal?: AbortSignal
21
+ }
22
+
23
+ /** How long GitHub's device codes stay valid. */
24
+ const DEVICE_CODE_MS = 15 * 60_000
25
+
26
+ /** The page GitHub asks the user to enter the code on, when it is GitHub's own (or the enterprise host's) https page. */
27
+ export function verificationUrl(raw: string, enterprise?: string): string | null {
28
+ let u: URL
29
+ try {
30
+ u = new URL(raw)
31
+ } catch {
32
+ return null
33
+ }
34
+ if (u.protocol !== "https:" || u.username || u.password || u.port) return null
35
+ return u.hostname === (enterprise || "github.com") ? u.toString() : null
36
+ }
37
+
38
+ /** "Enter code: ABCD-1234" → "ABCD-1234". */
39
+ export function userCode(instructions: string): string | null {
40
+ return /\b([A-Z0-9]{4}-[A-Z0-9]{4})\b/.exec(instructions || "")?.[1] || null
41
+ }
42
+
43
+ const message = (error: unknown) => {
44
+ const e = error as { data?: { message?: string }; name?: string } | undefined
45
+ return e?.data?.message || e?.name || "no reason given"
46
+ }
47
+
48
+ /**
49
+ * Sign in to GitHub Copilot with the device flow: GitHub.com, or `enterprise` (a GitHub Enterprise host such as
50
+ * acme.ghe.com). Returns the sign-in and the Copilot models it can use.
51
+ */
52
+ export async function copilotDeviceLogin(bin: string, io: DeviceLoginIO, enterprise?: string): Promise<{ auth: CopilotAuth; models: string[] }> {
53
+ const dir = mkdtempSync(path.join(os.tmpdir(), "forgeqe-copilot-"))
54
+ const sub = (name: string) => {
55
+ const p = path.join(dir, name)
56
+ mkdirSync(p, { recursive: true, mode: 0o700 })
57
+ return p
58
+ }
59
+ const env = {
60
+ ...allowListEnv(process.env, []),
61
+ XDG_DATA_HOME: sub("data"),
62
+ XDG_CONFIG_HOME: sub("config"),
63
+ XDG_STATE_HOME: sub("state"),
64
+ OPENCODE_DISABLE_PROJECT_CONFIG: "1",
65
+ OPENCODE_CONFIG_CONTENT: JSON.stringify({ $schema: "https://opencode.ai/config.json", share: "disabled", autoupdate: false }),
66
+ }
67
+ let server: { url: string; close(): void } | null = null
68
+ try {
69
+ server = await startServer(bin, env, 60_000)
70
+ const client = createOpencodeClient({ baseUrl: server.url, directory: dir })
71
+ const methods = (await client.provider.auth()).data?.[COPILOT_PROVIDER_ID] || []
72
+ const method = methods.findIndex((m) => m.type === "oauth")
73
+ if (method < 0) throw new Error("This agent runtime has no GitHub Copilot sign-in. Run forgeqe setup to install the version ForgeQE pins.")
74
+ const inputs: Record<string, string> = enterprise ? { deploymentType: "enterprise", enterpriseUrl: enterprise } : { deploymentType: "github.com" }
75
+ const started = await client.provider.oauth.authorize({ providerID: COPILOT_PROVIDER_ID, method, inputs })
76
+ if (!started.data) throw new Error(`GitHub did not start the sign-in: ${message(started.error)}`)
77
+ const url = verificationUrl(started.data.url, enterprise)
78
+ const code = userCode(started.data.instructions)
79
+ if (!url || !code) throw new Error("The agent runtime's sign-in did not give a GitHub page and one-time code.")
80
+ io.showCode(code, url)
81
+
82
+ // The callback returns once GitHub has the code entered (or the code expires); a stop gives up waiting.
83
+ const waited = await new Promise<{ ok: boolean; why?: string }>((resolve) => {
84
+ const timer = setTimeout(() => resolve({ ok: false, why: "The one-time code expired before it was entered." }), DEVICE_CODE_MS)
85
+ const stop = () => resolve({ ok: false, why: "Cancelled." })
86
+ io.signal?.addEventListener("abort", stop, { once: true })
87
+ client.provider.oauth
88
+ .callback({ providerID: COPILOT_PROVIDER_ID, method })
89
+ .then((r) => resolve(r.error ? { ok: false, why: `GitHub did not finish the sign-in: ${message(r.error)}` } : { ok: true }))
90
+ .catch((err) => resolve({ ok: false, why: `GitHub did not finish the sign-in: ${(err as Error).message}` }))
91
+ .finally(() => {
92
+ clearTimeout(timer)
93
+ io.signal?.removeEventListener("abort", stop)
94
+ })
95
+ })
96
+ if (!waited.ok) throw new Error(waited.why)
97
+
98
+ let saved: unknown
99
+ try {
100
+ saved = (JSON.parse(readFileSync(path.join(env.XDG_DATA_HOME, "opencode", "auth.json"), "utf8")) as Record<string, unknown>)[COPILOT_PROVIDER_ID]
101
+ } catch {}
102
+ const auth = parseCopilotAuth(saved)
103
+ if (!auth) throw new Error("The agent runtime did not save a GitHub Copilot sign-in.")
104
+ if ((auth.enterpriseUrl || undefined) !== enterprise) throw new Error("The Copilot sign-in is for a different GitHub than the one chosen.")
105
+
106
+ let models: string[] = []
107
+ try {
108
+ const list = (await client.provider.list()).data
109
+ const copilot = list?.all?.find((p) => p.id === COPILOT_PROVIDER_ID)
110
+ models = Object.keys(copilot?.models || {}).sort()
111
+ } catch {}
112
+ return { auth, models }
113
+ } finally {
114
+ server?.close()
115
+ rmSync(dir, { recursive: true, force: true })
116
+ }
117
+ }
118
+
119
+ /** The GitHub account a Copilot sign-in belongs to, or undefined when GitHub does not say. */
120
+ export async function copilotAccount(auth: CopilotAuth, fetchImpl: typeof fetch = fetch): Promise<string | undefined> {
121
+ const api = auth.enterpriseUrl ? `https://api.${auth.enterpriseUrl}` : "https://api.github.com"
122
+ try {
123
+ const res = await fetchImpl(`${api}/user`, { headers: { Accept: "application/vnd.github+json", Authorization: `Bearer ${auth.refresh}` }, signal: AbortSignal.timeout(10_000) })
124
+ if (!res.ok) return undefined
125
+ const login = ((await res.json()) as { login?: unknown }).login
126
+ return typeof login === "string" && /^[A-Za-z0-9-]{1,39}$/.test(login) ? login : undefined
127
+ } catch {
128
+ return undefined
129
+ }
130
+ }
@@ -1,10 +1,14 @@
1
1
  import { spawn, spawnSync } from "node:child_process"
2
2
 
3
3
  import { azCliAuth, resetAzCliAuth } from "../cases/ado.ts"
4
+ import { ghAuthToken } from "../cases/github.ts"
5
+ import { ensureOpencode, INSTALL_HINT } from "../code/runtime.ts"
6
+ import { COPILOT_AUTH_ENV, enterpriseHost, parseCopilotAuth, runtimeHasCopilot } from "../copilot.ts"
4
7
  import { basic, http } from "../cases/http.ts"
5
8
  import type { AppConfig } from "../config.ts"
6
9
  import type { AskQuestion } from "../mcp/tools.ts"
7
10
  import { withoutTelemetry } from "../privacy.ts"
11
+ import { copilotAccount, copilotDeviceLogin } from "./copilot.ts"
8
12
  import { forgetSystem, fromSaved, readIndex, savedSecrets, saveSystem, storeName, type Via } from "./store.ts"
9
13
 
10
14
  /** How a sign-in talks to the user: the terminal (`forgeqe auth`) or the shell's dialogs (/auth). */
@@ -19,11 +23,12 @@ export type AuthIO = {
19
23
  signal?: AbortSignal
20
24
  }
21
25
 
22
- export type SystemId = "azure_devops" | "github" | "jira" | "confluence" | "testrail" | "zephyr" | "qtest" | "xray" | "browserstack" | "testmu"
26
+ export type SystemId = "azure_devops" | "github" | "github_copilot" | "jira" | "confluence" | "testrail" | "zephyr" | "qtest" | "xray" | "browserstack" | "testmu"
23
27
 
24
28
  export const SYSTEMS: { id: SystemId; label: string; aliases: string[] }[] = [
25
29
  { id: "azure_devops", label: "Azure DevOps", aliases: ["ado", "azure", "devops", "azdo", "azure-devops"] },
26
30
  { id: "github", label: "GitHub", aliases: ["gh"] },
31
+ { id: "github_copilot", label: "GitHub Copilot", aliases: ["copilot", "github-copilot", "ghcp"] },
27
32
  { id: "jira", label: "Jira", aliases: ["atlassian"] },
28
33
  { id: "confluence", label: "Confluence", aliases: ["wiki"] },
29
34
  { id: "testrail", label: "TestRail", aliases: [] },
@@ -295,6 +300,51 @@ async function signInGithub(io: AuthIO): Promise<SignInResult> {
295
300
  return finish(io, "github", { via: "gh", env: {}, secrets: {}, account }, "GitHub CLI")
296
301
  }
297
302
 
303
+ /**
304
+ * GitHub Copilot as the model for ForgeQE's agent and Scribe: the agent runtime's own Copilot device sign-in, kept in
305
+ * ForgeQE's credential store. The browser engine uses GitHub Models with the GitHub CLI's sign-in, so say whether it has one.
306
+ */
307
+ async function signInCopilot(io: AuthIO, cfg?: AppConfig): Promise<SignInResult> {
308
+ const where =
309
+ (await io.choose({
310
+ title: "Sign in to GitHub Copilot",
311
+ detail:
312
+ "ForgeQE's agent and Scribe then run on your Copilot seat (Pro, Pro+, Business or Enterprise), through the agent runtime's GitHub Copilot sign-in. " +
313
+ "The browser engine uses GitHub Models with your GitHub CLI sign-in.",
314
+ options: [
315
+ { id: "github.com", label: "GitHub.com" },
316
+ { id: "enterprise", label: "GitHub Enterprise (data residency, e.g. acme.ghe.com)" },
317
+ { id: "cancel", label: "Cancel" },
318
+ ],
319
+ })) || "cancel"
320
+ if (where === "cancel") throw new Cancelled()
321
+ let enterprise: string | undefined
322
+ if (where === "enterprise") {
323
+ const raw = await ask(io, "Your GitHub Enterprise domain (e.g. acme.ghe.com)")
324
+ enterprise = enterpriseHost(raw) || undefined
325
+ if (!enterprise) throw new Error(`"${raw.slice(0, 80)}" is not a GitHub Enterprise domain: give its host, like acme.ghe.com.`)
326
+ }
327
+ const bin = await ensureOpencode(cfg?.code.opencode, (text) => io.say(text))
328
+ if (!bin) throw new Error(`GitHub Copilot signs in through the agent runtime, which is not installed. ${INSTALL_HINT}`)
329
+ const { auth, models } = await copilotDeviceLogin(
330
+ bin,
331
+ {
332
+ showCode: (code, url) => {
333
+ io.say(`Your one-time code is ${code}. Enter it on the GitHub page that opens in your browser (${url}), and authorize with the account that has the Copilot seat.`)
334
+ io.openUrl(url)
335
+ },
336
+ signal: io.signal,
337
+ },
338
+ enterprise,
339
+ )
340
+ const account = await copilotAccount(auth)
341
+ const result = await finish(io, "github_copilot", { via: "token", env: {}, secrets: { [COPILOT_AUTH_ENV]: JSON.stringify(auth) }, account }, "Copilot device sign-in")
342
+ if (models.length) io.say(`Copilot models for this seat: ${models.join(", ")}. Put one in llms.json as the github_copilot profile's "model".`)
343
+ if (!process.env.GITHUB_TOKEN?.trim() && !ghAuthToken())
344
+ io.say("The browser engine reaches GitHub Models with a GitHub sign-in, and there is none yet: run forgeqe auth login github, or set GITHUB_TOKEN.")
345
+ return result
346
+ }
347
+
298
348
  async function signInAtlassian(io: AuthIO, cfg: AppConfig | undefined, which: "jira" | "confluence"): Promise<SignInResult> {
299
349
  const names = envNames(cfg)[which]
300
350
  const label = which === "jira" ? "Jira" : "Confluence"
@@ -441,6 +491,8 @@ export async function signIn(io: AuthIO, system: string | undefined, cfg?: AppCo
441
491
  return await signInAdo(io, cfg)
442
492
  case "github":
443
493
  return await signInGithub(io)
494
+ case "github_copilot":
495
+ return await signInCopilot(io, cfg)
444
496
  case "jira":
445
497
  case "confluence":
446
498
  return await signInAtlassian(io, cfg, id)
@@ -507,6 +559,7 @@ export function authStatus(cfg?: AppConfig): string[] {
507
559
  const lines = [
508
560
  row("azure_devops", "Azure DevOps", ado && `${ado}, ${adoUrl}`),
509
561
  `${"GitHub".padEnd(14)}${ghUser ? `GitHub CLI as ${ghUser}` : Bun.which("gh") ? "not signed in" : "GitHub CLI (gh) not installed"}`,
562
+ row("github_copilot", "Copilot", parseCopilotAuth(process.env[COPILOT_AUTH_ENV]) ? (fromSaved.has(COPILOT_AUTH_ENV) ? "saved sign-in" : "environment") : runtimeHasCopilot() ? "agent runtime's own sign-in" : null),
510
563
  row("jira", "Jira", jira && `${jira}, ${jiraUrl}`),
511
564
  row("confluence", "Confluence", confHow && `${confHow}, ${conf!.url}`),
512
565
  row("testrail", "TestRail", cfg?.sources.testrail || process.env[n.testrail.url] ? source([n.testrail.email, n.testrail.token]) : null),
@@ -9,11 +9,13 @@ import { externalMcp, externalNotes, type ExternalPlan } from "../mcp/external.t
9
9
  import { MCP_NAME, TOOL_TIMEOUT_MS, localToolServer } from "../mcp/server.ts"
10
10
  import { TOOLS, environmentNotes, type ToolContext } from "../mcp/tools.ts"
11
11
  import { createRouter } from "../mcp/routing.ts"
12
+ import { CapabilityScope, capabilityIndex } from "../mcp/scope.ts"
12
13
  import { brainPrompt, BRAIN_SCHEMA, cleanFollowUps, type BrainReport, type FollowUp } from "./prompt.ts"
13
14
  import { redactSecrets } from "../sessionLogins.ts"
14
15
  import { logEvent } from "../sessionLog.ts"
15
16
  import { loadSkills } from "../skills/registry.ts"
16
17
  import { domainNotes } from "../domain.ts"
18
+ import { guardrailsNotes } from "../guardrails.ts"
17
19
 
18
20
  export const BRAIN_AGENT = "forgeqe"
19
21
 
@@ -119,12 +121,19 @@ export function carryOver(turns: { user: string; reply: string }[], text: string
119
121
  /** Routing (src/mcp/routing.ts) is on unless FORGEQE_ROUTING=off, kept for comparing and for finding a routing fault. */
120
122
  export const routingOn = (env: Record<string, string | undefined> = process.env) => !/^(off|0|false|no)$/i.test(env.FORGEQE_ROUTING || "")
121
123
 
124
+ /** Per-turn tool scope (src/mcp/scope.ts), part of routing; FORGEQE_TOOL_SCOPE=off gives every tool on every turn. */
125
+ export const toolScopeOn = (env: Record<string, string | undefined> = process.env) => routingOn(env) && !/^(off|0|false|no)$/i.test(env.FORGEQE_TOOL_SCOPE || "")
126
+
127
+ /** Continuations a turn may get after use_capabilities added capabilities, so a confused agent cannot loop. */
128
+ const MAX_SCOPE_CONTINUATIONS = 2
129
+
122
130
  export async function openBrain(opts: BrainOptions): Promise<Brain> {
123
131
  const { config } = opts
124
132
  const cwd = realpathOr(opts.cwd)
125
133
  const context = (): ToolContext => ({ ...opts.context(), config, cwd })
126
134
  // Tools listed in one line and playbooks on demand keep every model call small; FORGEQE_ROUTING=off sends it all.
127
- const router = routingOn() ? createRouter(TOOLS) : undefined
135
+ const scope = toolScopeOn() ? new CapabilityScope(config, TOOLS.map((t) => t.name)) : undefined
136
+ const router = routingOn() ? createRouter(TOOLS, { scope }) : undefined
128
137
  const tools = localToolServer({ tools: TOOLS, context, router, instructions: () => environmentNotes(context()), timeoutMs: TOOL_TIMEOUT_MS })
129
138
  const external = opts.external || externalMcp(config, Bun.which, process.env, { cwd })
130
139
  const notes = [environmentNotes(context()), ...externalNotes(external), ...(opts.notes || [])].filter(Boolean).join("\n")
@@ -142,6 +151,7 @@ export async function openBrain(opts: BrainOptions): Promise<Brain> {
142
151
  repo: cwd,
143
152
  profile: config.llm,
144
153
  domain: domainNotes(config),
154
+ guardrails: guardrailsNotes(config),
145
155
  model: config.code.model,
146
156
  bin: config.code.opencode,
147
157
  readDirs,
@@ -153,10 +163,11 @@ export async function openBrain(opts: BrainOptions): Promise<Brain> {
153
163
  agent: {
154
164
  name: BRAIN_AGENT,
155
165
  description: "ForgeQE: browser runs, test cases, tests, and CI pipelines",
156
- prompt: `${router ? router.parts.core : brainPrompt()}\n\nThis session:\n${notes}`,
166
+ prompt: `${router ? router.parts.core : brainPrompt()}${scope ? `\n\n${capabilityIndex()}` : ""}\n\nThis session:\n${notes}`,
157
167
  skills: "forgeqe",
158
168
  // Systems are read through MCP or query_api, never by scripts in the shell that handle tokens themselves.
159
- permission: { bash: agentBashPermission(privateEnvNames(config)) },
169
+ // Container services' secrets are the tests' and the containers', never the agent's to read in its shell.
170
+ permission: { bash: agentBashPermission([...privateEnvNames(config), ...Object.values(config.containers?.services || {}).flatMap((s) => s.secrets)]) },
160
171
  },
161
172
  skills: loadSkills(config.skills, cwd).skills,
162
173
  schema: BRAIN_SCHEMA,
@@ -184,7 +195,18 @@ export async function openBrain(opts: BrainOptions): Promise<Brain> {
184
195
  async ask(text) {
185
196
  stopped = false
186
197
  logEvent("agent.ask", { model: session.model, characters: text.length })
187
- const out = await session.prompt(text)
198
+ const turn = scope?.beginTurn(text)
199
+ if (turn) logEvent("agent.scope", { capabilities: turn.choice.reasons })
200
+ let out = await session.prompt(text, turn ? { tools: turn.tools } : undefined)
201
+ for (let i = 0; scope && i < MAX_SCOPE_CONTINUATIONS; i++) {
202
+ const added = scope.takePending()
203
+ if (!added.length || stopped || out.error === "stopped" || opts.signal?.aborted) break
204
+ logEvent("agent.scope.added", { capabilities: added })
205
+ out = await session.prompt(
206
+ `ForgeQE added ${added.join(", ")}: their tools are available now. Carry on with the user's request from where you stopped; do not repeat what is done.`,
207
+ { tools: scope.toggles() },
208
+ )
209
+ }
188
210
  // Written after the turn, when credentials the message gave are remembered and so redacted.
189
211
  logEvent("agent.turn", { ask: text, error: out.error, report: out.report, text: out.text })
190
212
  if (stopped || out.error === "stopped" || opts.signal?.aborted) return { reply: "", outcome: "stopped" }
@@ -28,6 +28,8 @@ export const GUIDE_EXCLUDED = ["HOW_TO_USE.md#agent-map"]
28
28
 
29
29
  const AREA_NOTE: Record<Area, string> = {
30
30
  Browser: "Goals carried out in a real browser, and the logins they use",
31
+ Desktop: "Windows thick-client apps: explore a goal, record by hand, replay the saved workflow",
32
+ Mobile: "Android and iOS apps through Appium or Maestro: explore a goal, replay, run the team's flows",
31
33
  "Test cases": "Run, check, write, and explore manual test cases",
32
34
  "Automated tests": "Write, heal, find, run, and stabilise tests in your repository",
33
35
  "Pull requests": "Reviews, impact, and the tests a change can break",