@capagents/forgeqe 1.2.1 → 1.3.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 (80) hide show
  1. package/HOW_TO_USE.md +40 -3
  2. package/README.md +1 -1
  3. package/docs/COST_ARCHITECTURE.md +280 -0
  4. package/docs/feature-test-prompts.md +95 -0
  5. package/docs/user-guide.html +54 -7
  6. package/evals/prompts/brain/route-api-scenarios.yaml +9 -0
  7. package/evals/prompts/brain/route-e2e.yaml +10 -0
  8. package/evals/prompts/brain/route-flaky.yaml +9 -0
  9. package/evals/prompts/brain/route-heal.yaml +9 -0
  10. package/evals/prompts/brain/route-regression-pack.yaml +9 -0
  11. package/evals/prompts/brain/route-review-update.yaml +10 -0
  12. package/evals/prompts/brain/route-run-cases.yaml +9 -0
  13. package/evals/prompts/brain/route-story-cases.yaml +12 -0
  14. package/forgeqe.example.yaml +15 -0
  15. package/package.json +1 -1
  16. package/prompts/README.md +30 -0
  17. package/prompts/brain.md +34 -49
  18. package/prompts/browser-checks.md +2 -0
  19. package/py/forgeqe_engine/checks.py +8 -2
  20. package/py/forgeqe_engine/llm.py +33 -5
  21. package/py/forgeqe_engine/memory.py +59 -2
  22. package/py/forgeqe_engine/observe.py +123 -0
  23. package/py/forgeqe_engine/replay.py +55 -2
  24. package/py/forgeqe_engine/service.py +155 -3
  25. package/src/agents.ts +52 -0
  26. package/src/brain/index.ts +8 -2
  27. package/src/capabilities/registry.ts +48 -6
  28. package/src/cases/ado.ts +1 -1
  29. package/src/cases/journey.ts +61 -0
  30. package/src/cases/readiness.ts +35 -6
  31. package/src/cases/save.ts +241 -48
  32. package/src/cases/types.ts +11 -0
  33. package/src/cli/help.ts +9 -0
  34. package/src/cli/index.ts +71 -1
  35. package/src/code/coverage.ts +22 -3
  36. package/src/code/decisions.ts +73 -0
  37. package/src/code/find.ts +3 -1
  38. package/src/code/graph/graph.ts +33 -2
  39. package/src/code/loop.ts +17 -1
  40. package/src/code/notes.ts +48 -1
  41. package/src/code/prompt.ts +31 -9
  42. package/src/code/protect.ts +103 -0
  43. package/src/code/review.ts +2 -0
  44. package/src/code/runtime.ts +35 -3
  45. package/src/code/shortlist.ts +309 -0
  46. package/src/code/testrun.ts +3 -1
  47. package/src/code/traceability.ts +2 -0
  48. package/src/commands.ts +6 -1
  49. package/src/config.ts +46 -0
  50. package/src/context.ts +75 -0
  51. package/src/engine/bridge.ts +47 -5
  52. package/src/mcp/captools.ts +19 -0
  53. package/src/mcp/casetools.ts +107 -11
  54. package/src/mcp/commands.ts +74 -20
  55. package/src/mcp/packtools.ts +187 -0
  56. package/src/mcp/routing.ts +145 -0
  57. package/src/mcp/server.ts +7 -3
  58. package/src/mcp/tools.ts +241 -29
  59. package/src/pack/collect.ts +251 -0
  60. package/src/pack/index.ts +134 -0
  61. package/src/pack/init.ts +45 -0
  62. package/src/pack/pack.ts +327 -0
  63. package/src/pack/pipeline.ts +373 -0
  64. package/src/pack/run.ts +155 -0
  65. package/src/registry/db.ts +249 -0
  66. package/src/registry/index.ts +72 -0
  67. package/src/registry/resolve.ts +57 -0
  68. package/src/registry/tier0.ts +81 -0
  69. package/src/report/data.ts +7 -0
  70. package/src/report/html.ts +14 -0
  71. package/src/report/template.html +52 -48
  72. package/src/report/usage.ts +113 -0
  73. package/src/run.ts +22 -2
  74. package/src/suite/run.ts +12 -5
  75. package/src/summary.ts +1 -0
  76. package/src/tokenomics/budget.ts +172 -0
  77. package/src/tokenomics/ledger.ts +287 -0
  78. package/src/tui/App.tsx +89 -2
  79. package/src/tui/inspector.tsx +40 -35
  80. package/src/types.ts +9 -2
@@ -0,0 +1,187 @@
1
+ import { realpathSync, writeFileSync } from "node:fs"
2
+ import path from "node:path"
3
+
4
+ import { listPacks, loadPack, makePack, packCard, packMarkdown, packRunCard, runPack, type PackSources } from "../pack/index.ts"
5
+ import { packsDir } from "../pack/pack.ts"
6
+ import { runPackOnPipeline } from "../pack/pipeline.ts"
7
+ import type { McpTool, ToolContext } from "./tools.ts"
8
+
9
+ const clip = (s: string | undefined, n: number) => {
10
+ const t = (s || "").trim()
11
+ return t.length <= n ? t : `…${t.slice(-(n - 1))}`
12
+ }
13
+
14
+ function repoOf(ctx: ToolContext, given?: unknown): string {
15
+ const p = given ? path.resolve(ctx.cwd, String(given)) : ctx.cwd
16
+ try {
17
+ return realpathSync(p)
18
+ } catch {
19
+ return p
20
+ }
21
+ }
22
+
23
+ const ITEM_SCHEMA = {
24
+ type: "object",
25
+ properties: {
26
+ source: { type: "string", description: "The tool it came from: jira, github, azure_devops, confluence, linear…" },
27
+ id: { type: "string" },
28
+ title: { type: "string" },
29
+ text: { type: "string", description: "What changes, in its words: description, acceptance criteria, release note" },
30
+ url: { type: "string" },
31
+ type: { type: "string", description: "Story, Bug, Feature, Task…" },
32
+ kind: { type: "string", enum: ["work_item", "pr", "commit", "note"] },
33
+ files: { type: "array", items: { type: "string" }, description: "Files it changed, when known" },
34
+ links: { type: "array", items: { type: "string" }, description: "Work items it refers to: ado:123, jira:PAY-12, github:45" },
35
+ test_cases: { type: "array", items: { type: "string" }, description: "Test cases that test it (Azure DevOps ids, or ADO-123)" },
36
+ },
37
+ required: ["title"],
38
+ }
39
+
40
+ export const PACK_TOOLS: McpTool[] = [
41
+ {
42
+ name: "build_regression_pack",
43
+ description:
44
+ "Build a regression pack: the repository's tests that a release's changes can break, each with why it is in (it automates a test case of a changed work item; the change is in the test code it uses; the work item's words match what it does; it works in a risk area the release touches; it is a smoke test), and the changes no test covers, each with the closest test and what it lacks. Changes come from any mix of: an Azure DevOps sprint, area, epic, or work item ids (with their linked pull requests' files); the commits between two refs of a repository; pull requests by number or merged in the last days; and items you read from any other connected tool (Jira, GitHub issues, a release note), passed in items. Saves the pack by name in .forgeqe/packs and shows a card. Nothing runs. Takes seconds.",
45
+ inputSchema: {
46
+ type: "object",
47
+ properties: {
48
+ name: { type: "string", description: "The pack's name, e.g. release-1.5 (default: release-<date>)" },
49
+ repo: { type: "string", description: "The test repository (default: the working directory)" },
50
+ sprint: { type: "string", description: "Azure DevOps sprint name of the team, or current" },
51
+ iteration: { type: "string", description: "Azure DevOps iteration path and everything under it: a release such as Release 2026.09, or Project\\Release 2026.09\\Sprint 13" },
52
+ area: { type: "string", description: "Azure DevOps area path" },
53
+ parent: { type: "number", description: "An Azure DevOps epic or feature: everything under it" },
54
+ work_items: { type: "array", items: { type: "number" }, description: "Azure DevOps work item ids" },
55
+ project: { type: "string", description: "Azure DevOps project (default: sources.azure_devops.project)" },
56
+ range_repo: { type: "string", description: "Repository for from/to (the product's, or the tests' own); default: the test repository" },
57
+ from: { type: "string", description: "Commits after this ref: a tag, branch, or commit (e.g. release/1.4)" },
58
+ to: { type: "string", description: "Commits up to this ref (default HEAD)" },
59
+ prs: { type: "array", items: { type: "string" }, description: "Pull request numbers or URLs" },
60
+ merged_days: { type: "number", description: "Pull requests merged in the last N days" },
61
+ pr_repo: { type: "string", description: "Repository the pull requests are in (default: the test repository)" },
62
+ items: { type: "array", items: ITEM_SCHEMA, description: "Changes you read from other connected tools, as they are" },
63
+ risk: { type: "boolean", description: "Add tests for risk areas the release touches (default true)" },
64
+ smoke: { type: "boolean", description: "Add smoke-tagged tests (default true)" },
65
+ },
66
+ },
67
+ async run(args, ctx) {
68
+ const repo = repoOf(ctx, args.repo)
69
+ const sources: PackSources = {
70
+ ...(args.sprint || args.iteration || args.area || args.parent || (Array.isArray(args.work_items) && args.work_items.length)
71
+ ? { ado: { ...(args.sprint ? { sprint: String(args.sprint) } : {}), ...(args.iteration ? { iteration: String(args.iteration) } : {}), ...(args.area ? { area: String(args.area) } : {}), ...(args.parent ? { parent: Number(args.parent) } : {}), ...(Array.isArray(args.work_items) ? { ids: args.work_items.map(Number).filter(Boolean) } : {}), ...(args.project ? { project: String(args.project) } : {}) } }
72
+ : {}),
73
+ ...(args.from ? { range: { repo: repoOf(ctx, args.range_repo || args.repo), from: String(args.from), ...(args.to ? { to: String(args.to) } : {}) } } : {}),
74
+ ...(Array.isArray(args.prs) && args.prs.length ? { prs: args.prs.map(String) } : {}),
75
+ ...(Number(args.merged_days) > 0 ? { mergedDays: Number(args.merged_days) } : {}),
76
+ ...(args.pr_repo ? { prRepo: repoOf(ctx, args.pr_repo) } : {}),
77
+ ...(Array.isArray(args.items) && args.items.length ? { items: args.items } : {}),
78
+ }
79
+ const pack = await makePack({ config: ctx.config, repo, name: args.name ? String(args.name) : undefined, sources, risk: args.risk !== false, smoke: args.smoke !== false })
80
+ const md = path.join(packsDir(repo), `${pack.name}.md`)
81
+ writeFileSync(md, `${packMarkdown(pack)}\n`)
82
+ ctx.emit?.({ type: "card", card: packCard(pack) })
83
+ return {
84
+ name: pack.name,
85
+ tests: pack.tests.map((t) => ({ test: t.test, file: t.file, why: [...new Set(t.reasons.map((r) => r.text))], ...(t.flaky ? { flaky: t.flaky } : {}) })),
86
+ changes: pack.items.length,
87
+ from: pack.scope,
88
+ not_covered: pack.uncovered,
89
+ risk_areas: pack.risk,
90
+ notes: pack.notes.length ? pack.notes : undefined,
91
+ file: md,
92
+ next_step: [
93
+ pack.tests.length ? `Offer to run it: run_regression_pack with name ${pack.name} and on local, testmu, or pipeline.` : "",
94
+ pack.uncovered.length ? "Name the changes no test covers. For one with a closest test, offer to extend that test (browser_run with existing_test and existing_mode extend), so the next pack covers it." : "",
95
+ ]
96
+ .filter(Boolean)
97
+ .join(" "),
98
+ }
99
+ },
100
+ },
101
+ {
102
+ name: "run_regression_pack",
103
+ description:
104
+ "Run a saved regression pack after the user confirms: on local (this machine's browsers), testmu (TestMu AI's cloud browsers: the repository's tests connect to them through FORGEQE_PW_WS_ENDPOINT), or pipeline (a CI pipeline runs it). Reads each test's result, keeps the run with the pack, and says what changed since its last run. Where ForgeQE cannot ask (an MCP client), pass confirmed: true only after the user agreed. Takes minutes.",
105
+ inputSchema: {
106
+ type: "object",
107
+ properties: {
108
+ name: { type: "string", description: "The pack's name" },
109
+ on: { type: "string", enum: ["local", "testmu", "pipeline"], description: "Where to run it (default local)" },
110
+ pipeline: { type: "string", enum: ["github", "azure_devops"], description: "With on pipeline: which one (default: packs.pipeline.default in forgeqe.yaml)" },
111
+ browsers: { type: "string", enum: ["local", "testmu"], description: "With on pipeline: the runner's own browser, or TestMu AI's (default local)" },
112
+ repo: { type: "string", description: "The test repository (default: the working directory)" },
113
+ confirmed: { type: "boolean", description: "Only where ForgeQE cannot ask: the user agreed to this run" },
114
+ },
115
+ required: ["name"],
116
+ },
117
+ async run(args, ctx) {
118
+ const repo = repoOf(ctx, args.repo)
119
+ const pack = loadPack(repo, String(args.name))
120
+ const on = args.on === "testmu" || args.on === "pipeline" ? args.on : "local"
121
+ const browsers = args.browsers === "testmu" ? "testmu" : "local"
122
+ const where = on === "testmu" ? "on TestMu AI" : on === "pipeline" ? `in the ${args.pipeline === "azure_devops" ? "Azure DevOps" : args.pipeline === "github" ? "GitHub Actions" : "configured"} pipeline${browsers === "testmu" ? " on TestMu AI" : ""}` : "on this machine"
123
+ const what = `${pack.tests.length} test${pack.tests.length === 1 ? "" : "s"}`
124
+ if (ctx.ask) {
125
+ const choice = await ctx.ask({
126
+ title: `Run pack ${pack.name} (${what}) ${where}?`,
127
+ lines: pack.tests.slice(0, 20).map((t) => ` • ${t.test}`).concat(pack.tests.length > 20 ? [` and ${pack.tests.length - 20} more`] : []),
128
+ options: [
129
+ { id: "run", label: `Run ${what} ${where}` },
130
+ { id: "no", label: "Don't run" },
131
+ ],
132
+ })
133
+ if (choice !== "run") return { ran: false, reason: "The user chose not to run it." }
134
+ } else if (args.confirmed !== true) {
135
+ return { ran: false, needs_confirmation: true, tests: pack.tests.map((t) => t.test), on, next_step: "Show the user the pack and where it runs; call run_regression_pack again with confirmed: true only after they say yes." }
136
+ }
137
+ const { run, record, notes, pack: after } =
138
+ on === "pipeline"
139
+ ? await runPackOnPipeline({
140
+ config: ctx.config,
141
+ repo,
142
+ name: pack.name,
143
+ ...(args.pipeline === "github" || args.pipeline === "azure_devops" ? { kind: args.pipeline } : {}),
144
+ target: browsers,
145
+ signal: ctx.signal,
146
+ onProgress: (text) => ctx.emit?.({ type: "agent", event: { type: "code", kind: "info", text } }),
147
+ })
148
+ : await runPack({ config: ctx.config, repo, name: pack.name, on, signal: ctx.signal, runTest: ctx.runTest })
149
+ ctx.emit?.({ type: "card", card: packRunCard(after, run, record, notes) })
150
+ const failed = run.tests.filter((t) => t.status === "failed")
151
+ return {
152
+ ran: true,
153
+ on: record.target,
154
+ status: run.status,
155
+ passed: run.counts.passed,
156
+ failed: run.counts.failed,
157
+ skipped: run.counts.skipped,
158
+ seconds: run.seconds,
159
+ failures: failed.length ? failed.map((t) => ({ name: t.name, message: t.message })) : undefined,
160
+ ...(run.flaky?.length ? { flaky: run.flaky } : {}),
161
+ previous: after.runs.at(-2) || undefined,
162
+ output: run.status === "passed" ? undefined : clip(run.output, 3000),
163
+ folder: run.dir,
164
+ ...(record.url ? { pipeline_run: record.url } : {}),
165
+ notes: notes.length ? notes : undefined,
166
+ next_step: failed.length ? "Name the failing tests with their messages; offer heal_tests for a test the app outgrew, or file the defects (file_bug)." : undefined,
167
+ }
168
+ },
169
+ },
170
+ {
171
+ name: "list_regression_packs",
172
+ description: "The saved regression packs of a repository: name, when built, from what, how many tests and gaps, and their last run. Nothing runs.",
173
+ inputSchema: { type: "object", properties: { repo: { type: "string" } } },
174
+ async run(args, ctx) {
175
+ return {
176
+ packs: listPacks(repoOf(ctx, args.repo)).map((p) => ({
177
+ name: p.name,
178
+ built: p.createdAt.slice(0, 16).replace("T", " "),
179
+ from: p.scope,
180
+ tests: p.tests.length,
181
+ not_covered: p.uncovered.length,
182
+ last_run: p.runs.at(-1) ? { at: p.runs.at(-1)!.at.slice(0, 16).replace("T", " "), on: p.runs.at(-1)!.target, status: p.runs.at(-1)!.status, failed: p.runs.at(-1)!.counts.failed } : undefined,
183
+ })),
184
+ }
185
+ },
186
+ },
187
+ ]
@@ -0,0 +1,145 @@
1
+ import { CAPABILITIES, type Capability } from "../capabilities/registry.ts"
2
+ import { prompt } from "../prompts.ts"
3
+ import type { McpTool } from "./tools.ts"
4
+
5
+ /**
6
+ * What ForgeQE's agent carries on every model call, and what it gets only when a task needs it.
7
+ *
8
+ * Every model call sends the system prompt and the tool definitions again. Tools stay listed in full: their
9
+ * descriptions are how the agent chooses a tool and plans the task, and their arguments are needed for any call, so a
10
+ * tool listed in one line cost an extra model call each time (measured: more tokens, not fewer). The playbook is
11
+ * what moves: the sections of brain.md that a capability owns (`playbook`) leave the system prompt, an index of them
12
+ * stays, and the first result of one of the capability's tools in a session (or of a tool in `alsoAfter`) carries the
13
+ * section. The `playbook` tool reads one before that. A section no capability owns stays in the system prompt, so a
14
+ * section renamed in a team's own copy of brain.md is never lost.
15
+ */
16
+
17
+ /** Rough tokens of a text: the budgets and `forgeqe context` use it, not billing. */
18
+ export const tokens = (text: string) => Math.ceil(text.length / 4)
19
+
20
+ const norm = (s: string) => s.trim().toLowerCase()
21
+
22
+ /** The capability each tool belongs to. */
23
+ export function capabilityOfTool(name: string, caps: Capability[] = CAPABILITIES): Capability | undefined {
24
+ return caps.find((c) => c.tools.includes(name))
25
+ }
26
+
27
+ // ---------------------------------------------------------------------------------------------
28
+ // The playbook
29
+ // ---------------------------------------------------------------------------------------------
30
+
31
+ export type Playbook = { heading: string; when: string; capability: string; tools: string[]; text: string }
32
+
33
+ /** brain.md split: the system prompt without the owned sections (with an index of them), and those sections. */
34
+ export type BrainParts = { core: string; playbooks: Playbook[] }
35
+
36
+ const SECTION = /^###\s+(.+?)\s*$/
37
+
38
+ /** Split the agent's prompt by the capabilities' playbook sections. Unowned sections stay in `core`. */
39
+ export function splitBrain(text: string, caps: Capability[] = CAPABILITIES): BrainParts {
40
+ const owned = new Map(caps.flatMap((c) => (c.playbook || []).map((p) => [norm(p.heading), { ...p, capability: c.id, tools: c.tools }] as const)))
41
+ const lines = text.split("\n")
42
+ const keep: string[] = []
43
+ const playbooks: Playbook[] = []
44
+ let current: { entry: Omit<Playbook, "text">; lines: string[] } | null = null
45
+ const close = () => {
46
+ if (current) playbooks.push({ ...current.entry, text: current.lines.join("\n").trim() })
47
+ current = null
48
+ }
49
+ for (const line of lines) {
50
+ const h = SECTION.exec(line)
51
+ if (h || /^##\s/.test(line)) {
52
+ close()
53
+ const entry = h ? owned.get(norm(h[1])) : undefined
54
+ if (entry) {
55
+ current = { entry: { heading: h![1].trim(), when: entry.when, capability: entry.capability, tools: entry.tools }, lines: [line] }
56
+ continue
57
+ }
58
+ }
59
+ if (current) current.lines.push(line)
60
+ else keep.push(line)
61
+ }
62
+ close()
63
+ let core = keep.join("\n").replace(/\n{3,}/g, "\n\n")
64
+ if (playbooks.length) {
65
+ const index = [
66
+ "",
67
+ "These playbooks are not in this prompt. The first result of their tools carries each one: obey it. To read one before you start, call playbook with its name.",
68
+ ...playbooks.map((p) => `- "${p.heading}": ${p.when}. Tools: ${p.tools.join(", ")}.`),
69
+ "",
70
+ ].join("\n")
71
+ core = /^## Playbooks\s*$/m.test(core) ? core.replace(/^## Playbooks\s*$/m, (m) => `${m}\n${index}`) : `${core.trimEnd()}\n\n## Playbooks\n${index}`
72
+ }
73
+ return { core: core.trim(), playbooks }
74
+ }
75
+
76
+ /** ForgeQE's agent's prompt as it is sent: the core, with the index of the playbooks it gets on demand. */
77
+ export const brainParts = (): BrainParts => splitBrain(prompt("brain"))
78
+
79
+ // ---------------------------------------------------------------------------------------------
80
+ // One session of ForgeQE's agent
81
+ // ---------------------------------------------------------------------------------------------
82
+
83
+ /**
84
+ * Routing for one agent session: which playbooks it was given, and what goes with a result. The playbook text is read
85
+ * once, when the session starts, like the system prompt.
86
+ */
87
+ export function createRouter(tools: McpTool[], opts: { caps?: Capability[]; brain?: string } = {}) {
88
+ const caps = opts.caps || CAPABILITIES
89
+ const parts = splitBrain(opts.brain ?? prompt("brain"), caps)
90
+ const given = new Set<string>()
91
+ const names = new Set(tools.map((t) => t.name))
92
+
93
+ const playbooksFor = (tool: string) => parts.playbooks.filter((p) => p.tools.includes(tool) || caps.find((c) => c.id === p.capability)?.alsoAfter?.includes(tool))
94
+ const give = (books: Playbook[]) => {
95
+ const fresh = books.filter((b) => !given.has(b.heading))
96
+ for (const b of fresh) given.add(b.heading)
97
+ return fresh
98
+ }
99
+ const block = (books: Playbook[]) => (books.length ? `\n\nPlaybook (obey it for this task):\n\n${books.map((b) => b.text).join("\n\n")}` : "")
100
+
101
+ return {
102
+ parts,
103
+ /** What goes after an error of a tool: the playbooks of its capability this session has not had yet. */
104
+ after(name: string): string {
105
+ return block(give(playbooksFor(name)))
106
+ },
107
+ /**
108
+ * A result with the playbooks it should carry: in a `playbook` field of a JSON object, so the result stays JSON for
109
+ * whatever reads it; appended to any other text.
110
+ */
111
+ attach(name: string, text: string): string {
112
+ const books = give(playbooksFor(name))
113
+ if (!books.length) return text
114
+ const playbook = `Obey it for this task:\n\n${books.map((b) => b.text).join("\n\n")}`
115
+ try {
116
+ const parsed = JSON.parse(text)
117
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return JSON.stringify({ ...parsed, playbook }, null, 2)
118
+ } catch {}
119
+ return text + block(books)
120
+ },
121
+ /** The playbook tool: playbooks by name, by capability id, or of a tool's capability. */
122
+ help(asked: string[]): string {
123
+ const books: Playbook[] = []
124
+ const unknown: string[] = []
125
+ for (const raw of asked) {
126
+ const exact = norm(raw)
127
+ // The client shows ForgeQE's tools with a forgeqe_ prefix; forgeqe_setup is that tool's own name.
128
+ const tool = names.has(exact) ? exact : names.has(exact.replace(/^forgeqe_/, "")) ? exact.replace(/^forgeqe_/, "") : ""
129
+ const found = tool ? playbooksFor(tool) : parts.playbooks.filter((p) => norm(p.heading) === exact || p.capability === exact)
130
+ if (found.length) books.push(...found)
131
+ else unknown.push(raw)
132
+ }
133
+ const unique = [...new Map(books.map((b) => [b.heading, b])).values()]
134
+ for (const b of unique) given.add(b.heading)
135
+ return [
136
+ ...(unique.length ? ["Playbook (obey it for this task):", "", ...unique.map((b) => b.text)] : []),
137
+ ...(unknown.length ? ["", `No playbook for: ${unknown.join(", ")}. Playbooks: ${parts.playbooks.map((p) => `"${p.heading}"`).join(", ")}. A tool not under one of them has its rules in its own description and in the prompt.`] : []),
138
+ ]
139
+ .join("\n")
140
+ .trim()
141
+ },
142
+ }
143
+ }
144
+
145
+ export type Router = ReturnType<typeof createRouter>
package/src/mcp/server.ts CHANGED
@@ -2,6 +2,7 @@ import { createHash, randomBytes, timingSafeEqual } from "node:crypto"
2
2
 
3
3
  import { EvidenceLog } from "../report/evidence.ts"
4
4
  import { logEvent } from "../sessionLog.ts"
5
+ import type { Router } from "./routing.ts"
5
6
  import type { McpTool, ToolContext, ToolEvent } from "./tools.ts"
6
7
 
7
8
  type Json = Record<string, any>
@@ -21,6 +22,8 @@ export type McpServerOptions = {
21
22
  /** Read on every call, so the shell's current profile and headed setting apply. */
22
23
  context: () => ToolContext
23
24
  instructions?: () => string
25
+ /** ForgeQE's agent: playbooks given with the first result of their tools. Other clients get results as they are. */
26
+ router?: Router
24
27
  version?: string
25
28
  progressMs?: number
26
29
  }
@@ -41,7 +44,7 @@ export function createMcpServer(opts: McpServerOptions) {
41
44
  async function callTool(params: Json, id: string | number, send: Send, outer?: AbortSignal): Promise<Json> {
42
45
  const tool = opts.tools.find((t) => t.name === params.name)
43
46
  if (!tool) return { content: [{ type: "text", text: `Unknown tool ${params.name}` }], isError: true }
44
- const base = { evidence, ...opts.context() }
47
+ const base = { evidence, ...opts.context(), ...(opts.router ? { router: opts.router } : {}) }
45
48
  const controller = new AbortController()
46
49
  const abort = () => controller.abort()
47
50
  base.signal?.addEventListener("abort", abort, { once: true })
@@ -91,9 +94,10 @@ export function createMcpServer(opts: McpServerOptions) {
91
94
  said(true)
92
95
  const text = typeof out === "string" ? out : JSON.stringify(out, null, 2)
93
96
  base.evidence.record(tool.name, args, text)
94
- return finish({ content: [{ type: "text", text }] })
97
+ return finish({ content: [{ type: "text", text: opts.router ? opts.router.attach(tool.name, text) : text }] })
95
98
  } catch (err) {
96
- return finish({ content: [{ type: "text", text: controller.signal.aborted ? "Stopped by the user." : (err as Error).message || String(err) }], isError: true })
99
+ const message = controller.signal.aborted ? "Stopped by the user." : (err as Error).message || String(err)
100
+ return finish({ content: [{ type: "text", text: message + (controller.signal.aborted ? "" : opts.router?.after(tool.name) || "") }], isError: true })
97
101
  } finally {
98
102
  if (timer) clearInterval(timer)
99
103
  running.delete(id)