@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.
- package/HOW_TO_USE.md +1639 -0
- package/README.md +338 -2
- package/bin/forgeqe.d.ts +3 -0
- package/bin/forgeqe.js +188 -0
- package/docs/ARCHITECTURE.md +408 -0
- package/docs/security-data-flow.html +1870 -0
- package/docs/user-guide.html +2094 -0
- package/evals/domain/access-role-change.yaml +37 -0
- package/evals/domain/banking-payment-transfer.yaml +41 -0
- package/evals/domain/gdpr-account-erasure.yaml +30 -0
- package/evals/domain/insurance-claim-fnol.yaml +30 -0
- package/evals/domain/insurance-policy-renewal.yaml +37 -0
- package/evals/domain/lending-loan-application.yaml +36 -0
- package/forgeqe-architecture.html +718 -0
- package/forgeqe.example.yaml +203 -0
- package/knowledge/README.md +18 -0
- package/knowledge/business-rules.md +10 -0
- package/knowledge/compliance.md +16 -0
- package/knowledge/environments.md +5 -0
- package/knowledge/glossary.md +5 -0
- package/knowledge/product.md +16 -0
- package/knowledge/roles.md +9 -0
- package/knowledge/test-data.md +7 -0
- package/llms.example.json +51 -0
- package/package.json +63 -4
- package/prompts/README.md +34 -0
- package/prompts/api-scenarios.md +21 -0
- package/prompts/api-tests.md +39 -0
- package/prompts/brain.md +166 -0
- package/prompts/browser-capture.md +7 -0
- package/prompts/browser-case-report.md +7 -0
- package/prompts/browser-checks.md +14 -0
- package/prompts/browser-efficiency.md +7 -0
- package/prompts/browser-error-recovery.md +15 -0
- package/prompts/browser-extra.md +5 -0
- package/prompts/browser-goal-verification.md +13 -0
- package/prompts/browser-skill-select.md +6 -0
- package/prompts/domain-rule.md +13 -0
- package/prompts/eval-judge.md +19 -0
- package/prompts/explore-charters.md +27 -0
- package/prompts/find-tests.md +28 -0
- package/prompts/heal.md +40 -0
- package/prompts/pr-review.md +29 -0
- package/prompts/skill-rule.md +6 -0
- package/prompts/test-agent.md +34 -0
- package/prompts/test-reviewer.md +30 -0
- package/prompts/test-rules.md +28 -0
- package/py/forgeqe_engine/__init__.py +3 -0
- package/py/forgeqe_engine/__main__.py +3 -0
- package/py/forgeqe_engine/brand.py +453 -0
- package/py/forgeqe_engine/brand_assets.py +6 -0
- package/py/forgeqe_engine/browser.py +196 -0
- package/py/forgeqe_engine/capture.py +538 -0
- package/py/forgeqe_engine/checks.py +98 -0
- package/py/forgeqe_engine/cli.py +188 -0
- package/py/forgeqe_engine/diagnostics.py +609 -0
- package/py/forgeqe_engine/llm.py +195 -0
- package/py/forgeqe_engine/logins.py +251 -0
- package/py/forgeqe_engine/loops.py +224 -0
- package/py/forgeqe_engine/memory.py +1004 -0
- package/py/forgeqe_engine/network.py +244 -0
- package/py/forgeqe_engine/prompts.py +20 -0
- package/py/forgeqe_engine/replay.py +240 -0
- package/py/forgeqe_engine/report.py +173 -0
- package/py/forgeqe_engine/service.py +1335 -0
- package/py/forgeqe_engine/settings.py +93 -0
- package/py/forgeqe_engine/skills.py +88 -0
- package/py/forgeqe_engine/summary.py +239 -0
- package/py/forgeqe_engine/urls.py +81 -0
- package/py/forgeqe_engine/workflow_yaml.py +689 -0
- package/skills/dotnet-nunit-playwright/SKILL.md +34 -0
- package/skills/dynamics-365/SKILL.md +21 -0
- package/skills/playwright-typescript/SKILL.md +35 -0
- package/skills/writing-skills/SKILL.md +30 -0
- package/src/api/plan.ts +246 -0
- package/src/api/run.ts +204 -0
- package/src/api/scenario.ts +502 -0
- package/src/api/schema.ts +170 -0
- package/src/api/spec.ts +624 -0
- package/src/api/store.ts +232 -0
- package/src/api/traffic.ts +281 -0
- package/src/api/types.ts +163 -0
- package/src/api/write.ts +272 -0
- package/src/auth/signin.ts +494 -0
- package/src/auth/store.ts +170 -0
- package/src/brain/index.ts +189 -0
- package/src/brain/prompt.ts +19 -0
- package/src/capabilities/docs.ts +101 -0
- package/src/capabilities/guide.ts +455 -0
- package/src/capabilities/registry.ts +343 -0
- package/src/capabilities/render.ts +106 -0
- package/src/capabilities/status.ts +137 -0
- package/src/card.ts +11 -0
- package/src/cases/ado.ts +325 -0
- package/src/cases/api.ts +117 -0
- package/src/cases/bugs.ts +223 -0
- package/src/cases/confluence.ts +111 -0
- package/src/cases/context.ts +223 -0
- package/src/cases/dashboards.ts +137 -0
- package/src/cases/failure.ts +97 -0
- package/src/cases/files.ts +295 -0
- package/src/cases/goal.ts +53 -0
- package/src/cases/http.ts +111 -0
- package/src/cases/index.ts +131 -0
- package/src/cases/jira.ts +122 -0
- package/src/cases/qtest.ts +108 -0
- package/src/cases/quality.ts +190 -0
- package/src/cases/rag.ts +100 -0
- package/src/cases/readiness.ts +284 -0
- package/src/cases/save.ts +149 -0
- package/src/cases/sprints.ts +148 -0
- package/src/cases/testrail.ts +176 -0
- package/src/cases/types.ts +107 -0
- package/src/cases/wiki.ts +105 -0
- package/src/cases/xray.ts +206 -0
- package/src/cases/zephyr.ts +153 -0
- package/src/childEnv.ts +86 -0
- package/src/cli/args.ts +114 -0
- package/src/cli/help.ts +167 -0
- package/src/cli/index.ts +1142 -0
- package/src/cli/terminal.ts +187 -0
- package/src/code/areas.ts +337 -0
- package/src/code/attention.ts +214 -0
- package/src/code/find.ts +540 -0
- package/src/code/handoff.ts +541 -0
- package/src/code/history.ts +170 -0
- package/src/code/impact.ts +231 -0
- package/src/code/index.ts +34 -0
- package/src/code/livebrowser.ts +67 -0
- package/src/code/loop.ts +603 -0
- package/src/code/notes.ts +75 -0
- package/src/code/profile.ts +226 -0
- package/src/code/prompt.ts +303 -0
- package/src/code/prreview.ts +649 -0
- package/src/code/prtests.ts +103 -0
- package/src/code/replay.ts +772 -0
- package/src/code/review.ts +202 -0
- package/src/code/runtime.ts +757 -0
- package/src/code/slices.ts +159 -0
- package/src/code/testrun.ts +187 -0
- package/src/code/traceability.ts +100 -0
- package/src/code/worktree.ts +153 -0
- package/src/commands.ts +101 -0
- package/src/config.ts +970 -0
- package/src/detail.ts +65 -0
- package/src/domain.ts +19 -0
- package/src/engine/bridge.ts +359 -0
- package/src/engine/mock.ts +102 -0
- package/src/engine/setup.ts +298 -0
- package/src/entra.ts +154 -0
- package/src/env.ts +24 -0
- package/src/evals/domain.ts +338 -0
- package/src/explore/charters.ts +151 -0
- package/src/explore/run.ts +280 -0
- package/src/explore/story.ts +106 -0
- package/src/guard.ts +51 -0
- package/src/heal/loop.ts +357 -0
- package/src/heal/prompt.ts +143 -0
- package/src/heal/report.ts +95 -0
- package/src/index.ts +12 -0
- package/src/init.ts +102 -0
- package/src/knowledge.ts +114 -0
- package/src/legacyEnv.ts +9 -0
- package/src/llm.ts +61 -0
- package/src/locale.ts +25 -0
- package/src/logins.ts +148 -0
- package/src/managed.ts +101 -0
- package/src/mcp/apitools.ts +335 -0
- package/src/mcp/captools.ts +25 -0
- package/src/mcp/casetools.ts +223 -0
- package/src/mcp/commands.ts +421 -0
- package/src/mcp/exploretools.ts +129 -0
- package/src/mcp/external.ts +172 -0
- package/src/mcp/git.ts +78 -0
- package/src/mcp/notifytools.ts +49 -0
- package/src/mcp/prtools.ts +261 -0
- package/src/mcp/qualitytools.ts +154 -0
- package/src/mcp/repotests.ts +286 -0
- package/src/mcp/server.ts +234 -0
- package/src/mcp/tools.ts +810 -0
- package/src/memory.ts +250 -0
- package/src/money.ts +39 -0
- package/src/monitor/alerts.ts +94 -0
- package/src/monitor/background.ts +68 -0
- package/src/monitor/run.ts +263 -0
- package/src/monitor/state.ts +105 -0
- package/src/notify/events.ts +144 -0
- package/src/notify/notice.ts +177 -0
- package/src/prices.ts +175 -0
- package/src/print.ts +54 -0
- package/src/privacy.ts +35 -0
- package/src/prompts.ts +165 -0
- package/src/quality/budgets.ts +249 -0
- package/src/quality/history.ts +149 -0
- package/src/quality/visual.ts +201 -0
- package/src/report/data.ts +475 -0
- package/src/report/html.ts +106 -0
- package/src/report/template.html +84 -0
- package/src/run.ts +243 -0
- package/src/sessionLog.ts +216 -0
- package/src/sessionLogins.ts +94 -0
- package/src/skills/registry.ts +217 -0
- package/src/skills/scaffold.ts +65 -0
- package/src/skills/select.ts +37 -0
- package/src/skills/view.ts +44 -0
- package/src/suite/report.ts +135 -0
- package/src/suite/run.ts +303 -0
- package/src/summary.ts +188 -0
- package/src/tools.ts +130 -0
- package/src/tui/App.tsx +983 -0
- package/src/tui/clipboard.ts +36 -0
- package/src/tui/components.tsx +263 -0
- package/src/tui/dialogs.ts +65 -0
- package/src/tui/main.tsx +20 -0
- package/src/tui/text.ts +28 -0
- package/src/tui/theme.ts +104 -0
- package/src/tui/transcript.tsx +344 -0
- package/src/types.ts +136 -0
- package/src/update.ts +311 -0
- package/src/usage.ts +158 -0
package/src/heal/loop.ts
ADDED
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, realpathSync, statSync, writeFileSync } from "node:fs"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
|
|
4
|
+
import type { CodeEvent, Usage } from "../types.ts"
|
|
5
|
+
import { privateEnvNames } from "../childEnv.ts"
|
|
6
|
+
import { addUsage, costOf, meterSince, meterSnapshot, type Cost } from "../usage.ts"
|
|
7
|
+
import { openSession, type OpencodeSession } from "../code/runtime.ts"
|
|
8
|
+
import { runTestCommand, type TestRun } from "../code/testrun.ts"
|
|
9
|
+
import { changedFiles, ownPaths, protectPatterns, restoreRemoved, snapshot } from "../code/worktree.ts"
|
|
10
|
+
import type { AppConfig } from "../config.ts"
|
|
11
|
+
import { MCP_NAME, TOOL_TIMEOUT_MS, localToolServer } from "../mcp/server.ts"
|
|
12
|
+
import { TOOLS, environmentNotes, type ToolContext, type ToolEvent } from "../mcp/tools.ts"
|
|
13
|
+
import { runDirectory } from "../run.ts"
|
|
14
|
+
import {
|
|
15
|
+
healAgentPrompt,
|
|
16
|
+
HEAL_SCHEMA,
|
|
17
|
+
findCommandPrompt,
|
|
18
|
+
firstHealPrompt,
|
|
19
|
+
missingTestsPrompt,
|
|
20
|
+
healNextPrompt,
|
|
21
|
+
remainingPrompt,
|
|
22
|
+
stillFailingPrompt,
|
|
23
|
+
type HealReport,
|
|
24
|
+
type HealVerdict,
|
|
25
|
+
} from "./prompt.ts"
|
|
26
|
+
import { healMarkdown } from "./report.ts"
|
|
27
|
+
import { loadSkills } from "../skills/registry.ts"
|
|
28
|
+
import { domainNotes } from "../domain.ts"
|
|
29
|
+
|
|
30
|
+
export const HEAL_AGENT = "forgeqe-heal"
|
|
31
|
+
const HEAL_TOOLS = new Set(["browser_run", "run_details", "run_test", "page_memory"])
|
|
32
|
+
const FATAL_ERRORS = ["ProviderAuthError", "APIError", "ContextOverflowError", "ContentFilterError"]
|
|
33
|
+
|
|
34
|
+
export type HealStatus = "passed" | "failed" | "stopped" | "clean"
|
|
35
|
+
|
|
36
|
+
export type HealTest = {
|
|
37
|
+
name: string
|
|
38
|
+
file?: string
|
|
39
|
+
status: HealVerdict
|
|
40
|
+
command?: string
|
|
41
|
+
reason?: string
|
|
42
|
+
runDir?: string
|
|
43
|
+
/** ForgeQE ran the test's command itself and it passed. */
|
|
44
|
+
verified?: boolean
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export type CommandResult = { exitCode: number | null; output: string; ms: number }
|
|
48
|
+
|
|
49
|
+
export type HealResult = {
|
|
50
|
+
status: HealStatus
|
|
51
|
+
repo: string
|
|
52
|
+
command: string
|
|
53
|
+
attempts: number
|
|
54
|
+
maxAttempts: number
|
|
55
|
+
tests: HealTest[]
|
|
56
|
+
changed: string[]
|
|
57
|
+
notes: string
|
|
58
|
+
/** The test command before and after healing, as ForgeQE ran it. */
|
|
59
|
+
before?: CommandResult
|
|
60
|
+
after?: CommandResult
|
|
61
|
+
/** Browser runs the agent made to see what the pages do now. */
|
|
62
|
+
runs: string[]
|
|
63
|
+
usage: Usage
|
|
64
|
+
/** What `usage` cost at the profile's price; unset without one. */
|
|
65
|
+
cost?: Cost
|
|
66
|
+
/** The whole job, browser runs included: tokens and cost from the usage meter. */
|
|
67
|
+
total?: { usage: Usage; cost?: Cost }
|
|
68
|
+
/** Skills the agent loaded. */
|
|
69
|
+
skills?: string[]
|
|
70
|
+
model: string
|
|
71
|
+
dir: string
|
|
72
|
+
startedAt: string
|
|
73
|
+
ms: number
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export type HealOptions = {
|
|
77
|
+
config: AppConfig
|
|
78
|
+
repo: string
|
|
79
|
+
/** The repo's test command; default code.test_command, else the agent finds it. */
|
|
80
|
+
command?: string
|
|
81
|
+
outDir?: string
|
|
82
|
+
headed?: boolean
|
|
83
|
+
maxSteps?: number
|
|
84
|
+
url?: string
|
|
85
|
+
signal?: AbortSignal
|
|
86
|
+
/** Scribe's progress: tool calls, thoughts, test runs, tokens. */
|
|
87
|
+
onEvent?: (e: CodeEvent) => void
|
|
88
|
+
/** Browser runs the agent starts through ForgeQE's tools. */
|
|
89
|
+
onTool?: (e: ToolEvent) => void
|
|
90
|
+
/** Replaces openSession; tests use it to script the agent. */
|
|
91
|
+
open?: (o: Parameters<typeof openSession<HealReport>>[0]) => Promise<OpencodeSession<HealReport>>
|
|
92
|
+
/** Replaces executeRun for browser_run; tests use it. */
|
|
93
|
+
execute?: ToolContext["execute"]
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function realpathOr(dir: string): string {
|
|
97
|
+
try {
|
|
98
|
+
return realpathSync(dir)
|
|
99
|
+
} catch {
|
|
100
|
+
return path.resolve(dir)
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const commandResult = (run: TestRun): CommandResult => ({ exitCode: run.exitCode, output: run.output, ms: run.ms })
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Run the repo's tests; when they fail, Scribe, with ForgeQE's browser tools, heals the tests the app has
|
|
108
|
+
* outgrown and reports real app bugs. ForgeQE re-runs every test the agent calls healed, then the whole command,
|
|
109
|
+
* and only its own runs count.
|
|
110
|
+
*/
|
|
111
|
+
export async function runHeal(opts: HealOptions): Promise<HealResult> {
|
|
112
|
+
const given = path.resolve(opts.repo)
|
|
113
|
+
if (!existsSync(given) || !statSync(given).isDirectory()) throw new Error(`Repo not found: ${given}`)
|
|
114
|
+
const repo = realpathSync(given)
|
|
115
|
+
const { config } = opts
|
|
116
|
+
const emit = opts.onEvent || (() => {})
|
|
117
|
+
const t0 = Date.now()
|
|
118
|
+
const meterStart = meterSnapshot()
|
|
119
|
+
let spent: Usage = { input: 0, output: 0 }
|
|
120
|
+
const dir = runDirectory(path.join(config.export.outDir, "heal"), opts.outDir)
|
|
121
|
+
mkdirSync(dir, { recursive: true })
|
|
122
|
+
// Browser runs land inside this heal's folder, and run_details' default (the latest run) finds them there.
|
|
123
|
+
const runsConfig: AppConfig = { ...config, export: { ...config.export, outDir: dir } }
|
|
124
|
+
const maxAttempts = Math.max(1, config.code.maxAttempts)
|
|
125
|
+
const timeoutSeconds = config.code.testTimeoutSeconds
|
|
126
|
+
|
|
127
|
+
const result: HealResult = {
|
|
128
|
+
status: "failed",
|
|
129
|
+
repo,
|
|
130
|
+
command: opts.command || config.code.testCommand || "",
|
|
131
|
+
attempts: 0,
|
|
132
|
+
maxAttempts,
|
|
133
|
+
tests: [],
|
|
134
|
+
changed: [],
|
|
135
|
+
notes: "",
|
|
136
|
+
runs: [],
|
|
137
|
+
usage: { input: 0, output: 0 },
|
|
138
|
+
model: "",
|
|
139
|
+
dir,
|
|
140
|
+
startedAt: new Date(t0).toISOString(),
|
|
141
|
+
ms: 0,
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const onTool = (e: ToolEvent) => {
|
|
145
|
+
if (e.type === "run-done" && e.runDir && !result.runs.includes(e.runDir)) result.runs.push(e.runDir)
|
|
146
|
+
opts.onTool?.(e)
|
|
147
|
+
}
|
|
148
|
+
const context = (): ToolContext => ({
|
|
149
|
+
config: runsConfig,
|
|
150
|
+
cwd: repo,
|
|
151
|
+
headed: opts.headed,
|
|
152
|
+
maxSteps: opts.maxSteps,
|
|
153
|
+
url: opts.url,
|
|
154
|
+
signal: opts.signal,
|
|
155
|
+
emit: onTool,
|
|
156
|
+
execute: opts.execute,
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
const agent: { tools?: ReturnType<typeof localToolServer>; session?: OpencodeSession<HealReport> } = {}
|
|
160
|
+
// Healing edits tests, never ForgeQE's config, runs, or page memory: denied to the agent, and put back if changed anyway.
|
|
161
|
+
const own = ownPaths(repo, [config.export.outDir, config.memory.dir])
|
|
162
|
+
const outside = [config.export.outDir, config.memory.dir].map((d) => realpathOr(d)).filter((d) => path.relative(repo, d).startsWith(".."))
|
|
163
|
+
const before = snapshot(repo, own)
|
|
164
|
+
const openAgent = async () => {
|
|
165
|
+
const tools = localToolServer({ tools: TOOLS.filter((t) => HEAL_TOOLS.has(t.name)), context, instructions: () => environmentNotes(context()), timeoutMs: TOOL_TIMEOUT_MS })
|
|
166
|
+
agent.tools = tools
|
|
167
|
+
const readDirs = [...new Set([given, config.export.outDir, config.memory.dir].flatMap((d) => [path.resolve(d), realpathOr(d)]))]
|
|
168
|
+
const s = await (opts.open || openSession<HealReport>)({
|
|
169
|
+
repo,
|
|
170
|
+
profile: config.llm,
|
|
171
|
+
domain: domainNotes(config),
|
|
172
|
+
model: config.code.model,
|
|
173
|
+
bin: config.code.opencode,
|
|
174
|
+
readDirs,
|
|
175
|
+
hide: privateEnvNames(config),
|
|
176
|
+
protect: protectPatterns(repo, own, outside),
|
|
177
|
+
signal: opts.signal,
|
|
178
|
+
onEvent: emit,
|
|
179
|
+
agent: { name: HEAL_AGENT, description: "Heals tests the app has outgrown and reports real app bugs", prompt: `${healAgentPrompt()}\n\nThis session:\n${environmentNotes(context())}`, skills: "scribe" },
|
|
180
|
+
skills: loadSkills(config.skills, repo).skills,
|
|
181
|
+
schema: HEAL_SCHEMA as unknown as Record<string, unknown>,
|
|
182
|
+
mcp: { [MCP_NAME]: tools.entry },
|
|
183
|
+
hideTool: (tool) => tool.startsWith(`${MCP_NAME}_`),
|
|
184
|
+
title: "ForgeQE heal",
|
|
185
|
+
})
|
|
186
|
+
agent.session = s
|
|
187
|
+
result.model = s.model
|
|
188
|
+
return s
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const run = async (command: string) => runTestCommand({ command, cwd: repo, timeoutSeconds, signal: opts.signal, hide: privateEnvNames(config) })
|
|
192
|
+
const stopped = () => Boolean(opts.signal?.aborted)
|
|
193
|
+
const secs = (ms: number) => `${Math.round(ms / 1000)}s`
|
|
194
|
+
const failWhy = (r: TestRun) => (r.exitCode === null ? `timed out after ${timeoutSeconds}s` : `exit ${r.exitCode}`)
|
|
195
|
+
|
|
196
|
+
try {
|
|
197
|
+
if (!result.command) {
|
|
198
|
+
const session = await openAgent()
|
|
199
|
+
emit({ type: "code", kind: "info", text: `Scribe (${session.model}) is finding the test command in ${repo}` })
|
|
200
|
+
const reply = await session.prompt(findCommandPrompt())
|
|
201
|
+
if (stopped()) {
|
|
202
|
+
result.status = "stopped"
|
|
203
|
+
return result
|
|
204
|
+
}
|
|
205
|
+
result.command = reply.report?.suite_command?.trim() || ""
|
|
206
|
+
if (!result.command) {
|
|
207
|
+
result.notes = reply.error || "The agent did not find a test command. Pass one: forgeqe heal \"<command>\", or set code.test_command."
|
|
208
|
+
return result
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
emit({ type: "code", kind: "info", text: `ForgeQE runs the tests: ${result.command}` })
|
|
213
|
+
const baseline = await run(result.command)
|
|
214
|
+
result.before = commandResult(baseline)
|
|
215
|
+
if (stopped()) {
|
|
216
|
+
result.status = "stopped"
|
|
217
|
+
return result
|
|
218
|
+
}
|
|
219
|
+
if (baseline.ok) {
|
|
220
|
+
result.status = "clean"
|
|
221
|
+
result.after = result.before
|
|
222
|
+
emit({ type: "code", kind: "test", ok: true, attempt: 0, text: `All tests pass (${secs(baseline.ms)}); nothing to heal` })
|
|
223
|
+
return result
|
|
224
|
+
}
|
|
225
|
+
emit({ type: "code", kind: "test", ok: false, attempt: 0, text: `Tests failed, ${failWhy(baseline)} (${secs(baseline.ms)})` })
|
|
226
|
+
|
|
227
|
+
let session = agent.session || (await openAgent())
|
|
228
|
+
emit({ type: "code", kind: "info", text: `Scribe (${session.model}) is healing the failing tests in ${repo}` })
|
|
229
|
+
// Each attempt after a failed re-run starts a new session from the state of the job (healNextPrompt), so the
|
|
230
|
+
// model is not sent every earlier attempt's tool calls again.
|
|
231
|
+
const attemptLog: string[] = []
|
|
232
|
+
const freshSession = async (attempt: number, outcome: string, task: string): Promise<string> => {
|
|
233
|
+
attemptLog.push(`${attempt}: ${outcome}`)
|
|
234
|
+
spent = addUsage(spent, session.usage)
|
|
235
|
+
await session.close()
|
|
236
|
+
agent.tools?.server.abortAll()
|
|
237
|
+
agent.tools?.stop()
|
|
238
|
+
session = await openAgent()
|
|
239
|
+
emit({ type: "code", kind: "info", text: `Scribe starts attempt ${attempt + 1} in a fresh session, from a summary of the job so far` })
|
|
240
|
+
return healNextPrompt({ command: result.command, tests: result.tests, changed: changedFiles(repo, result.changed), attempts: attemptLog, task })
|
|
241
|
+
}
|
|
242
|
+
const byName = new Map<string, HealTest>()
|
|
243
|
+
let full: TestRun | undefined
|
|
244
|
+
let verifiedSinceFull = false
|
|
245
|
+
let prompt = firstHealPrompt(result.command, baseline)
|
|
246
|
+
|
|
247
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
248
|
+
result.attempts = attempt
|
|
249
|
+
const reply = await session.prompt(prompt)
|
|
250
|
+
if (stopped()) {
|
|
251
|
+
result.status = "stopped"
|
|
252
|
+
break
|
|
253
|
+
}
|
|
254
|
+
if (reply.error && FATAL_ERRORS.some((name) => reply.error!.startsWith(name))) {
|
|
255
|
+
result.notes = reply.error
|
|
256
|
+
emit({ type: "code", kind: "info", text: `Scribe stopped: ${reply.error}` })
|
|
257
|
+
break
|
|
258
|
+
}
|
|
259
|
+
const report = reply.report
|
|
260
|
+
if (report?.changed) result.changed = [...new Set([...result.changed, ...report.changed])]
|
|
261
|
+
if (report?.notes) result.notes = report.notes
|
|
262
|
+
if (!report?.tests?.length) {
|
|
263
|
+
emit({ type: "code", kind: "test", ok: false, attempt, text: "no failing tests reported yet" })
|
|
264
|
+
prompt = missingTestsPrompt()
|
|
265
|
+
continue
|
|
266
|
+
}
|
|
267
|
+
for (const t of report.tests) {
|
|
268
|
+
const prev = byName.get(t.name)
|
|
269
|
+
const next: HealTest = {
|
|
270
|
+
...prev,
|
|
271
|
+
name: t.name,
|
|
272
|
+
status: t.status,
|
|
273
|
+
...(t.file ? { file: t.file } : {}),
|
|
274
|
+
...(t.command ? { command: t.command } : {}),
|
|
275
|
+
...(t.reason ? { reason: t.reason } : {}),
|
|
276
|
+
...(t.run_dir ? { runDir: path.resolve(repo, t.run_dir) } : {}),
|
|
277
|
+
}
|
|
278
|
+
const unchanged = prev?.verified && prev.status === "healed" && t.status === "healed" && (!t.command || t.command === prev.command)
|
|
279
|
+
next.verified = unchanged ? true : undefined
|
|
280
|
+
byName.set(t.name, next)
|
|
281
|
+
}
|
|
282
|
+
result.tests = [...byName.values()]
|
|
283
|
+
|
|
284
|
+
const pending = result.tests.filter((t) => t.status === "healed" && !t.verified)
|
|
285
|
+
if (!pending.length) break
|
|
286
|
+
|
|
287
|
+
const failures: { name: string; command?: string; run?: TestRun }[] = []
|
|
288
|
+
for (const t of pending) {
|
|
289
|
+
if (!t.command) {
|
|
290
|
+
failures.push({ name: t.name })
|
|
291
|
+
emit({ type: "code", kind: "test", ok: false, attempt, text: `${t.name}: no command to run it on its own` })
|
|
292
|
+
continue
|
|
293
|
+
}
|
|
294
|
+
emit({ type: "code", kind: "info", text: `ForgeQE runs: ${t.command}` })
|
|
295
|
+
const r = await run(t.command)
|
|
296
|
+
if (stopped()) break
|
|
297
|
+
if (r.ok) {
|
|
298
|
+
t.verified = true
|
|
299
|
+
verifiedSinceFull = true
|
|
300
|
+
emit({ type: "code", kind: "test", ok: true, attempt, text: `${t.name} passed in ${secs(r.ms)}` })
|
|
301
|
+
} else {
|
|
302
|
+
failures.push({ name: t.name, command: t.command, run: r })
|
|
303
|
+
emit({ type: "code", kind: "test", ok: false, attempt, text: `${t.name} failed, ${failWhy(r)}` })
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
if (stopped()) {
|
|
307
|
+
result.status = "stopped"
|
|
308
|
+
break
|
|
309
|
+
}
|
|
310
|
+
if (failures.length) {
|
|
311
|
+
if (attempt < maxAttempts) prompt = await freshSession(attempt, `${failures.map((f) => f.name).join(", ")} still failing`, stillFailingPrompt(failures))
|
|
312
|
+
continue
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
emit({ type: "code", kind: "info", text: `ForgeQE runs the tests: ${result.command}` })
|
|
316
|
+
full = await run(result.command)
|
|
317
|
+
verifiedSinceFull = false
|
|
318
|
+
if (stopped()) {
|
|
319
|
+
result.status = "stopped"
|
|
320
|
+
break
|
|
321
|
+
}
|
|
322
|
+
emit({ type: "code", kind: "test", ok: full.ok, attempt, text: full.ok ? `All tests pass (${secs(full.ms)})` : `Tests still fail, ${failWhy(full)}` })
|
|
323
|
+
if (full.ok) break
|
|
324
|
+
if (attempt < maxAttempts) prompt = await freshSession(attempt, `the tests still fail, ${failWhy(full)}`, remainingPrompt(result.command, full))
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
if (result.status !== "stopped") {
|
|
328
|
+
if (verifiedSinceFull) {
|
|
329
|
+
emit({ type: "code", kind: "info", text: `ForgeQE runs the tests: ${result.command}` })
|
|
330
|
+
full = await run(result.command)
|
|
331
|
+
emit({ type: "code", kind: "test", ok: full.ok, attempt: result.attempts, text: full.ok ? `All tests pass (${secs(full.ms)})` : `Tests still fail, ${failWhy(full)}` })
|
|
332
|
+
}
|
|
333
|
+
if (stopped()) result.status = "stopped"
|
|
334
|
+
else {
|
|
335
|
+
result.after = full ? commandResult(full) : result.before
|
|
336
|
+
result.status = result.after?.exitCode === 0 ? "passed" : "failed"
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
return result
|
|
340
|
+
} finally {
|
|
341
|
+
if (agent.session) {
|
|
342
|
+
result.usage = addUsage(spent, agent.session.usage)
|
|
343
|
+
result.cost = costOf(result.usage, config.code.model ? undefined : config.llm.price)
|
|
344
|
+
if (agent.session.skillsUsed?.length) result.skills = [...agent.session.skillsUsed]
|
|
345
|
+
await agent.session.close()
|
|
346
|
+
}
|
|
347
|
+
agent.tools?.server.abortAll()
|
|
348
|
+
agent.tools?.stop()
|
|
349
|
+
const restored = restoreRemoved(before, "own")
|
|
350
|
+
if (restored.length) emit({ type: "code", kind: "info", text: `Put back ${restored.join(", ")}: the agent changed ForgeQE's files` })
|
|
351
|
+
result.ms = Date.now() - t0
|
|
352
|
+
const jobTotal = meterSince(meterStart)
|
|
353
|
+
result.total = { usage: jobTotal.total, cost: jobTotal.cost }
|
|
354
|
+
writeFileSync(path.join(dir, "heal.json"), `${JSON.stringify(result, null, 2)}\n`)
|
|
355
|
+
writeFileSync(path.join(dir, "heal.md"), healMarkdown(result))
|
|
356
|
+
}
|
|
357
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import type { ChangedFile } from "../code/prompt.ts"
|
|
2
|
+
import { prompt } from "../prompts.ts"
|
|
3
|
+
|
|
4
|
+
/** How to fix tests broken by changes in the app. The heal agent and ForgeQE's own agent both follow it. */
|
|
5
|
+
export const HEAL_BRIEF = `- Work on failing tests only; leave passing tests alone.
|
|
6
|
+
- For each failing test, read it and the page objects, fixtures, and helpers it uses, and work out why it fails:
|
|
7
|
+
- the app changed under the test (an element was renamed or moved, a locator no longer matches, a step was added or removed, text or timing changed): heal the test;
|
|
8
|
+
- the test's own code, config, or environment is broken (an import, a dependency, the base URL, test data): fix that;
|
|
9
|
+
- the app no longer does what the test checks: that is a bug in the app, not in the test.
|
|
10
|
+
- When a failure involves the page, find out what the page does now before changing anything: run the flow the test covers with browser_run, as a plain-language goal that names the start page, each step, and each check the test makes. Do not put locators in the goal.
|
|
11
|
+
- If the browser run passes, the flow still works and the test is out of date. Call run_details for that run and update the test with the steps and the highest-confidence locators it recorded. When the locator lives in a page object, fixture, or helper, fix it there once.
|
|
12
|
+
- If the browser run fails at the step where the test fails, the app is broken there. Do not change the test to make it pass; report it as an app bug with what the browser run found.
|
|
13
|
+
- Keep each test's intent. Never delete or skip a test, loosen or remove an assertion, or raise a timeout just to get a pass.
|
|
14
|
+
- Run each healed test with run_test until it passes, then the whole test command again.`
|
|
15
|
+
|
|
16
|
+
/** System prompt of Scribe behind `forgeqe heal`. */
|
|
17
|
+
export const healAgentPrompt = () => prompt("heal")
|
|
18
|
+
|
|
19
|
+
export const HEAL_SCHEMA = {
|
|
20
|
+
type: "object",
|
|
21
|
+
properties: {
|
|
22
|
+
tests: {
|
|
23
|
+
type: "array",
|
|
24
|
+
description: "Every test that failed, including ones you could not heal",
|
|
25
|
+
items: {
|
|
26
|
+
type: "object",
|
|
27
|
+
properties: {
|
|
28
|
+
name: { type: "string", description: "The test as the runner names it, e.g. tests/login.spec.ts › signs in" },
|
|
29
|
+
file: { type: "string", description: "Test file, relative to the repository root" },
|
|
30
|
+
status: {
|
|
31
|
+
type: "string",
|
|
32
|
+
enum: ["healed", "app_bug", "not_healed"],
|
|
33
|
+
description: "healed: fixed and passing when you run it. app_bug: the app is broken at the step the test checks. not_healed: you could not fix it",
|
|
34
|
+
},
|
|
35
|
+
command: { type: "string", description: "Command, from the repository root, that runs only this test non-interactively; required for healed tests" },
|
|
36
|
+
reason: { type: "string", description: "Why it failed and what you changed, or what is broken in the app" },
|
|
37
|
+
run_dir: { type: "string", description: "Folder of the browser run you used, if any" },
|
|
38
|
+
},
|
|
39
|
+
required: ["name", "status"],
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
changed: { type: "array", items: { type: "string" }, description: "Files created or modified" },
|
|
43
|
+
notes: { type: "string", description: "Anything the user should know, in one or two sentences" },
|
|
44
|
+
suite_command: { type: "string", description: "Only when asked for it: the command that runs the repository's tests" },
|
|
45
|
+
},
|
|
46
|
+
required: ["tests"],
|
|
47
|
+
} as const
|
|
48
|
+
|
|
49
|
+
export type HealVerdict = "healed" | "app_bug" | "not_healed"
|
|
50
|
+
|
|
51
|
+
export type HealReport = {
|
|
52
|
+
tests?: { name: string; file?: string; status: HealVerdict; command?: string; reason?: string; run_dir?: string }[]
|
|
53
|
+
changed?: string[]
|
|
54
|
+
notes?: string
|
|
55
|
+
suite_command?: string
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export type CommandRun = { exitCode: number | null; output: string }
|
|
59
|
+
|
|
60
|
+
function outcome(run: CommandRun): string {
|
|
61
|
+
return run.exitCode === null ? "timed out" : `exited with code ${run.exitCode}`
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function fenced(output: string): string[] {
|
|
65
|
+
return ["```", output.trimEnd() || "(no output)", "```"]
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function findCommandPrompt(): string {
|
|
69
|
+
return "Before fixing anything, find the command that runs this repository's browser or UI tests from the repository root, non-interactively (for example npx playwright test). Do not run it and do not change files yet. Answer with suite_command and an empty tests list."
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function firstHealPrompt(command: string, run: CommandRun): string {
|
|
73
|
+
return [
|
|
74
|
+
`ForgeQE ran this repository's tests with \`${command}\` and it ${outcome(run)}. The end of the output:`,
|
|
75
|
+
"",
|
|
76
|
+
...fenced(run.output),
|
|
77
|
+
"",
|
|
78
|
+
"Heal the failing tests as your instructions describe, then give the structured result for every test that failed.",
|
|
79
|
+
].join("\n")
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function stillFailingPrompt(failures: { name: string; command?: string; run?: CommandRun }[]): string {
|
|
83
|
+
const parts = failures.flatMap((f) =>
|
|
84
|
+
!f.command
|
|
85
|
+
? [`- ${f.name}: you gave no command that runs only this test.`]
|
|
86
|
+
: [`- ${f.name}: \`${f.command}\` ${outcome(f.run!)}.`, "", ...fenced(f.run!.output), ""],
|
|
87
|
+
)
|
|
88
|
+
return [
|
|
89
|
+
"ForgeQE ran the tests you reported as healed, and these do not pass:",
|
|
90
|
+
"",
|
|
91
|
+
...parts,
|
|
92
|
+
"Fix them and run them until they pass, or report them as app_bug or not_healed. Then give the structured result again for every test that failed.",
|
|
93
|
+
].join("\n")
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function remainingPrompt(command: string, run: CommandRun): string {
|
|
97
|
+
return [
|
|
98
|
+
`Your healed tests pass on their own, but \`${command}\` still ${outcome(run)}. The end of the output:`,
|
|
99
|
+
"",
|
|
100
|
+
...fenced(run.output),
|
|
101
|
+
"",
|
|
102
|
+
"Account for every test that still fails: heal it, or report it as app_bug or not_healed with the reason. Then give the structured result again for every test that failed.",
|
|
103
|
+
].join("\n")
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function missingTestsPrompt(): string {
|
|
107
|
+
return "Your result did not list the failing tests. Give the structured result with every test that failed, each as healed, app_bug, or not_healed, with a command that runs only that test."
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The first prompt of a fresh session for the next healing attempt: the state of the job instead of the earlier
|
|
112
|
+
* attempts' conversation, so the model is not sent every earlier tool call again.
|
|
113
|
+
*/
|
|
114
|
+
export function healNextPrompt(opts: {
|
|
115
|
+
command: string
|
|
116
|
+
tests: { name: string; status: string; file?: string; command?: string; reason?: string; verified?: boolean }[]
|
|
117
|
+
changed: ChangedFile[]
|
|
118
|
+
attempts: string[]
|
|
119
|
+
task: string
|
|
120
|
+
}): string {
|
|
121
|
+
const files = opts.changed.map((f) =>
|
|
122
|
+
f.content === undefined ? `- ${f.path} (read it if you need it)` : [`### ${f.path}`, "", "```", f.content.trimEnd(), "```"].join("\n"),
|
|
123
|
+
)
|
|
124
|
+
return [
|
|
125
|
+
"You are continuing a healing job in a new session. The earlier attempts' conversation is not here; this message carries what matters from it. Read another file only when the fix needs it.",
|
|
126
|
+
"",
|
|
127
|
+
`The test command: ${opts.command}`,
|
|
128
|
+
"",
|
|
129
|
+
"Your verdicts so far:",
|
|
130
|
+
...opts.tests.map((t) => `- ${t.name}: ${t.status}${t.verified ? " (ForgeQE confirmed it passes)" : ""}${t.file ? `, ${t.file}` : ""}${t.command ? `, run with: ${t.command}` : ""}${t.reason ? `. ${t.reason.replace(/\s+/g, " ").slice(0, 300)}` : ""}`),
|
|
131
|
+
"",
|
|
132
|
+
"Attempts, in order:",
|
|
133
|
+
...opts.attempts.map((a) => `- ${a}`),
|
|
134
|
+
"",
|
|
135
|
+
"Files this job changed, as they are now:",
|
|
136
|
+
"",
|
|
137
|
+
...(files.length ? files : ["(none yet)"]),
|
|
138
|
+
"",
|
|
139
|
+
"## Now",
|
|
140
|
+
"",
|
|
141
|
+
opts.task,
|
|
142
|
+
].join("\n")
|
|
143
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import path from "node:path"
|
|
2
|
+
|
|
3
|
+
import { cardText, type Card, type CardStatus } from "../card.ts"
|
|
4
|
+
import { formatDuration, formatTokens } from "../tui/theme.ts"
|
|
5
|
+
import { usageLine } from "../usage.ts"
|
|
6
|
+
import type { HealResult, HealStatus, HealTest } from "./loop.ts"
|
|
7
|
+
|
|
8
|
+
const TITLE: Record<HealStatus, string> = {
|
|
9
|
+
clean: "Nothing to heal",
|
|
10
|
+
passed: "Tests healed",
|
|
11
|
+
failed: "Tests still failing",
|
|
12
|
+
stopped: "Healing stopped",
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const STATUS: Record<HealStatus, CardStatus> = { clean: "ok", passed: "ok", failed: "fail", stopped: "stopped" }
|
|
16
|
+
|
|
17
|
+
const VERDICT: Record<HealTest["status"], string> = { healed: "healed", app_bug: "app bug", not_healed: "not healed" }
|
|
18
|
+
|
|
19
|
+
function testMark(t: HealTest): string {
|
|
20
|
+
if (t.status === "healed") return t.verified ? "✓" : "?"
|
|
21
|
+
return t.status === "app_bug" ? "!" : "✗"
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const OUTPUT_CHARS = 4000
|
|
25
|
+
|
|
26
|
+
function tail(s: string): string {
|
|
27
|
+
return s.length <= OUTPUT_CHARS ? s : `…${s.slice(-OUTPUT_CHARS)}`
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function exit(r: { exitCode: number | null; ms: number }): string {
|
|
31
|
+
return `${r.exitCode === null ? "timed out" : `exit ${r.exitCode}`} after ${formatDuration(r.ms)}`
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** heal.md: the command before and after, each failing test's verdict, the browser runs, and the files changed. */
|
|
35
|
+
export function healMarkdown(r: HealResult): string {
|
|
36
|
+
const cell = (s: string) => s.replace(/\|/g, "\\|").replace(/\s+/g, " ").trim()
|
|
37
|
+
const lines = [
|
|
38
|
+
`# ForgeQE heal: ${TITLE[r.status].toLowerCase()}`,
|
|
39
|
+
"",
|
|
40
|
+
`- Repo: ${r.repo}`,
|
|
41
|
+
`- Command: ${r.command ? `\`${r.command}\`` : "(none found)"}`,
|
|
42
|
+
`- Started: ${r.startedAt}, took ${formatDuration(r.ms)}`,
|
|
43
|
+
...(r.model ? [`- Agent: ${r.model}, ${r.attempts}/${r.maxAttempts} attempts, ${formatTokens(r.usage.input + r.usage.output)} tokens`] : []),
|
|
44
|
+
...(r.total && (r.total.usage.input || r.total.usage.output) ? [`- Usage: ${usageLine(r.total.usage, r.total.cost)}`] : []),
|
|
45
|
+
...(r.notes ? ["", r.notes] : []),
|
|
46
|
+
]
|
|
47
|
+
if (r.tests.length) {
|
|
48
|
+
lines.push("", "## Failing tests", "", "| | Test | Verdict | Why | Browser run |", "|---|---|---|---|---|")
|
|
49
|
+
for (const t of r.tests) {
|
|
50
|
+
const verdict = t.status === "healed" && !t.verified ? "healed (not confirmed by ForgeQE)" : VERDICT[t.status]
|
|
51
|
+
lines.push(`| ${testMark(t)} | ${cell(t.name)} | ${verdict} | ${cell(t.reason || "")} | ${t.runDir ? cell(path.relative(r.dir, t.runDir) || t.runDir) : ""} |`)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (r.changed.length) lines.push("", "## Files changed", "", ...r.changed.map((f) => `- ${f}`))
|
|
55
|
+
if (r.runs.length) lines.push("", "## Browser runs", "", ...r.runs.map((d) => `- ${d} (report.html)`))
|
|
56
|
+
for (const [title, run] of [
|
|
57
|
+
["Before", r.before],
|
|
58
|
+
["After", r.status === "clean" ? undefined : r.after],
|
|
59
|
+
] as const) {
|
|
60
|
+
if (run) lines.push("", `## ${title}: ${exit(run)}`, "", "```", tail(run.output).trimEnd() || "(no output)", "```")
|
|
61
|
+
}
|
|
62
|
+
return `${lines.join("\n")}\n`
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The end-of-heal summary, as a card for the shell. */
|
|
66
|
+
export function healCard(r: HealResult, cwd = process.cwd()): Card {
|
|
67
|
+
const stats = [
|
|
68
|
+
r.model ? `${r.attempts}/${r.maxAttempts} ${r.maxAttempts === 1 ? "attempt" : "attempts"}` : "",
|
|
69
|
+
formatDuration(r.ms),
|
|
70
|
+
r.model ? `${formatTokens(r.usage.input + r.usage.output)} tokens` : "",
|
|
71
|
+
r.model,
|
|
72
|
+
].filter(Boolean)
|
|
73
|
+
const lines: string[] = []
|
|
74
|
+
if (r.status === "clean") lines.push(`${r.command} passes; nothing needed healing.`)
|
|
75
|
+
for (const t of r.tests) {
|
|
76
|
+
const verdict = t.status === "healed" && !t.verified ? "healed, not confirmed" : VERDICT[t.status]
|
|
77
|
+
lines.push(`${testMark(t)} ${t.name} ${verdict}`)
|
|
78
|
+
if (t.reason) lines.push(` ${t.reason.split("\n")[0].slice(0, 200)}`)
|
|
79
|
+
}
|
|
80
|
+
const bugs = r.tests.filter((t) => t.status === "app_bug").length
|
|
81
|
+
if (r.status === "failed" && bugs) lines.push("", `${bugs} failure${bugs === 1 ? " is" : "s are"} in the app, not the tests; the tests were left as they are.`)
|
|
82
|
+
if (r.notes && (r.status === "failed" || r.status === "stopped") && !r.tests.length) lines.push(r.notes)
|
|
83
|
+
const show = (f: string) => path.relative(cwd, f) || f
|
|
84
|
+
lines.push("")
|
|
85
|
+
if (r.command) lines.push(`Command ${r.command}${r.after && r.status !== "clean" ? ` (${r.after.exitCode === 0 ? "passes" : exit(r.after)})` : ""}`)
|
|
86
|
+
if (r.changed.length) lines.push(`Changed ${r.changed.join(", ")}`)
|
|
87
|
+
if (r.total && (r.total.usage.input || r.total.usage.output)) lines.push(`Usage ${usageLine(r.total.usage, r.total.cost)}`)
|
|
88
|
+
lines.push(`Report ${show(path.join(r.dir, "heal.md"))}`)
|
|
89
|
+
return { status: STATUS[r.status], title: TITLE[r.status], stats: stats.join(" · "), body: lines }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The end-of-heal summary for the terminal. */
|
|
93
|
+
export function formatHeal(r: HealResult, cwd = process.cwd()): string {
|
|
94
|
+
return cardText(healCard(r, cwd))
|
|
95
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import "./legacyEnv.ts"
|
|
3
|
+
import { main } from "./cli/index.ts"
|
|
4
|
+
|
|
5
|
+
if (import.meta.main) {
|
|
6
|
+
main(process.argv.slice(2))
|
|
7
|
+
.then((code) => process.exit(code))
|
|
8
|
+
.catch((err: unknown) => {
|
|
9
|
+
console.error(err instanceof Error ? err.message : String(err))
|
|
10
|
+
process.exit(1)
|
|
11
|
+
})
|
|
12
|
+
}
|