@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,287 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks"
2
+ import { randomUUID } from "node:crypto"
3
+ import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"
4
+ import os from "node:os"
5
+ import path from "node:path"
6
+
7
+ import { forgeqeHome } from "../tools.ts"
8
+ import type { Usage } from "../types.ts"
9
+ import { costOf, recordUsage, type Price, type Role } from "../usage.ts"
10
+
11
+ /**
12
+ * The usage ledger: one record per model call, by every agent, kept in ~/.forgeqe/usage/YYYY-MM.jsonl so the
13
+ * Tokenomics report can say where tokens went: by agent, job, task, purpose, model, user and machine, over time.
14
+ * A record holds counts, times and names only, never prompt or response text. FORGEQE_USAGE=off keeps nothing on
15
+ * disk (the meter in usage.ts still counts); FORGEQE_USAGE_DIR moves the files.
16
+ */
17
+
18
+ export type Trigger = "tui" | "cli" | "mcp" | "ci" | "monitor" | "schedule"
19
+
20
+ /** What a call was for, from the tools it asked for: no tools is the model thinking or answering. */
21
+ export type Purpose = "reasoning" | "planning" | "search" | "file read" | "edit" | "shell" | "browser" | "integration" | "delegation" | "unattributed"
22
+
23
+ /** What ForgeQE was doing when the call was made. Set with withUsageScope; inner scopes add to outer ones. */
24
+ export type UsageScope = {
25
+ /** The kind of work: "browser test", "heal", "write tests", "pr review", "eval", … */
26
+ job?: string
27
+ /** The piece of it: a session title, a case, a heal attempt. */
28
+ task?: string
29
+ runId?: string
30
+ caseId?: string
31
+ attempt?: number
32
+ }
33
+
34
+ export type CallRecord = {
35
+ v: 1
36
+ id: string
37
+ /** When the call finished (ISO). */
38
+ ts: string
39
+ ms?: number
40
+ agent: Role
41
+ job: string
42
+ task?: string
43
+ purpose: Purpose
44
+ /** The tools this call asked for, by name, in order, without repeats. */
45
+ tools?: string[]
46
+ runId?: string
47
+ caseId?: string
48
+ attempt?: number
49
+ step?: number
50
+ /** The agent runtime's session, or the engine's run. */
51
+ session?: string
52
+ provider?: string
53
+ model: string
54
+ /** Every prompt token, cached ones included (as Usage). */
55
+ input: number
56
+ cached?: number
57
+ cacheWrite?: number
58
+ /** Completion tokens, reasoning included. */
59
+ output: number
60
+ reasoning?: number
61
+ amount?: number
62
+ currency?: string
63
+ /** No price was known for the model: amount is missing. */
64
+ unpriced?: boolean
65
+ /** Part of the price is derived, or cached input was priced at the full rate. */
66
+ estimate?: boolean
67
+ outcome: "ok" | "error" | "cancelled"
68
+ user: string
69
+ machine: string
70
+ trigger: Trigger
71
+ /** One ForgeQE process: tells apart calls from two shells running at once. */
72
+ proc: string
73
+ }
74
+
75
+ const scopes = new AsyncLocalStorage<UsageScope>()
76
+
77
+ /** Run `fn` with `scope` added to the current one, for every model call made inside it (awaited work included). */
78
+ export function withUsageScope<T>(scope: UsageScope, fn: () => T): T {
79
+ return scopes.run({ ...scopes.getStore(), ...scope }, fn)
80
+ }
81
+
82
+ export function currentUsageScope(): UsageScope {
83
+ return { ...scopes.getStore() }
84
+ }
85
+
86
+ let trigger: Trigger | null = null
87
+
88
+ /** How this process was started; CI is told from the environment. Child processes (the MCP server) inherit it. */
89
+ export function setUsageTrigger(t: Trigger): void {
90
+ trigger = t
91
+ process.env.FORGEQE_TRIGGER ||= t
92
+ }
93
+
94
+ function triggerNow(): Trigger {
95
+ const env = process.env.FORGEQE_TRIGGER as Trigger | undefined
96
+ if (env) return env
97
+ if (process.env.CI || process.env.TF_BUILD || process.env.GITHUB_ACTIONS || process.env.GITLAB_CI) return "ci"
98
+ return trigger || "cli"
99
+ }
100
+
101
+ function who(): { user: string; machine: string } {
102
+ let user = process.env.USER || process.env.USERNAME || ""
103
+ try {
104
+ user = os.userInfo().username || user
105
+ } catch {}
106
+ return { user: user || "unknown", machine: os.hostname() || "unknown" }
107
+ }
108
+
109
+ const PROC = randomUUID().slice(0, 8)
110
+
111
+ const PURPOSES: [RegExp, Purpose][] = [
112
+ [/^(edit|write|patch|multiedit|apply_patch)$/i, "edit"],
113
+ [/(^|_)(browser_run|run_test|run_test_cases|page_memory)$/i, "browser"],
114
+ [/^bash$/i, "shell"],
115
+ [/^(grep|glob|codesearch|websearch|webfetch)$|(^|_)(find_test_cases|query_api|read_confluence|search|find|rag)/i, "search"],
116
+ [/^(read|list|ls)$/i, "file read"],
117
+ [/^task$/i, "delegation"],
118
+ [/^(todowrite|todoread)$/i, "planning"],
119
+ ]
120
+
121
+ /** What a call was for, from its tools: the first matching kind in PURPOSES' order wins; other tools are integrations. */
122
+ export function purposeOf(tools: string[] | undefined): Purpose {
123
+ const named = (tools || []).filter((t) => t && t !== "StructuredOutput")
124
+ if (!named.length) return "reasoning"
125
+ for (const [re, purpose] of PURPOSES) if (named.some((t) => re.test(t))) return purpose
126
+ return named.some((t) => t === "skill") ? "file read" : "integration"
127
+ }
128
+
129
+ /** What a call made without a scope was most likely part of, by the agent that made it. */
130
+ const DEFAULT_JOB: Record<Role, string> = {
131
+ "ForgeQE's agent": "chat",
132
+ "browser agent": "browser test",
133
+ Scribe: "write tests",
134
+ reviewer: "review",
135
+ judge: "eval",
136
+ refiner: "refine",
137
+ "root-cause analyst": "defect rca",
138
+ }
139
+
140
+ export function usageDir(): string {
141
+ return process.env.FORGEQE_USAGE_DIR || path.join(forgeqeHome(), "usage")
142
+ }
143
+
144
+ const keeping = () => !/^(0|off|false|no)$/i.test(process.env.FORGEQE_USAGE || "")
145
+
146
+ const monthFile = (d: Date) => path.join(usageDir(), `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}.jsonl`)
147
+
148
+ /** This process's calls, oldest first (at most MAX_KEPT). */
149
+ const calls: CallRecord[] = []
150
+ const MAX_KEPT = 50_000
151
+ const listeners = new Set<(c: CallRecord) => void>()
152
+
153
+ /** Called with every call recorded in this process; returns the unsubscribe. */
154
+ export function onCall(listener: (c: CallRecord) => void): () => void {
155
+ listeners.add(listener)
156
+ return () => listeners.delete(listener)
157
+ }
158
+
159
+ export type CallInput = {
160
+ agent: Role
161
+ usage: Usage
162
+ price: Price | undefined
163
+ model: string
164
+ provider?: string
165
+ ms?: number
166
+ tools?: string[]
167
+ purpose?: Purpose
168
+ reasoning?: number
169
+ session?: string
170
+ step?: number
171
+ /** The task when the scope names none (the agent runtime's session title). */
172
+ task?: string
173
+ outcome?: CallRecord["outcome"]
174
+ /** A cost the caller already knows (the agent runtime's own), used when `price` is unknown. */
175
+ knownCost?: { amount: number; currency: string }
176
+ }
177
+
178
+ /**
179
+ * Record one model call: counted in the meter by its agent, kept in this process's list, and appended to the
180
+ * month's ledger file. Never throws: a ledger that cannot be written loses the record, never the run.
181
+ */
182
+ export function recordCall(c: CallInput): CallRecord | null {
183
+ if (!c.usage.input && !c.usage.output) return null
184
+ recordUsage(c.agent, c.usage, c.price)
185
+ const scope = scopes.getStore() || {}
186
+ const cost = costOf(c.usage, c.price)
187
+ const fallback = !cost && c.knownCost && c.knownCost.amount > 0 ? c.knownCost : undefined
188
+ const now = new Date()
189
+ const tools = c.tools?.length ? [...new Set(c.tools)] : undefined
190
+ const rec: CallRecord = {
191
+ v: 1,
192
+ id: randomUUID(),
193
+ ts: now.toISOString(),
194
+ ...(c.ms !== undefined ? { ms: Math.max(0, Math.round(c.ms)) } : {}),
195
+ agent: c.agent,
196
+ job: scope.job || DEFAULT_JOB[c.agent] || "other",
197
+ ...(scope.task || c.task ? { task: scope.task || c.task } : {}),
198
+ purpose: c.purpose || purposeOf(tools),
199
+ ...(tools ? { tools } : {}),
200
+ ...(scope.runId ? { runId: scope.runId } : {}),
201
+ ...(scope.caseId ? { caseId: scope.caseId } : {}),
202
+ ...(scope.attempt ? { attempt: scope.attempt } : {}),
203
+ ...(c.step !== undefined ? { step: c.step } : {}),
204
+ ...(c.session ? { session: c.session } : {}),
205
+ ...(c.provider ? { provider: c.provider } : {}),
206
+ model: c.model || "unknown",
207
+ input: c.usage.input,
208
+ ...(c.usage.cached ? { cached: c.usage.cached } : {}),
209
+ ...(c.usage.cacheWrite ? { cacheWrite: c.usage.cacheWrite } : {}),
210
+ output: c.usage.output,
211
+ ...(c.reasoning ? { reasoning: c.reasoning } : {}),
212
+ ...(cost ? { amount: cost.amount, currency: cost.currency } : fallback ? { amount: fallback.amount, currency: fallback.currency, estimate: true } : { unpriced: true }),
213
+ ...(cost && (cost.estimate || cost.cachedAtFullRate) ? { estimate: true } : {}),
214
+ outcome: c.outcome || "ok",
215
+ ...who(),
216
+ trigger: triggerNow(),
217
+ proc: PROC,
218
+ }
219
+ calls.push(rec)
220
+ if (calls.length > MAX_KEPT) calls.splice(0, calls.length - MAX_KEPT)
221
+ if (keeping()) {
222
+ try {
223
+ mkdirSync(usageDir(), { recursive: true })
224
+ appendFileSync(monthFile(now), JSON.stringify(rec) + "\n")
225
+ } catch {}
226
+ }
227
+ for (const l of listeners) {
228
+ try {
229
+ l(rec)
230
+ } catch {}
231
+ }
232
+ return rec
233
+ }
234
+
235
+ /** This process's calls, filtered. */
236
+ export function sessionCalls(filter?: (c: CallRecord) => boolean): CallRecord[] {
237
+ return filter ? calls.filter(filter) : [...calls]
238
+ }
239
+
240
+ /** The calls of one run (a browser run's folder name), in this process. */
241
+ export function runCalls(runId: string): CallRecord[] {
242
+ return calls.filter((c) => c.runId === runId)
243
+ }
244
+
245
+ /** usage.json in a run's folder: that run's calls, for its report. Returns the file, or null when it made none. */
246
+ export function writeRunUsage(dir: string): string | null {
247
+ const mine = runCalls(path.basename(dir))
248
+ if (!mine.length) return null
249
+ const file = path.join(dir, "usage.json")
250
+ writeFileSync(file, JSON.stringify({ v: 1, calls: mine }, null, 2))
251
+ return file
252
+ }
253
+
254
+ /** Every call in the ledger files from `since` (inclusive) to `until` (exclusive), oldest first. Bad lines are skipped. */
255
+ export function readLedger(opts: { since?: Date; until?: Date; dir?: string } = {}): CallRecord[] {
256
+ const dir = opts.dir || usageDir()
257
+ if (!existsSync(dir)) return []
258
+ const since = opts.since?.getTime() ?? -Infinity
259
+ const until = opts.until?.getTime() ?? Infinity
260
+ const first = opts.since ? `${opts.since.getFullYear()}-${String(opts.since.getMonth() + 1).padStart(2, "0")}` : ""
261
+ const out: CallRecord[] = []
262
+ for (const f of readdirSync(dir).filter((f) => /^\d{4}-\d{2}\.jsonl$/.test(f)).sort()) {
263
+ if (first && f.slice(0, 7) < first) continue
264
+ let text = ""
265
+ try {
266
+ text = readFileSync(path.join(dir, f), "utf8")
267
+ } catch {
268
+ continue
269
+ }
270
+ for (const line of text.split("\n")) {
271
+ if (!line.trim()) continue
272
+ try {
273
+ const c = JSON.parse(line) as CallRecord
274
+ const t = Date.parse(c.ts)
275
+ if (c.v === 1 && t >= since && t < until) out.push(c)
276
+ } catch {}
277
+ }
278
+ }
279
+ return out.sort((a, b) => a.ts.localeCompare(b.ts))
280
+ }
281
+
282
+ /** Tests: forget this process's calls and listeners. */
283
+ export function resetLedger(): void {
284
+ calls.length = 0
285
+ listeners.clear()
286
+ trigger = null
287
+ }
package/src/tui/App.tsx CHANGED
@@ -17,7 +17,10 @@ import { HELP, parseCommand } from "../commands.ts"
17
17
  import { loadConfig, selectProfile, type AppConfig, type BrowserProvider } from "../config.ts"
18
18
  import { TOOLS, type AskQuestion, type ToolEvent } from "../mcp/tools.ts"
19
19
  import type { executeRun } from "../run.ts"
20
- import { bundleLogs } from "../sessionLog.ts"
20
+ import { bundleLogs, logEvent } from "../sessionLog.ts"
21
+ import { openRegistry, registryMode, type Plan, type Registry } from "../registry/index.ts"
22
+ import { resolveRequest, resolveTarget } from "../registry/resolve.ts"
23
+ import { runChecks, tier0Reply, type RunSummary } from "../registry/tier0.ts"
21
24
  import { findSkill, loadSkills } from "../skills/registry.ts"
22
25
  import { skillDetail, skillsOverview } from "../skills/view.ts"
23
26
  import { counts } from "../suite/report.ts"
@@ -45,6 +48,8 @@ import { CONTEXT_LIMIT } from "../code/runtime.ts"
45
48
  import { readKnowledge } from "../knowledge.ts"
46
49
  import { externalMcp } from "../mcp/external.ts"
47
50
  import { meterSince } from "../usage.ts"
51
+ import { setUsageTrigger } from "../tokenomics/ledger.ts"
52
+ import { watchBudgets } from "../tokenomics/budget.ts"
48
53
  import {
49
54
  ACTIVITY_FILTERS,
50
55
  ActivityPanel,
@@ -1162,6 +1167,81 @@ export function App({ options }: { options: TuiOptions }) {
1162
1167
  }
1163
1168
  }
1164
1169
 
1170
+ /**
1171
+ * Tier 0 (docs/COST_ARCHITECTURE.md): a passed run, run again as recorded through the same browser_run the agent
1172
+ * calls (an existing test first, then page memory), with no agent; the reply is written from the result.
1173
+ */
1174
+ const runKnown = async (plan: Plan, cfg: AppConfig, reg: Registry, request: string) => {
1175
+ const controller = begin("Running it again as recorded…", request)
1176
+ testsRef.current = { on: sessionSetup(cfg, options.repo).writeTests, why: "the session was started with --no-code" }
1177
+ const t0 = Date.now()
1178
+ let finalStatus = "fail"
1179
+ let out: RunSummary = {}
1180
+ try {
1181
+ const task = Boolean(plan.lastRunDir && existsSync(path.join(plan.lastRunDir, "task.json")))
1182
+ const args = { goal: plan.goal, purpose: task ? "task" : "test", ...(plan.startUrl ? { start_url: plan.startUrl } : {}) }
1183
+ const raw = (await TOOLS[0].run(args, { ...toolContext(false, controller.signal), config: cfg, cwd: process.cwd() })) as RunSummary & { passed?: RunSummary }
1184
+ out = raw.passed || raw
1185
+ out = { ...out, ...runChecks(out.run_dir) }
1186
+ finalStatus = out.status === "passed" ? "done" : out.status === "stopped" ? "stopped" : "fail"
1187
+ const reply = tier0Reply(plan, out)
1188
+ pushLine({ kind: "reply", text: reply.text, outcome: reply.outcome })
1189
+ } catch (err) {
1190
+ finalStatus = controller.signal.aborted ? "stopped" : "fail"
1191
+ if (!controller.signal.aborted) push("err", `Running it again failed: ${(err as Error).message}`)
1192
+ } finally {
1193
+ try {
1194
+ reg.log({ request, mode: "on", decision: "tier0", planId: plan.id, tier: 0, outcome: out.status || finalStatus, tokensIn: out.tokens || 0, ms: Date.now() - t0, ...(out.run_dir ? { runDir: out.run_dir } : {}) })
1195
+ } catch {}
1196
+ reg.close()
1197
+ pushProduced()
1198
+ finish(finalStatus)
1199
+ }
1200
+ }
1201
+
1202
+ /**
1203
+ * The registry's decision on a request (docs/COST_ARCHITECTURE.md). off: nothing, today's path. shadow: recorded
1204
+ * only, today's path. on: a Tier 0 match runs here and true is returned; anything else goes to the agent as before.
1205
+ * Any registry error also means today's path.
1206
+ */
1207
+ const routeKnown = (text: string, cfg: AppConfig): boolean => {
1208
+ const mode = registryMode(cfg)
1209
+ if (mode === "off") return false
1210
+ const reg = openRegistry(cfg)
1211
+ if (!reg) return false
1212
+ try {
1213
+ const d = resolveRequest(reg, text)
1214
+ const decision = d.tier === 0 ? "tier0" : d.why
1215
+ logEvent("registry.decision", { mode, decision, plan: d.plan?.id })
1216
+ if (mode === "on" && d.tier === 0) {
1217
+ void runKnown(d.plan, cfg, reg, text)
1218
+ return true
1219
+ }
1220
+ reg.log({ request: text, mode: mode === "on" ? "on" : "shadow", decision, ...(d.plan ? { planId: d.plan.id } : {}), ...(d.tier === 0 ? { tier: 0 } : {}) })
1221
+ } catch {}
1222
+ reg.close()
1223
+ return false
1224
+ }
1225
+
1226
+ /** /rerun: an explicit request to run a passed run again as recorded, whatever registry.mode says. */
1227
+ const rerunCommand = (target: string) => {
1228
+ const cfg = configRef.current
1229
+ if (!cfg) return push("err", "Config failed to load.")
1230
+ if (abortRef.current) return push("err", "ForgeQE is busy. Press esc to stop it.")
1231
+ const reg = openRegistry(cfg)
1232
+ if (!reg) return push("err", "The run registry could not be opened, so /rerun cannot look the run up. Ask ForgeQE to run it again instead.")
1233
+ let d: ReturnType<typeof resolveTarget>
1234
+ try {
1235
+ d = resolveTarget(reg, target, true)
1236
+ } catch (err) {
1237
+ reg.close()
1238
+ return push("err", `The run registry could not be read: ${(err as Error).message}. Ask ForgeQE to run it again instead.`)
1239
+ }
1240
+ if (d.tier === 0) return void runKnown(d.plan, cfg, reg, `/rerun ${target}`)
1241
+ reg.close()
1242
+ push("err", d.plan ? `Not run again as recorded: ${d.why} (${d.plan.goal}). Ask ForgeQE to look into it, or to run it again.` : `Nothing to run again: ${d.why} for "${target}". /rerun takes last, a run folder, or a goal word for word.`)
1243
+ }
1244
+
1165
1245
  const askAgent = async (text: string, cfg: AppConfig) => {
1166
1246
  const controller = begin("Thinking…", text)
1167
1247
  const meterStart = meterSnapshot()
@@ -1232,7 +1312,7 @@ export function App({ options }: { options: TuiOptions }) {
1232
1312
  }
1233
1313
  const why = options.brain ? null : brainUnavailable(cfg)
1234
1314
  if (why) void runDirect(text, cfg, why)
1235
- else void askAgent(text, cfg)
1315
+ else if (!routeKnown(text, cfg)) void askAgent(text, cfg)
1236
1316
  }
1237
1317
 
1238
1318
  /** Reads forgeqe.yaml and llms.json again (after /init wrote them), keeping the session going. */
@@ -1255,6 +1335,9 @@ export function App({ options }: { options: TuiOptions }) {
1255
1335
  let cfg = loadConfig({ configPath: options.configPath, profile: options.profile })
1256
1336
  if (options.profile) cfg = selectProfile(cfg, options.profile)
1257
1337
  configRef.current = cfg
1338
+ // Budgets only warn: each threshold once, in the conversation, while work goes on.
1339
+ setUsageTrigger("tui")
1340
+ if (cfg.budget) watchBudgets(cfg.budget, (w) => push("sys", w.text, "chat"))
1258
1341
  // A price in llms.json that differs from the model's list price (probably a typo or an old price), said with the
1259
1342
  // first message so the home screen stays as it is.
1260
1343
  const reviewer = cfg.code.reviewProfile ? cfg.profiles[cfg.code.reviewProfile] : undefined
@@ -1410,6 +1493,10 @@ export function App({ options }: { options: TuiOptions }) {
1410
1493
  void runAuth(cmd.action, cmd.system).catch((err) => push("err", (err as Error).message))
1411
1494
  return
1412
1495
  }
1496
+ if (cmd.type === "rerun") {
1497
+ rerunCommand(cmd.target)
1498
+ return
1499
+ }
1413
1500
  if (cmd.type === "unknown") {
1414
1501
  push("err", "Usage: /headed on|off, /details on|off, /profile <name>, /auth [system], /auth logout <system>. /help lists the commands.")
1415
1502
  return
@@ -101,15 +101,7 @@ export function StatusBar(props: {
101
101
  const done = props.status === "done"
102
102
  const failed = props.status === "fail"
103
103
  const color = props.running ? theme.warn : done ? theme.ok : failed ? theme.err : theme.muted
104
- const state = props.running
105
- ? props.who
106
- : done
107
- ? "Last request done"
108
- : failed
109
- ? "Last request failed"
110
- : props.status === "stopped"
111
- ? "Last request stopped"
112
- : "Ready for your next request"
104
+ const state = props.running ? props.who : done ? "Last request done" : failed ? "Last request failed" : props.status === "stopped" ? "Last request stopped" : "Ready for your next request"
113
105
  const bar = props.running && props.progress && props.progress.total > 1 ? props.progress : null
114
106
  const cells = 12
115
107
  const barText = bar ? ` ${bar.done}/${bar.total} ` : ""
@@ -183,7 +175,13 @@ export function planLines(s: Inspector): PlanLine[] {
183
175
  if (s.progress.items.length && s.plan.length) lines.push({ mark: "─", color: theme.border, text: s.progress.title, textColor: theme.faint })
184
176
  for (const i of s.progress.items) {
185
177
  const [mark, color] = PROGRESS_MARK[i.status]
186
- lines.push({ mark, color, text: i.detail ? `${i.label} — ${i.detail}` : i.label, textColor: i.status === "fail" ? theme.err : i.status === "running" ? theme.text : theme.muted, ...(i.status === "running" ? { live: true } : {}) })
178
+ lines.push({
179
+ mark,
180
+ color,
181
+ text: i.detail ? `${i.label} — ${i.detail}` : i.label,
182
+ textColor: i.status === "fail" ? theme.err : i.status === "running" ? theme.text : theme.muted,
183
+ ...(i.status === "running" ? { live: true } : {}),
184
+ })
187
185
  }
188
186
  return lines
189
187
  }
@@ -352,17 +350,13 @@ export function nowNext(s: Inspector): { now?: string; next?: string; done: numb
352
350
  const at = s.plan.findIndex((p) => p.status === "in_progress")
353
351
  const step = [...s.progress.items].reverse().find((i) => i.status === "running")
354
352
  const pending = s.plan.find((p, i) => p.status === "pending" && i > at)
355
- return {
356
- now: at >= 0 ? s.plan[at]!.text : step?.label,
357
- next: pending?.text,
358
- done: s.plan.filter((p) => p.status === "completed").length,
359
- total: s.plan.length,
360
- }
353
+ return { now: at >= 0 ? s.plan[at]!.text : step?.label, next: pending?.text, done: s.plan.filter((p) => p.status === "completed").length, total: s.plan.length }
361
354
  }
362
355
 
363
- /** Rows the Now/Next header takes: one for each it has. */
356
+ /** Rows the Now/Next header takes: one for each it has, and a blank row under it before the lines. */
364
357
  export function nowNextRows(n: ReturnType<typeof nowNext>): number {
365
- return (n.now ? 1 : 0) + (n.next ? 1 : 0)
358
+ const rows = (n.now ? 1 : 0) + (n.next ? 1 : 0)
359
+ return rows ? rows + 1 : 0
366
360
  }
367
361
 
368
362
  /** Everything the agents do, newest at the bottom; focused, it scrolls and narrows to errors, steps, or tools. */
@@ -402,22 +396,27 @@ export function ActivityPanel(props: {
402
396
  })}
403
397
  </box>
404
398
  ) : null}
405
- {props.now?.now ? (
406
- <box style={{ flexDirection: "row", height: 1, flexShrink: 0 }}>
407
- <text fg={theme.muted}>{"Now "}</text>
408
- <box style={{ width: 2, flexShrink: 0 }}>
409
- <Spinner kind="dots" color={theme.warn} />
410
- </box>
411
- <text fg={theme.text}>{clip(props.now.now, room)}</text>
399
+ {props.now?.now || props.now?.next ? (
400
+ // A blank row under Now/Next keeps the header apart from the lines scrolling beneath it.
401
+ <box style={{ flexDirection: "column", flexShrink: 0, marginBottom: 1 }}>
402
+ {props.now?.now ? (
403
+ <box style={{ flexDirection: "row", height: 1, flexShrink: 0 }}>
404
+ <text fg={theme.muted}>{"Now "}</text>
405
+ <box style={{ width: 2, flexShrink: 0 }}>
406
+ <Spinner kind="dots" color={theme.warn} />
407
+ </box>
408
+ <text fg={theme.text}>{clip(props.now.now, room)}</text>
409
+ </box>
410
+ ) : null}
411
+ {props.now?.next ? (
412
+ <text style={{ height: 1, flexShrink: 0 }}>
413
+ <span fg={theme.muted}>{"Next "}</span>
414
+ <span fg={theme.faint}>{"○ "}</span>
415
+ <span fg={theme.muted}>{clip(props.now.next, room)}</span>
416
+ </text>
417
+ ) : null}
412
418
  </box>
413
419
  ) : null}
414
- {props.now?.next ? (
415
- <text style={{ height: 1, flexShrink: 0 }}>
416
- <span fg={theme.muted}>{"Next "}</span>
417
- <span fg={theme.faint}>{"○ "}</span>
418
- <span fg={theme.muted}>{clip(props.now.next, room)}</span>
419
- </text>
420
- ) : null}
421
420
  {real(shown) ? (
422
421
  <scrollbox ref={props.scrollRef} stickyScroll stickyStart="bottom" style={{ flexGrow: 1 }} focused={false}>
423
422
  {shown.map((line, i) => (
@@ -550,9 +549,15 @@ export function InfoStrip(props: { s: Inspector; c: ContextInfo; total: Usage; w
550
549
  <box style={{ flexDirection: "row", height: 1, flexShrink: 0, width: props.width, paddingLeft: 1 }}>
551
550
  <text>
552
551
  {parts.flatMap(([label, value, color], i) => [
553
- <span key={`s${i}`} fg={theme.faint}>{i ? " · " : ""}</span>,
554
- <span key={`l${i}`} fg={theme.muted}>{label}</span>,
555
- <span key={`v${i}`} fg={color}>{value}</span>,
552
+ <span key={`s${i}`} fg={theme.faint}>
553
+ {i ? " · " : ""}
554
+ </span>,
555
+ <span key={`l${i}`} fg={theme.muted}>
556
+ {label}
557
+ </span>,
558
+ <span key={`v${i}`} fg={color}>
559
+ {value}
560
+ </span>,
556
561
  ])}
557
562
  </text>
558
563
  </box>
package/src/types.ts CHANGED
@@ -29,6 +29,11 @@ export type MemoryInfo = {
29
29
  elements: number
30
30
  newElements: number
31
31
  flow: "saved" | "updated" | null
32
+ /** Recorded checks found on the page again during the replay, and how many of them held (registry.mode on). */
33
+ checked?: number
34
+ checksHeld?: number
35
+ /** Read-only steps the replay left out. */
36
+ skippedReads?: number
32
37
  /** After a failed run: the steps that worked before it went wrong, saved in resume.json for a retry to replay. */
33
38
  resume?: { steps: number; failedAt: number; reason: string; lines: string[]; file?: string }
34
39
  }
@@ -118,8 +123,10 @@ export type CodeResult = {
118
123
  knownDefect?: boolean
119
124
  /** The job did not finish: what it wrote was moved to the needs-attention folder (see src/code/attention.ts). */
120
125
  attention?: { dir: string; files: string[]; patch?: string; readme: string }
121
- /** The job worked from an existing test: ran it as it was, extended it, or updated it. */
122
- existing?: "reused" | "extended" | "updated"
126
+ /** The job worked from an existing test: ran it as it was, extended it, updated it, or added a test beside it. */
127
+ existing?: "reused" | "extended" | "updated" | "added_beside"
128
+ /** Times Scribe changed the existing test it was asked to keep, and was sent back before the test ran. */
129
+ breaches?: number
123
130
  }
124
131
 
125
132
  /** What an agent did to a file, for the shell's file list. */