runcap 0.1.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.
@@ -0,0 +1,1874 @@
1
+ import { spawn } from "node:child_process";
2
+ import { createHash } from "node:crypto";
3
+ import http from "node:http";
4
+ import { appendFile, mkdir, readFile, readdir, writeFile } from "node:fs/promises";
5
+ import { existsSync } from "node:fs";
6
+ import path from "node:path";
7
+ import process from "node:process";
8
+
9
+ const STORE_DIR = ".runcap";
10
+ const MISSIONS_DIR = path.join(STORE_DIR, "missions");
11
+ const PLANS_DIR = path.join(STORE_DIR, "plans");
12
+ const FUEL_FILE = path.join(STORE_DIR, "fuel.json");
13
+ const GATEWAY_EVENTS_FILE = path.join(STORE_DIR, "gateway-events.jsonl");
14
+ const ENV_EXAMPLE_FILE = ".env.example";
15
+
16
+ const ERROR_PATTERNS = [
17
+ {
18
+ kind: "module_not_found",
19
+ confidence: "high",
20
+ regexes: [
21
+ /Cannot find module ['"]([^'"]+)['"]/g,
22
+ /Cannot find package ['"]([^'"]+)['"]/g,
23
+ /Module not found.*?['"]([^'"]+)['"]/g,
24
+ /Can't resolve ['"]([^'"]+)['"]/g
25
+ ]
26
+ },
27
+ {
28
+ kind: "typescript_error",
29
+ confidence: "high",
30
+ regexes: [/(TS\d{4})[:\s]+([^\n]+)/g]
31
+ },
32
+ {
33
+ kind: "syntax_error",
34
+ confidence: "medium",
35
+ regexes: [/(SyntaxError|Parsing error|Unexpected token)[:\s]+([^\n]+)/g]
36
+ },
37
+ {
38
+ kind: "test_failure",
39
+ confidence: "medium",
40
+ regexes: [/(FAIL|failed|AssertionError|Expected .* Received .*)/gi]
41
+ },
42
+ {
43
+ kind: "command_not_found",
44
+ confidence: "high",
45
+ regexes: [
46
+ /Error: spawn ([^\s]+) ENOENT/g,
47
+ /spawn ([^\s]+) ENOENT/g,
48
+ /command not found: ([^\s]+)/gi,
49
+ /([^:\s]+): command not found/gi
50
+ ]
51
+ }
52
+ ];
53
+
54
+ export async function runMission({ command, label, fuelBefore }) {
55
+ await ensureStore();
56
+ const id = createMissionId(label);
57
+ const missionDir = path.join(MISSIONS_DIR, id);
58
+ await mkdir(missionDir, { recursive: true });
59
+
60
+ const start = new Date();
61
+ const cwd = process.cwd();
62
+ const before = await collectSnapshot(cwd);
63
+ const preflight = buildPreflight(command.join(" "), before);
64
+ const output = await runChild(command, cwd);
65
+ const after = await collectSnapshot(cwd);
66
+ const terminal = `${output.stdout}\n${output.stderr}`;
67
+ const errors = parseErrors(terminal);
68
+ const diffEvidence = analyzeDiff(before, after);
69
+ const stuck = detectStuck({ output, errors, diffEvidence, preflight });
70
+ const rescue = buildRescuePacket({ command, output, errors, diffEvidence, preflight, stuck });
71
+
72
+ const fuel = await readFuel();
73
+ const mission = {
74
+ id,
75
+ label: label ?? null,
76
+ command,
77
+ cwd,
78
+ startedAt: start.toISOString(),
79
+ finishedAt: new Date().toISOString(),
80
+ durationMs: output.durationMs,
81
+ exitCode: output.exitCode,
82
+ signal: output.signal,
83
+ fuelBefore: fuelBefore ?? fuel.currentPercent ?? null,
84
+ fuelAfter: null,
85
+ fuelUsedPercent: null,
86
+ preflight,
87
+ before,
88
+ after,
89
+ diffEvidence,
90
+ errors,
91
+ stuck,
92
+ rescue,
93
+ logs: {
94
+ stdoutPath: "stdout.log",
95
+ stderrPath: "stderr.log"
96
+ }
97
+ };
98
+
99
+ await writeFile(path.join(missionDir, "stdout.log"), output.stdout);
100
+ await writeFile(path.join(missionDir, "stderr.log"), output.stderr);
101
+ await writeFile(path.join(missionDir, "mission.json"), JSON.stringify(mission, null, 2));
102
+ await writeFile(path.join(missionDir, "report.md"), formatReport(mission));
103
+ await writeFile(path.join(missionDir, "report.html"), formatHtmlReport(mission));
104
+ await writeFile(path.join(STORE_DIR, "latest"), id);
105
+
106
+ return {
107
+ id,
108
+ summary: shortSummary(mission)
109
+ };
110
+ }
111
+
112
+ export async function latestMissionId() {
113
+ try {
114
+ return (await readFile(path.join(STORE_DIR, "latest"), "utf8")).trim();
115
+ } catch {
116
+ return null;
117
+ }
118
+ }
119
+
120
+ export async function renderReport(id) {
121
+ const mission = await readMission(id);
122
+ return formatReport(mission);
123
+ }
124
+
125
+ export async function exportSnapshot(id) {
126
+ await ensureStore();
127
+ const mission = await readMission(id);
128
+ const gateway = await readGatewaySummary();
129
+ const fuel = await readFuel();
130
+ const exportObject = {
131
+ exportedAt: new Date().toISOString(),
132
+ product: "Runcap",
133
+ truthModel: {
134
+ progressProof: "observed_from_git_and_command_result",
135
+ terminalErrors: "calculated_from_terminal_logs",
136
+ fuel: mission.fuelUsedPercent === null ? "unknown_until_manual_calibration" : "manual_before_after_calibration",
137
+ gatewayCost: gateway.truth
138
+ },
139
+ mission: {
140
+ id: mission.id,
141
+ label: mission.label,
142
+ command: mission.command,
143
+ status: mission.stuck.status,
144
+ confidence: mission.stuck.confidence,
145
+ exitCode: mission.exitCode,
146
+ durationMs: mission.durationMs,
147
+ evidence: mission.diffEvidence,
148
+ errors: mission.errors,
149
+ stuckSignals: mission.stuck.signals,
150
+ rescue: mission.rescue
151
+ },
152
+ fuel,
153
+ gateway
154
+ };
155
+ const file = path.join(MISSIONS_DIR, id, "export.json");
156
+ await writeFile(file, JSON.stringify(exportObject, null, 2));
157
+ return `Export written: ${file}`;
158
+ }
159
+
160
+ export function templates() {
161
+ return `Runcap Templates
162
+
163
+ 1. Coding feature with proof
164
+ runcap preflight -- claude "Implement <one feature>. Acceptance: <one visible result>. Verify with <command>."
165
+ runcap run --label feature -- claude "Implement <one feature>. Change only relevant files. Run <command>. Stop and report if blocked."
166
+
167
+ 2. Stuck rescue pass
168
+ runcap run --label rescue -- claude "Do not write broad code. Use the previous Runcap report. Diagnose the failure, map it to files/config, make the smallest fix, then run verification."
169
+
170
+ 3. Explorer before expensive coding
171
+ runcap run --label explorer -- claude "Do not edit files. Inspect the project and return the smallest implementation plan with exact files and verification command."
172
+
173
+ 4. Subscription fuel discipline
174
+ runcap fuel set 24
175
+ runcap run --label focused-slice --fuel-before 24 -- claude "Build one vertical slice only. Stop after verification."
176
+ runcap fuel calibrate <mission-id> <after-percent>
177
+
178
+ 5. API cost gateway
179
+ runcap gateway --mock
180
+ # or
181
+ AIM_DAILY_BUDGET_USD=5 OPENAI_API_KEY=sk-... runcap gateway
182
+ `;
183
+ }
184
+
185
+ export async function preflightMission(command) {
186
+ await ensureStore();
187
+ const snapshot = await collectSnapshot(process.cwd());
188
+ const preflight = buildPreflight(command.join(" "), snapshot);
189
+ const fuel = await readFuel();
190
+ return formatPreflight({ command, preflight, fuel });
191
+ }
192
+
193
+ export async function planMission(goal, options = {}) {
194
+ await ensureStore();
195
+ const snapshot = await collectSnapshot(process.cwd());
196
+ const fuel = await readFuel();
197
+ const plan = buildAiWorkPlan(goal, {
198
+ quality: options.quality ?? "high",
199
+ fuelPercent: options.fuelPercent ?? fuel.currentPercent,
200
+ snapshot
201
+ });
202
+ const planDir = path.join(PLANS_DIR, plan.id);
203
+ await mkdir(planDir, { recursive: true });
204
+ await writeFile(path.join(planDir, "plan.json"), JSON.stringify(plan, null, 2));
205
+ await writeFile(path.join(planDir, "plan.md"), formatPlan(plan));
206
+ await writeFile(path.join(STORE_DIR, "latest-plan"), plan.id);
207
+ return plan;
208
+ }
209
+
210
+ export async function listPlans() {
211
+ await ensureStore();
212
+ const plans = await readPlans();
213
+ if (plans.length === 0) return "No plans recorded yet.";
214
+ return plans.map((plan) => [
215
+ plan.id,
216
+ ` goal: ${plan.goal}`,
217
+ ` budget risk: ${plan.budget.risk}`,
218
+ ` expected saving: ${plan.budget.expectedWasteReduction}`,
219
+ ` planning model: ${plan.routing.planningTier}`,
220
+ ` execution model: ${plan.routing.executionTier}`,
221
+ ` report: ${path.join(PLANS_DIR, plan.id, "plan.md")}`
222
+ ].join("\n")).join("\n\n");
223
+ }
224
+
225
+ export async function listMissions() {
226
+ await ensureStore();
227
+ const missions = await readMissionSummaries();
228
+ if (missions.length === 0) return "No missions recorded yet.";
229
+ return missions.map((mission) => [
230
+ mission.id,
231
+ ` status: ${mission.status}`,
232
+ ` exit: ${mission.exitCode}`,
233
+ ` errors: ${mission.errorCount}`,
234
+ ` changed files: ${mission.changedFileCount}`,
235
+ ` report: ${path.join(MISSIONS_DIR, mission.id, "report.md")}`
236
+ ].join("\n")).join("\n\n");
237
+ }
238
+
239
+ export async function setupProject() {
240
+ await ensureStore();
241
+ const envExample = [
242
+ "# Runcap",
243
+ "# For real gateway mode:",
244
+ "OPENAI_API_KEY=",
245
+ "AIM_UPSTREAM_BASE_URL=https://api.openai.com/v1",
246
+ "",
247
+ "# Optional budget guard. If estimated spend already exceeds this, gateway blocks new calls.",
248
+ "AIM_DAILY_BUDGET_USD=5",
249
+ "",
250
+ "# For demo mode without external API calls:",
251
+ "AIM_GATEWAY_MODE=mock"
252
+ ].join("\n");
253
+ if (!existsSync(ENV_EXAMPLE_FILE)) {
254
+ await writeFile(ENV_EXAMPLE_FILE, `${envExample}\n`);
255
+ }
256
+ return [
257
+ "Runcap setup complete.",
258
+ `Store: ${STORE_DIR}`,
259
+ `Example env: ${ENV_EXAMPLE_FILE}`,
260
+ "",
261
+ "Try:",
262
+ " runcap preflight -- claude \"build the full mobile app\"",
263
+ " runcap run --label demo -- npm --prefix examples/broken-ts-app run build",
264
+ " runcap gateway --mock"
265
+ ].join("\n");
266
+ }
267
+
268
+ export async function doctor() {
269
+ await ensureStore();
270
+ const snapshot = await collectSnapshot(process.cwd());
271
+ const fuel = await readFuel();
272
+ const missions = await readMissionSummaries();
273
+ const gateway = await readGatewaySummary();
274
+ const hasVerificationScript = Boolean(snapshot.packageJson && Object.keys(snapshot.packageJson.scripts ?? {}).some((name) => /test|build|lint|typecheck/.test(name)));
275
+ const checks = [
276
+ ["Store exists", existsSync(STORE_DIR), STORE_DIR],
277
+ ["Git available", snapshot.gitAvailable, snapshot.gitAvailable ? "observed" : "not available"],
278
+ ["package.json visible", Boolean(snapshot.packageJson), Boolean(snapshot.packageJson) ? "yes" : "no"],
279
+ ["Verification scripts", hasVerificationScript, "build/test/lint/typecheck"],
280
+ ["Fuel calibrated", fuel.currentPercent !== null, fuel.currentPercent === null ? "unknown" : `${fuel.currentPercent}%`],
281
+ ["Missions recorded", missions.length > 0, String(missions.length)],
282
+ ["Gateway events recorded", gateway.callCount > 0, `${gateway.callCount} calls`]
283
+ ];
284
+ return [
285
+ "Runcap Doctor",
286
+ ...checks.map(([name, ok, detail]) => `${ok ? "OK" : "WARN"} ${name}: ${detail}`),
287
+ "",
288
+ "Recommended next step:",
289
+ missions.length === 0
290
+ ? " runcap run --label first-check -- npm test"
291
+ : " runcap dashboard"
292
+ ].join("\n");
293
+ }
294
+
295
+ export async function startDashboard({ port = 8791 } = {}) {
296
+ await ensureStore();
297
+ const server = http.createServer(async (request, response) => {
298
+ try {
299
+ const url = new URL(request.url ?? "/", `http://127.0.0.1:${port}`);
300
+ if (url.pathname === "/") {
301
+ send(response, 200, renderDashboardHtml(), "text/html; charset=utf-8");
302
+ } else if (url.pathname === "/api/status") {
303
+ sendJson(response, await dashboardStatus());
304
+ } else if (url.pathname === "/api/missions") {
305
+ sendJson(response, await readMissionSummaries());
306
+ } else if (url.pathname === "/api/plans" && request.method === "GET") {
307
+ sendJson(response, await readPlans());
308
+ } else if (url.pathname === "/api/plans" && request.method === "POST") {
309
+ const body = safeJson(await readRequestBody(request));
310
+ if (!body.goal || typeof body.goal !== "string") {
311
+ sendJson(response, { error: "Missing plan goal." }, 400);
312
+ return;
313
+ }
314
+ sendJson(response, await planMission(body.goal, {
315
+ quality: body.quality,
316
+ fuelPercent: Number.isFinite(Number(body.fuelPercent)) ? Number(body.fuelPercent) : undefined
317
+ }), 201);
318
+ } else if (url.pathname.startsWith("/api/plans/")) {
319
+ const id = decodeURIComponent(url.pathname.replace("/api/plans/", ""));
320
+ sendJson(response, await readPlan(id));
321
+ } else if (url.pathname === "/api/gateway") {
322
+ sendJson(response, await readGatewaySummary());
323
+ } else if (url.pathname.startsWith("/api/missions/")) {
324
+ const id = decodeURIComponent(url.pathname.replace("/api/missions/", ""));
325
+ sendJson(response, await readMission(id));
326
+ } else {
327
+ send(response, 404, "Not found", "text/plain; charset=utf-8");
328
+ }
329
+ } catch (error) {
330
+ sendJson(response, { error: error.message }, 500);
331
+ }
332
+ });
333
+ await listenLocal(server, port, "dashboard");
334
+ console.log(`Runcap dashboard: http://127.0.0.1:${port}`);
335
+ console.log("Press Ctrl+C to stop.");
336
+ }
337
+
338
+ async function listenLocal(server, port, label) {
339
+ await new Promise((resolve, reject) => {
340
+ server.once("error", reject);
341
+ server.listen(port, "127.0.0.1", resolve);
342
+ }).catch((error) => {
343
+ if (error.code === "EADDRINUSE") {
344
+ throw new Error(`${label} port ${port} is already in use. Try another port, for example ${port + 1}.`);
345
+ }
346
+ if (error.code === "EPERM") {
347
+ throw new Error(`Cannot open local ${label} port ${port} in this environment. Try another port or grant local server permission.`);
348
+ }
349
+ throw error;
350
+ });
351
+ }
352
+
353
+ export async function startGateway({ port = 8792, mock = false } = {}) {
354
+ await ensureStore();
355
+ const gatewayMode = mock || process.env.AIM_GATEWAY_MODE === "mock" ? "mock" : "proxy";
356
+ const openaiKey = process.env.AIM_UPSTREAM_API_KEY ?? process.env.OPENAI_API_KEY;
357
+ const anthropicKey = process.env.ANTHROPIC_API_KEY;
358
+ const openaiBaseUrl = process.env.AIM_UPSTREAM_BASE_URL ?? process.env.OPENAI_BASE_URL ?? "https://api.openai.com/v1";
359
+ const anthropicBaseUrl = process.env.ANTHROPIC_BASE_URL ?? "https://api.anthropic.com/v1";
360
+ const anthropicVersion = process.env.ANTHROPIC_VERSION ?? "2023-06-01";
361
+ if (gatewayMode !== "mock" && !openaiKey && !anthropicKey) {
362
+ throw new Error("Missing upstream key. Set OPENAI_API_KEY (for /v1/chat/completions) and/or ANTHROPIC_API_KEY (for /v1/messages). The gateway cannot proxy without at least one.");
363
+ }
364
+ const server = http.createServer(async (request, response) => {
365
+ const started = Date.now();
366
+ try {
367
+ const url = new URL(request.url ?? "/", `http://127.0.0.1:${port}`);
368
+ if (request.method === "GET" && url.pathname === "/health") {
369
+ sendJson(response, {
370
+ ok: true,
371
+ mode: gatewayMode,
372
+ openaiUpstream: openaiBaseUrl,
373
+ anthropicUpstream: anthropicBaseUrl,
374
+ openaiKey: Boolean(openaiKey),
375
+ anthropicKey: Boolean(anthropicKey)
376
+ });
377
+ return;
378
+ }
379
+ if (request.method !== "POST" || !url.pathname.startsWith("/v1/")) {
380
+ send(response, 404, "Gateway supports POST /v1/* and GET /health.", "text/plain; charset=utf-8");
381
+ return;
382
+ }
383
+
384
+ const bodyText = await readRequestBody(request);
385
+ const requestBody = safeJson(bodyText) ?? {};
386
+ const budget = readBudget();
387
+ const summary = await readGatewaySummary();
388
+ if (budget !== null && summary.estimatedCostUsd >= budget) {
389
+ const event = {
390
+ at: new Date().toISOString(),
391
+ path: url.pathname,
392
+ model: requestBody.model ?? "unknown",
393
+ status: 429,
394
+ durationMs: Date.now() - started,
395
+ usage: null,
396
+ cost: null,
397
+ truth: "budget_guard",
398
+ error: `Budget exceeded: ${summary.estimatedCostUsd} >= ${budget}`,
399
+ requestHash: createHash("sha1").update(bodyText).digest("hex")
400
+ };
401
+ await appendGatewayEvent(event);
402
+ sendJson(response, { error: event.error, truth: event.truth }, 429);
403
+ return;
404
+ }
405
+ if (gatewayMode === "mock") {
406
+ const responseBody = mockCompletion(requestBody, url.pathname);
407
+ const responseText = JSON.stringify(responseBody);
408
+ send(response, 200, responseText, "application/json; charset=utf-8");
409
+ await appendGatewayEvent({
410
+ at: new Date().toISOString(),
411
+ path: url.pathname,
412
+ model: requestBody.model ?? responseBody.model ?? "mock-model",
413
+ status: 200,
414
+ durationMs: Date.now() - started,
415
+ usage: responseBody.usage,
416
+ cost: estimateApiCost(responseBody.usage, requestBody.model ?? responseBody.model),
417
+ truth: "mock_provider_usage",
418
+ requestHash: createHash("sha1").update(bodyText).digest("hex")
419
+ });
420
+ return;
421
+ }
422
+ const isAnthropic = url.pathname.startsWith("/v1/messages");
423
+ const upstreamBase = isAnthropic ? anthropicBaseUrl : openaiBaseUrl;
424
+ const upstreamKey = isAnthropic ? anthropicKey : openaiKey;
425
+ if (!upstreamKey) {
426
+ const missing = isAnthropic ? "ANTHROPIC_API_KEY" : "OPENAI_API_KEY";
427
+ send(response, 400, `Gateway has no ${missing} set for ${url.pathname}.`, "text/plain; charset=utf-8");
428
+ return;
429
+ }
430
+ const headers = isAnthropic
431
+ ? {
432
+ "x-api-key": upstreamKey,
433
+ "anthropic-version": anthropicVersion,
434
+ "content-type": request.headers["content-type"] ?? "application/json"
435
+ }
436
+ : {
437
+ "authorization": `Bearer ${upstreamKey}`,
438
+ "content-type": request.headers["content-type"] ?? "application/json"
439
+ };
440
+ // Anthropic base URLs already include /v1; avoid doubling it.
441
+ const pathForUpstream = isAnthropic ? url.pathname.replace(/^\/v1/, "") : url.pathname;
442
+ const upstreamUrl = `${upstreamBase.replace(/\/$/, "")}${pathForUpstream}`;
443
+ const upstreamResponse = await fetch(upstreamUrl, {
444
+ method: "POST",
445
+ headers,
446
+ body: bodyText
447
+ });
448
+ const responseText = await upstreamResponse.text();
449
+ response.writeHead(upstreamResponse.status, {
450
+ "content-type": upstreamResponse.headers.get("content-type") ?? "application/json",
451
+ "cache-control": "no-store"
452
+ });
453
+ response.end(responseText);
454
+
455
+ const responseBody = safeJson(responseText) ?? {};
456
+ await appendGatewayEvent({
457
+ at: new Date().toISOString(),
458
+ path: url.pathname,
459
+ model: requestBody.model ?? responseBody.model ?? "unknown",
460
+ status: upstreamResponse.status,
461
+ durationMs: Date.now() - started,
462
+ usage: responseBody.usage ?? null,
463
+ cost: estimateApiCost(responseBody.usage, requestBody.model ?? responseBody.model),
464
+ truth: responseBody.usage ? "provider_usage" : "unknown",
465
+ requestHash: createHash("sha1").update(bodyText).digest("hex")
466
+ });
467
+ } catch (error) {
468
+ await appendGatewayEvent({
469
+ at: new Date().toISOString(),
470
+ path: request.url,
471
+ model: "unknown",
472
+ status: 500,
473
+ durationMs: Date.now() - started,
474
+ usage: null,
475
+ cost: null,
476
+ truth: "unknown",
477
+ error: error.message
478
+ }).catch(() => {});
479
+ sendJson(response, { error: error.message }, 500);
480
+ }
481
+ });
482
+ await listenLocal(server, port, "gateway");
483
+ console.log(`Runcap gateway: http://127.0.0.1:${port}/v1`);
484
+ console.log(`Mode: ${gatewayMode}`);
485
+ if (gatewayMode === "mock") {
486
+ console.log("Upstream: mock local responder");
487
+ } else {
488
+ console.log(`Upstream (OpenAI /v1/chat/completions): ${openaiKey ? openaiBaseUrl : "no key set"}`);
489
+ console.log(`Upstream (Anthropic /v1/messages): ${anthropicKey ? anthropicBaseUrl : "no key set"}`);
490
+ }
491
+ console.log("Press Ctrl+C to stop.");
492
+ }
493
+
494
+ export async function showStatus(options = {}) {
495
+ await ensureStore();
496
+ const fuel = await readFuel();
497
+ const fuelLine = fuel.currentPercent === null
498
+ ? "Fuel: unknown. Run `aim fuel set <percent>` to calibrate subscription limits."
499
+ : `Fuel: ${fuel.currentPercent}% (${fuel.source}, confidence: ${fuel.confidence})`;
500
+ if (options.includeFuelOnly) return fuelLine;
501
+
502
+ const gateway = await readGatewaySummary();
503
+ const gatewayLine = `Gateway: ${gateway.callCount} calls, ${gateway.totalTokens} tokens, $${gateway.estimatedCostUsd} estimated (${gateway.truth})`;
504
+ const latest = await latestMissionId();
505
+ if (!latest) return `${fuelLine}\n${gatewayLine}\nNo missions recorded yet.`;
506
+ const mission = await readMission(latest);
507
+ return [
508
+ fuelLine,
509
+ gatewayLine,
510
+ `Latest mission: ${mission.id}`,
511
+ `Status: ${mission.stuck.status}`,
512
+ `Exit code: ${mission.exitCode}`,
513
+ `Changed files: ${mission.diffEvidence.changedFiles.length}`,
514
+ `Errors: ${mission.errors.length}`,
515
+ `Report: ${path.join(MISSIONS_DIR, mission.id, "report.md")}`
516
+ ].join("\n");
517
+ }
518
+
519
+ export async function recordFuel(value) {
520
+ await ensureStore();
521
+ const fuel = {
522
+ currentPercent: clampPercent(value),
523
+ source: "manual",
524
+ confidence: "medium",
525
+ updatedAt: new Date().toISOString(),
526
+ calibrations: []
527
+ };
528
+ await writeFile(FUEL_FILE, JSON.stringify(fuel, null, 2));
529
+ return `Fuel set to ${fuel.currentPercent}%. Future missions can estimate subscription burn from this baseline.`;
530
+ }
531
+
532
+ export async function calibrateFuel(id, afterPercent) {
533
+ const mission = await readMission(id);
534
+ const after = clampPercent(afterPercent);
535
+ const before = mission.fuelBefore;
536
+ if (before === null || before === undefined) {
537
+ throw new Error("Mission has no fuelBefore baseline. Use `aim run --fuel-before <percent>` next time.");
538
+ }
539
+ const used = Number(Math.max(0, before - after).toFixed(2));
540
+ mission.fuelAfter = after;
541
+ mission.fuelUsedPercent = used;
542
+ mission.fuelAccuracy = {
543
+ source: "manual_calibration",
544
+ confidence: "high",
545
+ note: "Subscription providers rarely expose exact token-to-percent formulas. This is measured from user-visible before/after fuel."
546
+ };
547
+ const missionDir = path.join(MISSIONS_DIR, mission.id);
548
+ await writeFile(path.join(missionDir, "mission.json"), JSON.stringify(mission, null, 2));
549
+ await writeFile(path.join(missionDir, "report.md"), formatReport(mission));
550
+ await writeFile(path.join(missionDir, "report.html"), formatHtmlReport(mission));
551
+
552
+ const fuel = await readFuel();
553
+ fuel.currentPercent = after;
554
+ fuel.updatedAt = new Date().toISOString();
555
+ fuel.calibrations = [...(fuel.calibrations ?? []), { missionId: id, before, after, used, at: fuel.updatedAt }];
556
+ await writeFile(FUEL_FILE, JSON.stringify(fuel, null, 2));
557
+ return `Mission ${id} calibrated: used ${used}% fuel.`;
558
+ }
559
+
560
+ async function ensureStore() {
561
+ await mkdir(MISSIONS_DIR, { recursive: true });
562
+ await mkdir(PLANS_DIR, { recursive: true });
563
+ }
564
+
565
+ function createMissionId(label) {
566
+ const stamp = new Date().toISOString().replace(/[-:]/g, "").replace(/\..+/, "");
567
+ const cleanLabel = label ? `-${label.replace(/[^a-zA-Z0-9_-]+/g, "-").slice(0, 36)}` : "";
568
+ const hash = createHash("sha1").update(`${stamp}${Math.random()}`).digest("hex").slice(0, 7);
569
+ return `${stamp}${cleanLabel}-${hash}`;
570
+ }
571
+
572
+ function createPlanId(goal) {
573
+ const stamp = new Date().toISOString().replace(/[-:]/g, "").replace(/\..+/, "");
574
+ const cleanGoal = goal.replace(/[^a-zA-Z0-9_-]+/g, "-").replace(/^-|-$/g, "").slice(0, 34) || "ai-work";
575
+ const hash = createHash("sha1").update(`${stamp}${goal}${Math.random()}`).digest("hex").slice(0, 7);
576
+ return `${stamp}-plan-${cleanGoal}-${hash}`;
577
+ }
578
+
579
+ function buildAiWorkPlan(goal, { quality = "high", fuelPercent = null, snapshot = {} } = {}) {
580
+ const cleanGoal = goal.trim();
581
+ const lower = cleanGoal.toLowerCase();
582
+ const words = cleanGoal.split(/\s+/).filter(Boolean).length;
583
+ const taskType = classifyTask(lower);
584
+ const bigSignals = [
585
+ /full|entire|complete|whole|production|everything|mvp|startup|platform/.test(lower),
586
+ /полное|полностью|приложение|платформ/.test(lower),
587
+ words > 22
588
+ ].filter(Boolean).length;
589
+ const hasRepo = Boolean(snapshot.packageJson);
590
+ const hasVerification = hasRepo && Object.keys(snapshot.packageJson?.scripts ?? {}).some((name) => /test|build|lint|typecheck/.test(name));
591
+ const fuel = Number.isFinite(Number(fuelPercent)) ? Number(fuelPercent) : null;
592
+ const budgetRisk = bigSignals > 0 || (fuel !== null && fuel < 30) ? "High" : fuel !== null && fuel < 55 ? "Medium" : "Low";
593
+ const expectedWasteReduction = budgetRisk === "High" ? "40-70%" : budgetRisk === "Medium" ? "25-45%" : "10-25%";
594
+ const qualityRisk = quality === "cheap" && budgetRisk === "High" ? "High" : budgetRisk === "High" ? "Medium" : "Low";
595
+ const routing = routeTask({ taskType, budgetRisk, quality, hasVerification });
596
+ const proof = proofForTask({ taskType, hasVerification });
597
+ const missions = missionBreakdown({ taskType, budgetRisk, proof });
598
+ return {
599
+ id: createPlanId(cleanGoal),
600
+ createdAt: new Date().toISOString(),
601
+ goal: cleanGoal,
602
+ taskType,
603
+ inputs: {
604
+ quality,
605
+ fuelPercent: fuel,
606
+ repoDetected: hasRepo,
607
+ verificationDetected: hasVerification
608
+ },
609
+ budget: {
610
+ risk: budgetRisk,
611
+ expectedWasteReduction,
612
+ reason: budgetRisk === "High"
613
+ ? "The goal is broad or fuel is low. A single agent run is likely to waste context and repeat work."
614
+ : "The goal can be controlled with smaller missions and proof checkpoints."
615
+ },
616
+ routing,
617
+ quality: {
618
+ risk: qualityRisk,
619
+ proof
620
+ },
621
+ missions,
622
+ stopRule: stopRuleForTask(taskType),
623
+ commandTemplates: commandTemplatesForPlan(cleanGoal, missions),
624
+ truth: {
625
+ source: "local_heuristic_planner",
626
+ costPrecision: "estimate_not_provider_bill",
627
+ qualityPrecision: "requires_artifact_review"
628
+ }
629
+ };
630
+ }
631
+
632
+ function classifyTask(lower) {
633
+ if (/code|bug|test|build|app|api|database|typescript|react|python|deploy|auth|repo|github/.test(lower)) return "software";
634
+ if (/video|script|post|content|image|marketing|copy|campaign|linkedin|youtube/.test(lower)) return "creative";
635
+ if (/invoice|crm|email|calendar|automation|report|workflow|support|sales|ops/.test(lower)) return "operations";
636
+ if (/research|market|competitor|analysis|strategy|audit/.test(lower)) return "research";
637
+ return "general";
638
+ }
639
+
640
+ function routeTask({ taskType, budgetRisk, quality, hasVerification }) {
641
+ const strongFirst = budgetRisk === "High" || quality === "high";
642
+ const executionTier = taskType === "software" && hasVerification
643
+ ? "Balanced or cheap model for narrow edits after diagnosis"
644
+ : taskType === "creative" && quality !== "high"
645
+ ? "Cheap model for drafts, strong model only for final review"
646
+ : taskType === "research"
647
+ ? "Cheap model for collection, strong model for synthesis"
648
+ : "Cheap model for execution with owner review";
649
+ return {
650
+ planningTier: strongFirst ? "Strong model for planning only" : "Cheap model first",
651
+ executionTier,
652
+ escalationRule: "Escalate to a stronger model only when proof fails, architecture is unclear, or the same blocker repeats."
653
+ };
654
+ }
655
+
656
+ function proofForTask({ taskType, hasVerification }) {
657
+ if (taskType === "software") {
658
+ return hasVerification
659
+ ? "diff exists, verification command passes, and changed files match the mission scope"
660
+ : "diff exists, app starts or manual smoke check is documented";
661
+ }
662
+ if (taskType === "creative") return "usable artifact exists, revision checklist is completed, and owner selects one direction";
663
+ if (taskType === "operations") return "sample workflow output is reviewed by owner and exception path is documented";
664
+ if (taskType === "research") return "source list, synthesis, and decision recommendation are separated";
665
+ return "artifact exists and owner can inspect whether it solves the requested job";
666
+ }
667
+
668
+ function missionBreakdown({ taskType, budgetRisk, proof }) {
669
+ const first = taskType === "software"
670
+ ? "Inspect the repo, identify files, dependencies, verification command, and risk. Do not write code yet."
671
+ : taskType === "creative"
672
+ ? "Define target audience, format, acceptance checklist, and 2-3 directions before generation."
673
+ : taskType === "operations"
674
+ ? "Map current workflow, inputs, outputs, owner approvals, and failure cases."
675
+ : "Clarify the decision, collect only necessary context, and define what proof will be accepted.";
676
+ const second = taskType === "software"
677
+ ? "Implement one narrow vertical slice and run exactly one verification command."
678
+ : "Create the smallest useful artifact that can be reviewed by the owner.";
679
+ return [
680
+ { name: "Discovery mission", modelTier: budgetRisk === "High" ? "Strong" : "Cheap", instruction: first, proof: "plan with files/tools/proof, no broad execution" },
681
+ { name: "Execution mission", modelTier: "Cheaper unless proof fails", instruction: second, proof },
682
+ { name: "Review mission", modelTier: "Strong only if needed", instruction: "Compare result against proof, list gaps, and decide continue/stop/rescue.", proof: "clear continue, stop, or rescue decision" }
683
+ ];
684
+ }
685
+
686
+ function stopRuleForTask(taskType) {
687
+ if (taskType === "software") return "Stop after the same error appears twice, after 10 minutes with no file changes, or after a broad rewrite request appears.";
688
+ if (taskType === "creative") return "Stop when output repeats, becomes generic, or produces no inspectable artifact.";
689
+ if (taskType === "operations") return "Stop when the agent cannot name the owner, system of record, approval point, or exception path.";
690
+ if (taskType === "research") return "Stop when sources are missing, claims are uncited, or the answer becomes a generic summary.";
691
+ return "Stop when there is no new artifact, no proof, or repeated generic output.";
692
+ }
693
+
694
+ function commandTemplatesForPlan(goal, missions) {
695
+ const quotedGoal = goal.replace(/"/g, '\\"');
696
+ return missions.map((mission, index) => ({
697
+ mission: mission.name,
698
+ command: `runcap run --label plan-${index + 1} -- codex "${mission.instruction} Goal: ${quotedGoal} Proof required: ${mission.proof}"`
699
+ }));
700
+ }
701
+
702
+ async function runChild(command, cwd) {
703
+ const started = Date.now();
704
+ const [program, ...args] = command;
705
+ return await new Promise((resolve) => {
706
+ const child = spawn(program, args, {
707
+ cwd,
708
+ env: { ...process.env, AIM_WRAPPED: "1" },
709
+ shell: false
710
+ });
711
+ let stdout = "";
712
+ let stderr = "";
713
+ child.stdout?.on("data", (chunk) => {
714
+ const text = chunk.toString();
715
+ stdout += text;
716
+ process.stdout.write(text);
717
+ });
718
+ child.stderr?.on("data", (chunk) => {
719
+ const text = chunk.toString();
720
+ stderr += text;
721
+ process.stderr.write(text);
722
+ });
723
+ child.on("error", (error) => {
724
+ stderr += `\n${error.stack ?? error.message}\n`;
725
+ resolve({ stdout, stderr, exitCode: 127, signal: null, durationMs: Date.now() - started });
726
+ });
727
+ child.on("close", (exitCode, signal) => {
728
+ resolve({ stdout, stderr, exitCode, signal, durationMs: Date.now() - started });
729
+ });
730
+ });
731
+ }
732
+
733
+ async function collectSnapshot(cwd) {
734
+ const [status, diffNameStatus, diff, packageJson, tsconfig] = await Promise.all([
735
+ git(["status", "--short", "--", "."], cwd),
736
+ git(["diff", "--name-status", "--", "."], cwd),
737
+ git(["diff", "--", "."], cwd),
738
+ readOptional(path.join(cwd, "package.json")),
739
+ readOptional(path.join(cwd, "tsconfig.json"))
740
+ ]);
741
+ return {
742
+ at: new Date().toISOString(),
743
+ gitAvailable: !status.error,
744
+ status: status.text,
745
+ diffNameStatus: diffNameStatus.text,
746
+ diff: diff.text,
747
+ packageJson: packageJson ? safeJson(packageJson) : null,
748
+ tsconfig: tsconfig ? safeJson(tsconfig) : null
749
+ };
750
+ }
751
+
752
+ async function git(args, cwd) {
753
+ return await new Promise((resolve) => {
754
+ const child = spawn("git", args, { cwd, shell: false });
755
+ let stdout = "";
756
+ let stderr = "";
757
+ child.stdout.on("data", (chunk) => { stdout += chunk.toString(); });
758
+ child.stderr.on("data", (chunk) => { stderr += chunk.toString(); });
759
+ child.on("error", (error) => resolve({ text: "", error: error.message }));
760
+ child.on("close", (code) => resolve({ text: stdout.trim(), error: code === 0 ? null : stderr.trim() }));
761
+ });
762
+ }
763
+
764
+ async function readOptional(file) {
765
+ try {
766
+ return await readFile(file, "utf8");
767
+ } catch {
768
+ return null;
769
+ }
770
+ }
771
+
772
+ function safeJson(text) {
773
+ try {
774
+ return JSON.parse(text);
775
+ } catch {
776
+ return { parseError: true };
777
+ }
778
+ }
779
+
780
+ function analyzeDiff(before, after) {
781
+ const beforeLines = new Set(cleanDiffNameStatus(before.diffNameStatus));
782
+ const afterLines = cleanDiffNameStatus(after.diffNameStatus);
783
+ const missionLines = afterLines.filter((line) => !beforeLines.has(line));
784
+ const changed = missionLines.map((line) => {
785
+ const [, file = line] = line.split(/\s+/, 2);
786
+ return file;
787
+ });
788
+ const newDiffLines = after.diff.split("\n").filter((line) => line.startsWith("+") && !line.startsWith("+++"));
789
+ const addedImports = newDiffLines
790
+ .map((line) => line.match(/import .* from ['"]([^'"]+)['"]/))
791
+ .filter(Boolean)
792
+ .map((match) => match[1]);
793
+ return {
794
+ changedFiles: [...new Set(changed)],
795
+ changedFileCount: new Set(changed).size,
796
+ hadPreExistingDiff: beforeLines.size > 0,
797
+ addedImports: [...new Set(addedImports)],
798
+ diffBytes: after.diff.length,
799
+ diffHash: createHash("sha1").update(after.diff).digest("hex")
800
+ };
801
+ }
802
+
803
+ function cleanDiffNameStatus(text) {
804
+ return (text || "")
805
+ .split("\n")
806
+ .filter(Boolean)
807
+ .filter((line) => !line.includes(".runcap/") && !line.includes(".aim-control/"));
808
+ }
809
+
810
+ function buildPreflight(prompt, snapshot) {
811
+ const lower = prompt.toLowerCase();
812
+ const broadWords = ["build app", "entire app", "full app", "production", "everything", "whole project", "сделай приложение", "полное приложение"];
813
+ const risky = broadWords.filter((word) => lower.includes(word));
814
+ const hasPackage = Boolean(snapshot.packageJson);
815
+ const hasTests = hasPackage && Object.keys(snapshot.packageJson.scripts ?? {}).some((name) => /test|build|lint|typecheck/.test(name));
816
+ return {
817
+ scopeRisk: risky.length > 0 ? "high" : prompt.length > 180 ? "medium" : "low",
818
+ scopeSignals: risky,
819
+ repoSignals: {
820
+ hasPackageJson: hasPackage,
821
+ hasTsconfig: Boolean(snapshot.tsconfig),
822
+ hasVerificationScripts: hasTests
823
+ },
824
+ recommendation: risky.length > 0
825
+ ? "Split the mission into a narrow vertical slice before running an expensive agent loop."
826
+ : "Mission looks narrow enough for a first run, but proof should still be verified through artifacts."
827
+ };
828
+ }
829
+
830
+ function parseErrors(text) {
831
+ const errors = [];
832
+ for (const pattern of ERROR_PATTERNS) {
833
+ for (const regex of pattern.regexes) {
834
+ regex.lastIndex = 0;
835
+ let match;
836
+ while ((match = regex.exec(text)) !== null) {
837
+ errors.push({
838
+ kind: pattern.kind,
839
+ confidence: pattern.confidence,
840
+ raw: match[0].slice(0, 280),
841
+ primary: match[1] ?? null,
842
+ detail: match[2] ?? null,
843
+ sourceFile: findSourceFileNear(text, match.index),
844
+ line: findLineNear(text, match.index)
845
+ });
846
+ }
847
+ }
848
+ }
849
+ return dedupe(errors, (error) => `${error.kind}:${error.raw}`);
850
+ }
851
+
852
+ function findSourceFileNear(text, index) {
853
+ const start = Math.max(0, index - 420);
854
+ const end = Math.min(text.length, index + 620);
855
+ const chunk = text.slice(start, end);
856
+ const importedFrom = chunk.match(/imported from ([^\s\n)]+)/);
857
+ if (importedFrom?.[1]) return cleanPath(importedFrom[1]);
858
+ const file = chunk.match(/([A-Za-z0-9_./@-]+\.(?:ts|tsx|js|jsx|mjs|cjs))/);
859
+ return file?.[1] ? cleanPath(file[1]) : null;
860
+ }
861
+
862
+ function findLineNear(text, index) {
863
+ const start = Math.max(0, index - 220);
864
+ const end = Math.min(text.length, index + 220);
865
+ const chunk = text.slice(start, end);
866
+ const line = chunk.match(/(?:line |:)(\d+)(?::\d+)?/i);
867
+ return line?.[1] ? Number(line[1]) : null;
868
+ }
869
+
870
+ function cleanPath(value) {
871
+ return String(value).replace(/^file:\/\//, "").replace(/[),.;]+$/, "");
872
+ }
873
+
874
+ function detectStuck({ output, errors, diffEvidence, preflight }) {
875
+ const signals = [];
876
+ if (output.exitCode !== 0) signals.push({ signal: "command_failed", weight: 2, evidence: `exit code ${output.exitCode}` });
877
+ if (errors.length > 0) signals.push({ signal: "terminal_errors", weight: 2, evidence: `${errors.length} parsed error(s)` });
878
+ if (errors.some((error) => error.kind === "command_not_found")) {
879
+ signals.push({ signal: "missing_cli", weight: 2, evidence: "the requested command could not be found on PATH" });
880
+ }
881
+ if (diffEvidence.changedFiles.length === 0 && output.durationMs > 3000) {
882
+ signals.push({ signal: "no_artifact", weight: 2, evidence: "no git diff changed during mission" });
883
+ }
884
+ if (diffEvidence.changedFiles.length > 0 && output.exitCode !== 0) {
885
+ signals.push({ signal: "artifact_but_not_verified", weight: 1, evidence: "files changed but command failed" });
886
+ }
887
+ if (preflight.scopeRisk === "high") {
888
+ signals.push({ signal: "scope_too_broad", weight: 1, evidence: "preflight detected broad mission wording" });
889
+ }
890
+ const score = signals.reduce((sum, item) => sum + item.weight, 0);
891
+ return {
892
+ status: score >= 4 ? "stuck" : score >= 2 ? "at_risk" : "progressing",
893
+ confidence: score >= 5 ? "high" : score >= 3 ? "medium" : "low",
894
+ score,
895
+ signals
896
+ };
897
+ }
898
+
899
+ function buildRescuePacket({ command, output, errors, diffEvidence, preflight, stuck }) {
900
+ const recommendations = [];
901
+ const moduleErrors = errors.filter((error) => error.kind === "module_not_found");
902
+ const tsErrors = errors.filter((error) => error.kind === "typescript_error");
903
+ const commandErrors = errors.filter((error) => error.kind === "command_not_found");
904
+
905
+ for (const error of commandErrors) {
906
+ const missingCommand = error.primary ?? command[0];
907
+ recommendations.push({
908
+ title: "Install or expose the missing agent command",
909
+ confidence: "high",
910
+ evidence: [
911
+ error.raw,
912
+ `Requested command: ${command[0]}`,
913
+ "The process failed before the agent could do any useful work."
914
+ ],
915
+ nextAction: `Install '${missingCommand}' or run the mission with a command that exists in this terminal session.`,
916
+ prompt: `The agent command '${missingCommand}' was not found. Do not retry the same command until the CLI is installed and available on PATH. First run '${missingCommand} --version' or choose another installed agent command, then rerun the mission.`
917
+ });
918
+ }
919
+
920
+ for (const error of moduleErrors) {
921
+ const imported = error.primary;
922
+ const wasAdded = imported && diffEvidence.addedImports.includes(imported);
923
+ const sourceChanged = error.sourceFile && diffEvidence.changedFiles.some((file) => error.sourceFile.endsWith(file) || file.endsWith(error.sourceFile));
924
+ recommendations.push({
925
+ title: "Resolve missing import before continuing feature work",
926
+ confidence: wasAdded || sourceChanged ? "high" : "medium",
927
+ evidence: [
928
+ error.raw,
929
+ wasAdded ? `The missing import '${imported}' appears in the latest git diff.` : "The terminal reported a missing module.",
930
+ error.sourceFile ? `Source file: ${error.sourceFile}${sourceChanged ? " (changed in this mission)" : ""}` : "No source file could be extracted from the terminal output.",
931
+ diffEvidence.changedFiles.length ? `Changed files: ${diffEvidence.changedFiles.join(", ")}` : "No changed files were detected."
932
+ ],
933
+ nextAction: wasAdded
934
+ ? `Ask the agent to verify whether '${imported}' exists, whether the path alias is configured, and to change only the import path or create the missing module.`
935
+ : "Ask the agent to locate the missing module and inspect package.json/tsconfig paths before writing more code.",
936
+ prompt: `Do not continue broad implementation. Diagnose this missing module first: ${error.raw}. Check package.json, tsconfig paths, and the latest git diff. Make the smallest change that resolves the import, then run the failing command again.`
937
+ });
938
+ }
939
+
940
+ if (tsErrors.length > 0) {
941
+ recommendations.push({
942
+ title: "Fix TypeScript errors against the changed files",
943
+ confidence: "medium",
944
+ evidence: [
945
+ `${tsErrors.length} TypeScript error(s) parsed from terminal output.`,
946
+ `Source files: ${tsErrors.map((error) => error.sourceFile).filter(Boolean).join(", ") || "unknown"}`,
947
+ diffEvidence.changedFiles.length ? `Changed files: ${diffEvidence.changedFiles.join(", ")}` : "No changed files were detected."
948
+ ],
949
+ nextAction: "Ask the agent to map each TS error to a changed file before editing anything else.",
950
+ prompt: `Focus only on these TypeScript errors: ${tsErrors.map((error) => error.raw).join(" | ")}. For each error, identify the file and the smallest fix. Do not refactor unrelated code.`
951
+ });
952
+ }
953
+
954
+ if (diffEvidence.changedFiles.length === 0 && output.exitCode !== 0) {
955
+ recommendations.push({
956
+ title: "Switch from implementation to diagnosis",
957
+ confidence: "high",
958
+ evidence: ["The command failed and produced no git diff artifact."],
959
+ nextAction: "Run an explorer/diagnostic pass before asking for implementation again.",
960
+ prompt: `Do not write code yet. Inspect the project and explain why this command failed: ${command.join(" ")}. Return the exact files or config values needed to fix it.`
961
+ });
962
+ }
963
+
964
+ if (preflight.scopeRisk === "high") {
965
+ recommendations.push({
966
+ title: "Reduce scope before another expensive run",
967
+ confidence: "medium",
968
+ evidence: preflight.scopeSignals.map((signal) => `Broad scope signal: ${signal}`),
969
+ nextAction: "Turn the mission into one vertical slice with one verification command.",
970
+ prompt: "Rewrite this mission as one deliverable that can be completed and verified in under 30 minutes. Include acceptance criteria and stop conditions."
971
+ });
972
+ }
973
+
974
+ if (recommendations.length === 0) {
975
+ recommendations.push({
976
+ title: "Continue with checkpointed verification",
977
+ confidence: "low",
978
+ evidence: ["No strong failure pattern was detected."],
979
+ nextAction: "Continue only with a clear verification command and stop if no artifact changes.",
980
+ prompt: "Continue the task, but after each change run the smallest relevant verification command and summarize the evidence."
981
+ });
982
+ }
983
+
984
+ return {
985
+ verdict: stuck.status === "stuck" ? "rescue_required" : stuck.status === "at_risk" ? "checkpoint_required" : "continue_with_monitoring",
986
+ recommendations
987
+ };
988
+ }
989
+
990
+ async function readMission(id) {
991
+ const file = path.join(MISSIONS_DIR, id, "mission.json");
992
+ return JSON.parse(await readFile(file, "utf8"));
993
+ }
994
+
995
+ async function readMissionSummaries() {
996
+ const ids = await listMissionIds();
997
+ const missions = await Promise.all(ids.map(async (id) => readMission(id).catch(() => null)));
998
+ return missions.filter(Boolean).map(summarizeMission).reverse();
999
+ }
1000
+
1001
+ async function readPlan(id) {
1002
+ const file = path.join(PLANS_DIR, id, "plan.json");
1003
+ return JSON.parse(await readFile(file, "utf8"));
1004
+ }
1005
+
1006
+ async function readPlans() {
1007
+ if (!existsSync(PLANS_DIR)) return [];
1008
+ const ids = (await readdir(PLANS_DIR)).sort();
1009
+ const plans = await Promise.all(ids.map(async (id) => readPlan(id).catch(() => null)));
1010
+ return plans.filter(Boolean).reverse();
1011
+ }
1012
+
1013
+ function summarizeMission(mission) {
1014
+ return {
1015
+ id: mission.id,
1016
+ label: mission.label,
1017
+ command: mission.command.join(" "),
1018
+ startedAt: mission.startedAt,
1019
+ status: mission.stuck.status,
1020
+ confidence: mission.stuck.confidence,
1021
+ exitCode: mission.exitCode,
1022
+ errorCount: mission.errors.length,
1023
+ changedFileCount: mission.diffEvidence.changedFiles.length,
1024
+ fuelBefore: mission.fuelBefore,
1025
+ fuelAfter: mission.fuelAfter,
1026
+ fuelUsedPercent: mission.fuelUsedPercent,
1027
+ primaryRecommendation: mission.rescue.recommendations[0]?.title ?? null
1028
+ };
1029
+ }
1030
+
1031
+ async function listMissionIds() {
1032
+ if (!existsSync(MISSIONS_DIR)) return [];
1033
+ const names = await readdir(MISSIONS_DIR);
1034
+ return names.sort();
1035
+ }
1036
+
1037
+ async function readFuel() {
1038
+ try {
1039
+ return JSON.parse(await readFile(FUEL_FILE, "utf8"));
1040
+ } catch {
1041
+ return { currentPercent: null, source: "unknown", confidence: "unknown", calibrations: [] };
1042
+ }
1043
+ }
1044
+
1045
+ async function dashboardStatus() {
1046
+ const fuel = await readFuel();
1047
+ const missions = await readMissionSummaries();
1048
+ const gateway = await readGatewaySummary();
1049
+ return {
1050
+ fuel,
1051
+ gateway,
1052
+ missionCount: missions.length,
1053
+ latest: missions[0] ?? null,
1054
+ counts: missions.reduce((acc, mission) => {
1055
+ acc[mission.status] = (acc[mission.status] ?? 0) + 1;
1056
+ return acc;
1057
+ }, {})
1058
+ };
1059
+ }
1060
+
1061
+ async function appendGatewayEvent(event) {
1062
+ await appendFile(GATEWAY_EVENTS_FILE, `${JSON.stringify(event)}\n`);
1063
+ }
1064
+
1065
+ async function readGatewayEvents() {
1066
+ const text = await readOptional(GATEWAY_EVENTS_FILE);
1067
+ if (!text) return [];
1068
+ return text.split("\n").filter(Boolean).map((line) => safeJson(line)).filter(Boolean);
1069
+ }
1070
+
1071
+ async function readGatewaySummary() {
1072
+ const events = await readGatewayEvents();
1073
+ const successful = events.filter((event) => event.status >= 200 && event.status < 300);
1074
+ const totalTokens = events.reduce((sum, event) => sum + Number(event.usage?.total_tokens ?? 0), 0);
1075
+ const estimatedCost = events.reduce((sum, event) => sum + Number(event.cost?.estimatedUsd ?? 0), 0);
1076
+ return {
1077
+ callCount: events.length,
1078
+ successfulCallCount: successful.length,
1079
+ totalTokens,
1080
+ estimatedCostUsd: Number(estimatedCost.toFixed(6)),
1081
+ truth: events.some((event) => event.truth === "provider_usage" || event.truth === "mock_provider_usage")
1082
+ ? "usage_plus_static_price_table"
1083
+ : "unknown",
1084
+ recent: events.slice(-20).reverse()
1085
+ };
1086
+ }
1087
+
1088
+ function readBudget() {
1089
+ const raw = process.env.AIM_DAILY_BUDGET_USD;
1090
+ if (raw === undefined || raw === "") return null;
1091
+ const value = Number(raw);
1092
+ return Number.isFinite(value) && value >= 0 ? value : null;
1093
+ }
1094
+
1095
+ function mockCompletion(requestBody, pathname = "/v1/chat/completions") {
1096
+ const content = "Mock response from Runcap gateway. This call was recorded with provider-like usage for demo and budget testing.";
1097
+ const promptText = JSON.stringify(requestBody.messages ?? requestBody.input ?? requestBody.prompt ?? "");
1098
+ const promptTokens = Math.max(1, Math.ceil(promptText.length / 4));
1099
+ const completionTokens = Math.max(12, Math.ceil(content.length / 4));
1100
+
1101
+ if (pathname.startsWith("/v1/messages")) {
1102
+ // Anthropic Messages API shape.
1103
+ return {
1104
+ id: `msg-mock-${Date.now()}`,
1105
+ type: "message",
1106
+ role: "assistant",
1107
+ model: requestBody.model ?? "claude-sonnet-4-6",
1108
+ content: [{ type: "text", text: content }],
1109
+ stop_reason: "end_turn",
1110
+ usage: {
1111
+ input_tokens: promptTokens,
1112
+ output_tokens: completionTokens,
1113
+ cache_read_input_tokens: 0
1114
+ }
1115
+ };
1116
+ }
1117
+
1118
+ return {
1119
+ id: `chatcmpl-mock-${Date.now()}`,
1120
+ object: "chat.completion",
1121
+ created: Math.floor(Date.now() / 1000),
1122
+ model: requestBody.model ?? "gpt-5.4-mini",
1123
+ choices: [
1124
+ {
1125
+ index: 0,
1126
+ message: { role: "assistant", content },
1127
+ finish_reason: "stop"
1128
+ }
1129
+ ],
1130
+ usage: {
1131
+ prompt_tokens: promptTokens,
1132
+ completion_tokens: completionTokens,
1133
+ total_tokens: promptTokens + completionTokens
1134
+ }
1135
+ };
1136
+ }
1137
+
1138
+ // Sourced multi-provider price table.
1139
+ // Sources: claude.com/pricing (Anthropic API) and developers.openai.com/api/docs/pricing.
1140
+ // Verified 2026-06-01. Prices are USD per 1,000,000 tokens.
1141
+ // cacheReadPerMillion = cost of a cached-read input token (Anthropic ~10% of input, OpenAI ~10% of input).
1142
+ // Batch APIs run at ~50% of standard rates for both providers.
1143
+ const PRICE_TABLE_SOURCE = "official_provider_pricing";
1144
+ const PRICE_TABLE_VERIFIED = "2026-06-01";
1145
+ const BATCH_DISCOUNT = 0.5;
1146
+
1147
+ const MODEL_PRICES = [
1148
+ // Anthropic (claude.com/pricing)
1149
+ { match: ["claude-opus", "opus-4"], inputPerMillion: 5, outputPerMillion: 25, cacheReadPerMillion: 0.5, provider: "anthropic" },
1150
+ { match: ["claude-sonnet", "sonnet-4"], inputPerMillion: 3, outputPerMillion: 15, cacheReadPerMillion: 0.3, provider: "anthropic" },
1151
+ { match: ["claude-haiku", "haiku-4"], inputPerMillion: 1, outputPerMillion: 5, cacheReadPerMillion: 0.1, provider: "anthropic" },
1152
+ // OpenAI (developers.openai.com/api/docs/pricing)
1153
+ { match: ["gpt-5.5"], inputPerMillion: 5, outputPerMillion: 30, cacheReadPerMillion: 0.5, provider: "openai" },
1154
+ { match: ["gpt-5.4-nano"], inputPerMillion: 0.2, outputPerMillion: 1.25, cacheReadPerMillion: 0.02, provider: "openai" },
1155
+ { match: ["gpt-5.4-mini", "gpt-5-mini"], inputPerMillion: 0.75, outputPerMillion: 4.5, cacheReadPerMillion: 0.075, provider: "openai" },
1156
+ { match: ["gpt-5.4", "gpt-5"], inputPerMillion: 2.5, outputPerMillion: 15, cacheReadPerMillion: 0.25, provider: "openai" },
1157
+ // Legacy OpenAI (kept for back-compat with older agents)
1158
+ { match: ["gpt-4.1-mini"], inputPerMillion: 0.4, outputPerMillion: 1.6, cacheReadPerMillion: 0.1, provider: "openai" },
1159
+ { match: ["gpt-4.1"], inputPerMillion: 2, outputPerMillion: 8, cacheReadPerMillion: 0.5, provider: "openai" },
1160
+ { match: ["gpt-4o-mini"], inputPerMillion: 0.15, outputPerMillion: 0.6, cacheReadPerMillion: 0.075, provider: "openai" },
1161
+ { match: ["gpt-4o"], inputPerMillion: 2.5, outputPerMillion: 10, cacheReadPerMillion: 1.25, provider: "openai" }
1162
+ ];
1163
+
1164
+ function estimateApiCost(usage, model) {
1165
+ if (!usage) return null;
1166
+ const pricing = modelPricing(model);
1167
+ if (!pricing) {
1168
+ return {
1169
+ estimatedUsd: null,
1170
+ truth: "unknown_price",
1171
+ note: `No verified price entry for model "${model}". Cost is honestly unknown rather than guessed.`
1172
+ };
1173
+ }
1174
+ // Token fields differ by provider:
1175
+ // OpenAI: prompt_tokens / completion_tokens (+ prompt_tokens_details.cached_tokens)
1176
+ // Anthropic: input_tokens / output_tokens (+ cache_read_input_tokens)
1177
+ const cachedInput = Number(
1178
+ usage.cache_read_input_tokens ??
1179
+ usage.prompt_tokens_details?.cached_tokens ??
1180
+ 0
1181
+ );
1182
+ const rawInput = Number(usage.prompt_tokens ?? usage.input_tokens ?? 0);
1183
+ const freshInput = Math.max(0, rawInput - cachedInput);
1184
+ const output = Number(usage.completion_tokens ?? usage.output_tokens ?? 0);
1185
+
1186
+ const inputRate = pricing.batch ? pricing.inputPerMillion * BATCH_DISCOUNT : pricing.inputPerMillion;
1187
+ const outputRate = pricing.batch ? pricing.outputPerMillion * BATCH_DISCOUNT : pricing.outputPerMillion;
1188
+ const cacheRate = pricing.batch ? pricing.cacheReadPerMillion * BATCH_DISCOUNT : pricing.cacheReadPerMillion;
1189
+
1190
+ const estimatedUsd =
1191
+ (freshInput / 1_000_000) * inputRate +
1192
+ (cachedInput / 1_000_000) * cacheRate +
1193
+ (output / 1_000_000) * outputRate;
1194
+
1195
+ return {
1196
+ estimatedUsd: Number(estimatedUsd.toFixed(6)),
1197
+ truth: "calculated_from_sourced_price_table",
1198
+ pricing: {
1199
+ ...pricing,
1200
+ source: PRICE_TABLE_SOURCE,
1201
+ verified: PRICE_TABLE_VERIFIED,
1202
+ cachedInputTokens: cachedInput
1203
+ }
1204
+ };
1205
+ }
1206
+
1207
+ function modelPricing(model = "") {
1208
+ const name = String(model).toLowerCase();
1209
+ const batch = name.includes("batch");
1210
+ for (const entry of MODEL_PRICES) {
1211
+ if (entry.match.some((m) => name.includes(m))) {
1212
+ return {
1213
+ inputPerMillion: entry.inputPerMillion,
1214
+ outputPerMillion: entry.outputPerMillion,
1215
+ cacheReadPerMillion: entry.cacheReadPerMillion,
1216
+ provider: entry.provider,
1217
+ batch,
1218
+ source: PRICE_TABLE_SOURCE,
1219
+ verified: PRICE_TABLE_VERIFIED
1220
+ };
1221
+ }
1222
+ }
1223
+ return null;
1224
+ }
1225
+
1226
+ function readRequestBody(request) {
1227
+ return new Promise((resolve, reject) => {
1228
+ let body = "";
1229
+ request.on("data", (chunk) => { body += chunk.toString(); });
1230
+ request.on("end", () => resolve(body));
1231
+ request.on("error", reject);
1232
+ });
1233
+ }
1234
+
1235
+ function shortSummary(mission) {
1236
+ return [
1237
+ "",
1238
+ `Runcap mission: ${mission.id}`,
1239
+ `Status: ${mission.stuck.status} (${mission.stuck.confidence} confidence)`,
1240
+ `Exit code: ${mission.exitCode}`,
1241
+ `Changed files: ${mission.diffEvidence.changedFiles.length}`,
1242
+ `Parsed errors: ${mission.errors.length}`,
1243
+ `Primary recommendation: ${mission.rescue.recommendations[0]?.title}`,
1244
+ `Report: ${path.join(MISSIONS_DIR, mission.id, "report.md")}`,
1245
+ `HTML: ${path.join(MISSIONS_DIR, mission.id, "report.html")}`,
1246
+ ""
1247
+ ].join("\n");
1248
+ }
1249
+
1250
+ function formatPreflight({ command, preflight, fuel }) {
1251
+ const fuelLine = fuel.currentPercent === null
1252
+ ? "Fuel: unknown. Set it with `aim fuel set <percent>` if using subscriptions."
1253
+ : `Fuel: ${fuel.currentPercent}% (${fuel.confidence} confidence)`;
1254
+ const scopeAdvice = preflight.scopeRisk === "high"
1255
+ ? "Do not launch as one broad mission. Split into one vertical slice with a verification command."
1256
+ : preflight.scopeRisk === "medium"
1257
+ ? "Launch with a strict checkpoint and stop condition."
1258
+ : "Safe to run as a first checkpointed mission.";
1259
+ return [
1260
+ `Preflight: ${command.join(" ")}`,
1261
+ `Scope risk: ${preflight.scopeRisk}`,
1262
+ fuelLine,
1263
+ `Repo: package.json=${preflight.repoSignals.hasPackageJson}, tsconfig=${preflight.repoSignals.hasTsconfig}, verification scripts=${preflight.repoSignals.hasVerificationScripts}`,
1264
+ `Recommendation: ${scopeAdvice}`,
1265
+ "",
1266
+ "Suggested mission contract:",
1267
+ "- Define one deliverable.",
1268
+ "- Define one verification command.",
1269
+ "- Stop if no artifact changes after the first failed loop.",
1270
+ "- Require evidence before calling the task done."
1271
+ ].join("\n");
1272
+ }
1273
+
1274
+ function formatReport(mission) {
1275
+ const fuel = mission.fuelUsedPercent === null
1276
+ ? `Fuel: before ${mission.fuelBefore ?? "unknown"}%, after unknown. Calibrate with \`aim fuel calibrate ${mission.id} <after-percent>\`.`
1277
+ : `Fuel used: ${mission.fuelUsedPercent}% (source: manual calibration, confidence: high).`;
1278
+ const errorLines = mission.errors.length
1279
+ ? mission.errors.map((error) => `- ${error.kind} (${error.confidence}): ${error.raw}`).join("\n")
1280
+ : "- none parsed";
1281
+ const signalLines = mission.stuck.signals.length
1282
+ ? mission.stuck.signals.map((item) => `- ${item.signal}: ${item.evidence}`).join("\n")
1283
+ : "- no strong stuck signals";
1284
+ const recommendationLines = mission.rescue.recommendations.map((rec, index) => [
1285
+ `${index + 1}. ${rec.title} (${rec.confidence})`,
1286
+ ` Evidence: ${rec.evidence.join(" | ")}`,
1287
+ ` Next action: ${rec.nextAction}`,
1288
+ ` Rescue prompt: ${rec.prompt}`
1289
+ ].join("\n")).join("\n\n");
1290
+ return `# AI Mission Report
1291
+
1292
+ Mission: ${mission.id}
1293
+ Command: \`${mission.command.join(" ")}\`
1294
+ Status: ${mission.stuck.status}
1295
+ Confidence: ${mission.stuck.confidence}
1296
+ Exit code: ${mission.exitCode}
1297
+ Duration: ${Math.round(mission.durationMs / 1000)}s
1298
+ ${fuel}
1299
+
1300
+ ## Evidence
1301
+ - Changed files: ${mission.diffEvidence.changedFiles.length ? mission.diffEvidence.changedFiles.join(", ") : "none"}
1302
+ - Diff bytes: ${mission.diffEvidence.diffBytes}
1303
+ - Added imports: ${mission.diffEvidence.addedImports.length ? mission.diffEvidence.addedImports.join(", ") : "none"}
1304
+ - Scope risk: ${mission.preflight.scopeRisk}
1305
+
1306
+ ## Parsed Errors
1307
+ ${errorLines}
1308
+
1309
+ ## Stuck Signals
1310
+ ${signalLines}
1311
+
1312
+ ## Rescue Recommendations
1313
+ ${recommendationLines}
1314
+
1315
+ ## Truth Labels
1316
+ - Cost/Fuel: ${mission.fuelUsedPercent === null ? "estimated/unknown until calibrated" : "observed from manual before/after calibration"}
1317
+ - Progress proof: observed from git diff and command result
1318
+ - Error parsing: calculated from terminal logs
1319
+ - Rescue advice: generated from evidence packet, not from hidden assumptions
1320
+ `;
1321
+ }
1322
+
1323
+ function formatPlan(plan) {
1324
+ const missionLines = plan.missions.map((mission, index) => [
1325
+ `${index + 1}. ${mission.name}`,
1326
+ ` Model tier: ${mission.modelTier}`,
1327
+ ` Instruction: ${mission.instruction}`,
1328
+ ` Proof: ${mission.proof}`
1329
+ ].join("\n")).join("\n\n");
1330
+ const commandLines = plan.commandTemplates.map((template) => [
1331
+ `### ${template.mission}`,
1332
+ "```bash",
1333
+ template.command,
1334
+ "```"
1335
+ ].join("\n")).join("\n\n");
1336
+ return `# AI Work Plan
1337
+
1338
+ Plan: ${plan.id}
1339
+ Goal: ${plan.goal}
1340
+ Task type: ${plan.taskType}
1341
+ Created: ${plan.createdAt}
1342
+
1343
+ ## Budget Decision
1344
+ - Risk: ${plan.budget.risk}
1345
+ - Expected waste reduction: ${plan.budget.expectedWasteReduction}
1346
+ - Reason: ${plan.budget.reason}
1347
+
1348
+ ## Model Routing
1349
+ - Planning: ${plan.routing.planningTier}
1350
+ - Execution: ${plan.routing.executionTier}
1351
+ - Escalation: ${plan.routing.escalationRule}
1352
+
1353
+ ## Quality Proof
1354
+ - Risk: ${plan.quality.risk}
1355
+ - Proof: ${plan.quality.proof}
1356
+
1357
+ ## Missions
1358
+ ${missionLines}
1359
+
1360
+ ## Stop Rule
1361
+ ${plan.stopRule}
1362
+
1363
+ ## Command Templates
1364
+ ${commandLines}
1365
+
1366
+ ## Truth Labels
1367
+ - Planner source: ${plan.truth.source}
1368
+ - Cost precision: ${plan.truth.costPrecision}
1369
+ - Quality precision: ${plan.truth.qualityPrecision}
1370
+ `;
1371
+ }
1372
+
1373
+ function formatHtmlReport(mission) {
1374
+ const firstRecommendation = mission.rescue.recommendations[0] ?? {};
1375
+ const firstError = mission.errors[0];
1376
+ const statusLabel = mission.stuck.status === "stuck"
1377
+ ? "Needs rescue"
1378
+ : mission.stuck.status === "at_risk"
1379
+ ? "Needs attention"
1380
+ : "Moving";
1381
+ const happened = firstError
1382
+ ? firstError.raw
1383
+ : mission.stuck.signals[0]?.evidence ?? "No critical failure pattern was parsed.";
1384
+ const cause = firstError?.kind === "command_not_found"
1385
+ ? `The command '${firstError.primary ?? mission.command[0]}' was not found in this terminal environment.`
1386
+ : firstError?.sourceFile
1387
+ ? `The failure points to ${firstError.sourceFile}.`
1388
+ : mission.stuck.status === "progressing"
1389
+ ? "No immediate blocker detected."
1390
+ : "The run failed without enough artifact evidence to prove progress.";
1391
+ const changed = mission.diffEvidence.changedFiles.length
1392
+ ? mission.diffEvidence.changedFiles.join(", ")
1393
+ : "No file changes were detected during this mission.";
1394
+ return `<!doctype html>
1395
+ <html lang="en">
1396
+ <head>
1397
+ <meta charset="utf-8">
1398
+ <meta name="viewport" content="width=device-width, initial-scale=1">
1399
+ <title>AI Mission Report - ${escapeHtml(mission.label ?? mission.id)}</title>
1400
+ <style>
1401
+ :root { color-scheme: dark; --bg:#0f1115; --panel:#181c22; --soft:#202630; --line:#303946; --text:#f5f7fb; --muted:#a7b0bd; --accent:#70d6ff; --warn:#ffd166; --bad:#ff6b6b; --good:#55d78a; }
1402
+ * { box-sizing:border-box; }
1403
+ body { margin:0; font-family:Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background:linear-gradient(145deg,#141b24,#0f1115 45%); color:var(--text); }
1404
+ main { max-width:980px; margin:0 auto; padding:36px 20px 56px; }
1405
+ .notice, details { background:rgba(24,28,34,.96); border:1px solid var(--line); border-radius:10px; padding:22px; }
1406
+ .notice { border-color:rgba(112,214,255,.5); box-shadow:0 20px 70px rgba(0,0,0,.28); }
1407
+ h1 { margin:0 0 8px; font-size:30px; letter-spacing:0; }
1408
+ h2 { margin:24px 0 10px; font-size:18px; }
1409
+ p { line-height:1.58; color:var(--muted); }
1410
+ .status { display:inline-block; border:1px solid var(--line); border-radius:999px; padding:5px 10px; margin:0 8px 8px 0; color:var(--muted); font-size:13px; }
1411
+ .stuck { color:var(--bad); border-color:rgba(255,107,107,.55); }
1412
+ .at_risk { color:var(--warn); border-color:rgba(255,209,102,.55); }
1413
+ .progressing { color:var(--good); border-color:rgba(85,215,138,.55); }
1414
+ .grid { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:14px; margin:22px 0; }
1415
+ .card { background:var(--soft); border:1px solid var(--line); border-radius:8px; padding:16px; }
1416
+ .card strong { display:block; margin-bottom:8px; }
1417
+ .action { background:#10151c; border:1px solid rgba(112,214,255,.55); border-radius:8px; padding:18px; margin-top:18px; }
1418
+ .action-title { color:var(--accent); font-size:13px; font-weight:800; text-transform:uppercase; margin-bottom:8px; }
1419
+ pre { white-space:pre-wrap; background:#0b0d10; border:1px solid var(--line); border-radius:8px; padding:14px; overflow:auto; line-height:1.55; color:var(--text); }
1420
+ details { margin-top:16px; }
1421
+ summary { cursor:pointer; color:var(--muted); font-weight:700; }
1422
+ code { color:var(--accent); }
1423
+ @media (max-width:760px) { .grid { grid-template-columns:1fr; } h1 { font-size:24px; } }
1424
+ </style>
1425
+ </head>
1426
+ <body>
1427
+ <main>
1428
+ <div class="notice">
1429
+ <span class="status ${escapeHtml(mission.stuck.status)}">${escapeHtml(statusLabel)}</span>
1430
+ <span class="status">confidence: ${escapeHtml(mission.stuck.confidence)}</span>
1431
+ <span class="status">exit: ${escapeHtml(String(mission.exitCode))}</span>
1432
+ <h1>${escapeHtml(mission.label ?? mission.id)}</h1>
1433
+ <p><code>${escapeHtml(mission.command.join(" "))}</code></p>
1434
+ <div class="grid">
1435
+ <div class="card"><strong>What happened</strong><p>${escapeHtml(happened)}</p></div>
1436
+ <div class="card"><strong>Likely cause</strong><p>${escapeHtml(cause)}</p></div>
1437
+ <div class="card"><strong>What changed</strong><p>${escapeHtml(changed)}</p></div>
1438
+ </div>
1439
+ <div class="action">
1440
+ <div class="action-title">Recommended next step</div>
1441
+ <p>${escapeHtml(firstRecommendation.nextAction ?? "Continue only with a clear verification command.")}</p>
1442
+ <pre>${escapeHtml(firstRecommendation.prompt ?? "")}</pre>
1443
+ </div>
1444
+ <details>
1445
+ <summary>Technical evidence</summary>
1446
+ <pre>${escapeHtml(JSON.stringify({
1447
+ missionId: mission.id,
1448
+ errors: mission.errors,
1449
+ stuckSignals: mission.stuck.signals,
1450
+ diffEvidence: mission.diffEvidence,
1451
+ truthLabels: {
1452
+ fuel: mission.fuelUsedPercent === null ? "unknown_until_calibrated" : "manual_calibration",
1453
+ progress: "observed_from_git_diff_and_command_result",
1454
+ rescue: "generated_from_evidence_packet"
1455
+ }
1456
+ }, null, 2))}</pre>
1457
+ </details>
1458
+ </div>
1459
+ </main>
1460
+ </body>
1461
+ </html>`;
1462
+ }
1463
+
1464
+ function escapeHtml(value) {
1465
+ return String(value ?? "").replace(/[&<>"']/g, (char) => ({
1466
+ "&": "&amp;",
1467
+ "<": "&lt;",
1468
+ ">": "&gt;",
1469
+ '"': "&quot;",
1470
+ "'": "&#39;"
1471
+ })[char]);
1472
+ }
1473
+
1474
+ function renderDashboardHtml() {
1475
+ return `<!doctype html>
1476
+ <html lang="en">
1477
+ <head>
1478
+ <meta charset="utf-8">
1479
+ <meta name="viewport" content="width=device-width, initial-scale=1">
1480
+ <title>Runcap</title>
1481
+ <style>
1482
+ :root { color-scheme: dark; --bg:#080d13; --panel:#111821; --panel2:#17212d; --soft:#202b38; --line:#2c3948; --text:#f8fafc; --muted:#a8b3c2; --good:#52d789; --warn:#ffd166; --bad:#ff6868; --accent:#63d5ff; --violet:#b8a0ff; }
1483
+ * { box-sizing: border-box; }
1484
+ body { margin:0; min-height:100vh; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background:var(--bg); color:var(--text); }
1485
+ body:before { content:""; position:fixed; inset:0; pointer-events:none; background:radial-gradient(circle at 20% 0%, rgba(99,213,255,0.12), transparent 32%), radial-gradient(circle at 90% 8%, rgba(184,160,255,0.1), transparent 34%), linear-gradient(180deg, rgba(255,255,255,0.03), transparent 260px); }
1486
+ button, textarea, select, input { font:inherit; }
1487
+ .app { position:relative; display:grid; grid-template-columns: 320px minmax(0,1fr); min-height:100vh; }
1488
+ aside { border-right:1px solid var(--line); background:rgba(8,13,19,0.82); padding:22px; overflow:auto; }
1489
+ main { padding:28px; overflow:auto; }
1490
+ h1 { margin:0; font-size:24px; letter-spacing:0; }
1491
+ h2 { margin:0; font-size:38px; line-height:1.06; letter-spacing:0; }
1492
+ h3 { margin:0; font-size:15px; }
1493
+ p { margin:0; }
1494
+ .muted { color:var(--muted); }
1495
+ .brand { display:flex; align-items:center; gap:12px; margin-bottom:22px; }
1496
+ .mark { width:42px; height:42px; border-radius:8px; display:grid; place-items:center; color:#061017; font-weight:900; background:linear-gradient(135deg, var(--accent), var(--good)); }
1497
+ .tagline { color:var(--muted); font-size:13px; margin-top:4px; line-height:1.35; }
1498
+ .nav { display:grid; gap:8px; margin:18px 0 22px; }
1499
+ .nav button { text-align:left; border:1px solid var(--line); background:rgba(17,24,33,0.82); color:var(--text); border-radius:8px; padding:12px; cursor:pointer; }
1500
+ .nav button.active, .nav button:hover { border-color:var(--accent); background:#152434; }
1501
+ .nav strong { display:block; }
1502
+ .nav span { display:block; color:var(--muted); font-size:12px; margin-top:3px; }
1503
+ .side-title { margin:18px 0 10px; color:var(--muted); font-size:12px; font-weight:800; text-transform:uppercase; }
1504
+ .summary { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:10px; }
1505
+ .mini, .panel, .mission, .metric, .step, .plan-card, details { border:1px solid var(--line); background:rgba(17,24,33,0.9); border-radius:8px; }
1506
+ .mini { padding:12px; min-height:76px; }
1507
+ .mini strong { display:block; font-size:22px; }
1508
+ .mini span { color:var(--muted); font-size:12px; }
1509
+ .mission { width:100%; color:inherit; text-align:left; cursor:pointer; margin:0 0 10px; padding:12px; }
1510
+ .mission:hover, .mission.active { border-color:var(--accent); }
1511
+ .mission.active { background:#142232; box-shadow: inset 3px 0 0 var(--accent); }
1512
+ .mission-head { display:flex; align-items:center; justify-content:space-between; gap:8px; margin-bottom:7px; }
1513
+ .mission-name { font-weight:800; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
1514
+ .mission-line { color:var(--muted); font-size:13px; line-height:1.35; }
1515
+ .status { font-size:12px; border:1px solid var(--line); padding:4px 8px; border-radius:999px; white-space:nowrap; }
1516
+ .stuck { color:var(--bad); border-color:rgba(255,104,104,0.5); }
1517
+ .at_risk { color:var(--warn); border-color:rgba(255,209,102,0.5); }
1518
+ .progressing { color:var(--good); border-color:rgba(82,215,137,0.5); }
1519
+ .hero { display:grid; grid-template-columns:minmax(0,1.2fr) minmax(360px,0.8fr); gap:18px; margin-bottom:18px; }
1520
+ .panel { padding:24px; }
1521
+ .hero-copy { color:var(--muted); font-size:17px; line-height:1.55; margin-top:14px; max-width:880px; }
1522
+ .badge-row { display:flex; flex-wrap:wrap; gap:8px; margin-top:18px; }
1523
+ .badge { display:inline-flex; align-items:center; gap:6px; border:1px solid var(--line); color:var(--muted); border-radius:999px; padding:6px 10px; font-size:12px; }
1524
+ .badge.good { color:var(--good); border-color:rgba(82,215,137,0.48); }
1525
+ .badge.warn { color:var(--warn); border-color:rgba(255,209,102,0.48); }
1526
+ .badge.bad { color:var(--bad); border-color:rgba(255,104,104,0.48); }
1527
+ .metrics { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:10px; margin-top:20px; }
1528
+ .metric { padding:14px; }
1529
+ .metric strong { display:block; font-size:24px; line-height:1.1; }
1530
+ .metric span { display:block; color:var(--muted); font-size:12px; margin-top:6px; }
1531
+ .planner textarea { width:100%; min-height:128px; resize:vertical; background:#0a1017; color:var(--text); border:1px solid var(--line); border-radius:8px; padding:13px; line-height:1.45; }
1532
+ .field-row { display:grid; grid-template-columns:1fr 1fr; gap:10px; margin-top:10px; }
1533
+ select, input { width:100%; background:#0a1017; color:var(--text); border:1px solid var(--line); border-radius:8px; padding:10px; }
1534
+ label { display:block; color:var(--muted); font-size:12px; font-weight:750; margin:0 0 7px; }
1535
+ .primary, .ghost { border-radius:8px; padding:10px 13px; cursor:pointer; font-weight:800; }
1536
+ .primary { border:1px solid rgba(99,213,255,0.7); color:#061017; background:linear-gradient(135deg, var(--accent), var(--good)); }
1537
+ .ghost { border:1px solid var(--line); color:var(--text); background:#0a1017; }
1538
+ .actions { display:flex; gap:10px; flex-wrap:wrap; margin-top:12px; }
1539
+ .plan-grid { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:12px; margin:18px 0; }
1540
+ .plan-card { padding:16px; }
1541
+ .plan-card strong { display:block; margin-bottom:8px; }
1542
+ .plan-card p, .step p { color:var(--muted); line-height:1.48; }
1543
+ .timeline { display:grid; gap:10px; margin-top:14px; }
1544
+ .step { padding:15px; display:grid; grid-template-columns:34px minmax(0,1fr); gap:12px; align-items:start; }
1545
+ .num { width:28px; height:28px; border-radius:8px; display:grid; place-items:center; background:#0a1017; border:1px solid var(--line); color:var(--accent); font-weight:900; }
1546
+ .rescue { border-color:rgba(99,213,255,0.55); background:#172433; }
1547
+ .decision { color:var(--warn); font-weight:900; font-size:18px; margin:8px 0 0; }
1548
+ pre { white-space:pre-wrap; margin:12px 0 0; background:#070b10; border:1px solid var(--line); border-radius:8px; padding:13px; line-height:1.5; overflow:auto; }
1549
+ details { padding:14px 16px; margin-top:14px; }
1550
+ summary { cursor:pointer; color:var(--muted); font-weight:800; }
1551
+ .hidden { display:none; }
1552
+ .empty { padding:40px; text-align:left; }
1553
+ .copy { margin-top:10px; }
1554
+ @media (max-width: 1180px) { .app { grid-template-columns:1fr; } aside { border-right:0; border-bottom:1px solid var(--line); } .hero, .plan-grid, .metrics { grid-template-columns:1fr; } .field-row { grid-template-columns:1fr; } }
1555
+ </style>
1556
+ </head>
1557
+ <body>
1558
+ <div class="app">
1559
+ <aside>
1560
+ <div class="brand">
1561
+ <div class="mark">AI</div>
1562
+ <div>
1563
+ <h1>Runcap</h1>
1564
+ <div class="tagline">Plan AI work. Route models. Prove progress. Stop waste.</div>
1565
+ </div>
1566
+ </div>
1567
+ <div class="nav">
1568
+ <button id="nav-plan" class="active" onclick="setView('plan')"><strong>New AI Mission</strong><span>Estimate, split, route, then run</span></button>
1569
+ <button id="nav-monitor" onclick="setView('monitor')"><strong>Active Work</strong><span>Rescue stuck agents with evidence</span></button>
1570
+ </div>
1571
+ <div class="side-title">AI budget signal</div>
1572
+ <div class="mission">
1573
+ <div class="mission-line" id="fuel">Fuel: loading...</div>
1574
+ <div class="mission-line" id="truth">Gateway truth: loading...</div>
1575
+ </div>
1576
+ <div class="summary">
1577
+ <div class="mini"><strong id="total">0</strong><span>checks</span></div>
1578
+ <div class="mini"><strong id="needs">0</strong><span>need attention</span></div>
1579
+ <div class="mini"><strong id="tokens">0</strong><span>API tokens</span></div>
1580
+ <div class="mini"><strong id="cost">$0</strong><span>API estimate</span></div>
1581
+ </div>
1582
+ <div class="side-title">Saved plans</div>
1583
+ <div id="plans"></div>
1584
+ <div class="side-title">Recent agent checks</div>
1585
+ <div id="missions"></div>
1586
+ </aside>
1587
+ <main>
1588
+ <section id="plan-view"></section>
1589
+ <section id="monitor-view" class="hidden"></section>
1590
+ </main>
1591
+ </div>
1592
+ <script>
1593
+ const state = { selected: null, selectedPlan: null, missions: [], plans: [], view: "plan", plannerRendered: false };
1594
+ const esc = (value) => String(value ?? "").replace(/[&<>"']/g, (c) => ({ "&":"&amp;", "<":"&lt;", ">":"&gt;", '"':"&quot;", "'":"&#39;" }[c]));
1595
+ async function load() {
1596
+ const [status, missions, plans] = await Promise.all([
1597
+ fetch("/api/status").then((r) => r.json()),
1598
+ fetch("/api/missions").then((r) => r.json()),
1599
+ fetch("/api/plans").then((r) => r.json())
1600
+ ]);
1601
+ state.missions = missions;
1602
+ state.plans = plans;
1603
+ document.getElementById("fuel").textContent = status.fuel.currentPercent === null ? "Fuel: unknown" : "Fuel: " + status.fuel.currentPercent + "%";
1604
+ document.getElementById("truth").textContent = "Gateway truth: " + status.gateway.truth;
1605
+ document.getElementById("total").textContent = status.missionCount;
1606
+ document.getElementById("needs").textContent = (status.counts.stuck ?? 0) + (status.counts.at_risk ?? 0);
1607
+ document.getElementById("tokens").textContent = status.gateway.totalTokens;
1608
+ document.getElementById("cost").textContent = "$" + status.gateway.estimatedCostUsd;
1609
+ renderList();
1610
+ renderPlans();
1611
+ if (!state.plannerRendered) renderPlanner(status);
1612
+ if (!state.selected && missions[0]) showMission(missions[0].id, false);
1613
+ if (!missions[0]) renderEmptyMonitor();
1614
+ }
1615
+ function setView(view) {
1616
+ state.view = view;
1617
+ document.getElementById("plan-view").classList.toggle("hidden", view !== "plan");
1618
+ document.getElementById("monitor-view").classList.toggle("hidden", view !== "monitor");
1619
+ document.getElementById("nav-plan").classList.toggle("active", view === "plan");
1620
+ document.getElementById("nav-monitor").classList.toggle("active", view === "monitor");
1621
+ }
1622
+ function renderList() {
1623
+ document.getElementById("missions").innerHTML = state.missions.map((m) =>
1624
+ '<button class="mission ' + (m.id === state.selected ? 'active' : '') + '" onclick="showMission(\\'' + esc(m.id) + '\\')">' +
1625
+ '<div class="mission-head"><span class="mission-name">' + esc(m.label || m.id.slice(0, 18)) + '</span><span class="status ' + esc(m.status) + '">' + labelStatus(m.status) + '</span></div>' +
1626
+ '<div class="mission-line">' + esc(shortCommand(m.command)) + '</div>' +
1627
+ '<div class="mission-line">' + summaryLine(m) + '</div>' +
1628
+ '</button>'
1629
+ ).join("");
1630
+ }
1631
+ function renderPlans() {
1632
+ document.getElementById("plans").innerHTML = state.plans.slice(0, 6).map((plan) =>
1633
+ '<button class="mission ' + (plan.id === state.selectedPlan ? 'active' : '') + '" onclick="showPlan(\\'' + esc(plan.id) + '\\')">' +
1634
+ '<div class="mission-head"><span class="mission-name">' + esc(plan.goal || plan.id.slice(0, 18)) + '</span><span class="status at_risk">' + esc(plan.budget?.risk || "plan") + '</span></div>' +
1635
+ '<div class="mission-line">saving: ' + esc(plan.budget?.expectedWasteReduction || "unknown") + '</div>' +
1636
+ '<div class="mission-line">' + esc(plan.routing?.planningTier || "routing unknown") + '</div>' +
1637
+ '</button>'
1638
+ ).join("");
1639
+ }
1640
+ function renderPlanner(status) {
1641
+ state.plannerRendered = true;
1642
+ const fuel = status.fuel.currentPercent === null ? 24 : Number(status.fuel.currentPercent);
1643
+ document.getElementById("plan-view").innerHTML =
1644
+ '<div class="hero">' +
1645
+ '<div class="panel">' +
1646
+ '<h2>Turn one expensive AI request into a managed plan.</h2>' +
1647
+ '<p class="hero-copy">Describe the outcome you want. The manager estimates budget risk, splits the work into verifiable missions, recommends model tiers, and defines stop rules before credits are burned.</p>' +
1648
+ '<div class="badge-row"><span class="badge good">Target: same or better result</span><span class="badge warn">Spend goal: 30-70% less waste</span><span class="badge">Fuel now: ' + esc(fuel) + '%</span></div>' +
1649
+ '<div class="metrics"><div class="metric"><strong>Plan</strong><span>before spending</span></div><div class="metric"><strong>Route</strong><span>right model per task</span></div><div class="metric"><strong>Prove</strong><span>with output evidence</span></div><div class="metric"><strong>Learn</strong><span>from every run</span></div></div>' +
1650
+ '</div>' +
1651
+ '<div class="panel planner">' +
1652
+ '<h3>Mission Planner</h3>' +
1653
+ '<label for="task-input">What do you want AI to achieve?</label>' +
1654
+ '<textarea id="task-input" placeholder="Example: build a mobile app MVP, create a video campaign, automate invoice processing, fix my React auth flow...">Build a mobile app MVP with login, database, dashboard and deployment</textarea>' +
1655
+ '<div class="field-row"><div><label for="fuel-input">Available weekly fuel %</label><input id="fuel-input" type="number" min="0" max="100" value="' + esc(fuel) + '"></div><div><label for="quality-input">Quality target</label><select id="quality-input"><option value="high">High quality</option><option value="balanced">Balanced</option><option value="cheap">Cheapest acceptable</option></select></div></div>' +
1656
+ '<div class="actions"><button class="primary" onclick="planTask()">Create managed plan</button><button class="ghost" onclick="copyPlan()">Copy plan</button></div>' +
1657
+ '</div>' +
1658
+ '</div>' +
1659
+ '<div id="planner-result"><div class="panel"><h3>Ready when you are</h3><p class="hero-copy">Create a managed plan to save it locally, generate mission steps, and get copyable commands for agent runs.</p></div></div>';
1660
+ }
1661
+ function renderPlan(plan) {
1662
+ const result = document.getElementById("planner-result");
1663
+ if (!result) return;
1664
+ result.innerHTML =
1665
+ '<div class="plan-grid">' +
1666
+ '<div class="plan-card"><strong>Budget decision</strong><p>Risk: <b>' + esc(plan.budget.risk) + '</b>. Expected waste reduction: <b>' + esc(plan.budget.expectedWasteReduction) + '</b>. ' + esc(plan.budget.reason) + '</p></div>' +
1667
+ '<div class="plan-card"><strong>Model routing</strong><p>Planning: <b>' + esc(plan.routing.planningTier) + '</b>. Execution: <b>' + esc(plan.routing.executionTier) + '</b></p></div>' +
1668
+ '<div class="plan-card"><strong>Quality proof</strong><p>' + esc(plan.quality.proof) + '</p></div>' +
1669
+ '</div>' +
1670
+ '<div class="timeline">' +
1671
+ plan.missions.map((mission, index) => '<div class="step"><div class="num">' + (index + 1) + '</div><div><strong>' + esc(mission.name) + '</strong><p>' + esc(mission.instruction) + '</p><p class="muted">Proof: ' + esc(mission.proof) + '</p></div></div>').join("") +
1672
+ '<div class="step"><div class="num">!</div><div><strong>Stop rule</strong><p>' + esc(plan.stopRule) + '</p></div></div>' +
1673
+ '</div>' +
1674
+ '<details open><summary>Copyable agent commands</summary><pre>' + esc(plan.commandTemplates.map((item) => item.command).join("\\n\\n")) + '</pre></details>' +
1675
+ '<details><summary>Plan truth labels</summary><pre>' + esc(JSON.stringify(plan.truth, null, 2)) + '</pre></details>';
1676
+ window.lastPlanText = "Runcap plan\\nPlan: " + plan.id + "\\nGoal: " + plan.goal + "\\nBudget risk: " + plan.budget.risk + "\\nExpected waste reduction: " + plan.budget.expectedWasteReduction + "\\nPlanning model: " + plan.routing.planningTier + "\\nExecution model: " + plan.routing.executionTier + "\\nProof: " + plan.quality.proof + "\\nStop rule: " + plan.stopRule + "\\n\\nCommands:\\n" + plan.commandTemplates.map((item) => item.command).join("\\n\\n");
1677
+ }
1678
+ async function showPlan(id) {
1679
+ state.selectedPlan = id;
1680
+ setView("plan");
1681
+ renderPlans();
1682
+ const plan = await fetch("/api/plans/" + encodeURIComponent(id)).then((r) => r.json());
1683
+ const input = document.getElementById("task-input");
1684
+ if (input) input.value = plan.goal;
1685
+ renderPlan(plan);
1686
+ }
1687
+ async function showMission(id, activate = true) {
1688
+ state.selected = id;
1689
+ if (activate) setView("monitor");
1690
+ renderList();
1691
+ const m = await fetch("/api/missions/" + encodeURIComponent(id)).then((r) => r.json());
1692
+ const d = diagnose(m);
1693
+ const roi = estimateRoi(m);
1694
+ const rec = m.rescue.recommendations[0] || {};
1695
+ document.getElementById("monitor-view").innerHTML =
1696
+ '<div class="hero">' +
1697
+ '<div class="panel ' + esc(m.stuck.status) + '">' +
1698
+ '<div class="headline"><div><h2>' + esc(d.title) + '</h2><p class="hero-copy">' + esc(d.description) + '</p></div><span class="status ' + esc(m.stuck.status) + '">' + labelStatus(m.stuck.status) + '</span></div>' +
1699
+ '<div class="badge-row"><span class="badge ' + (m.stuck.status === "stuck" ? "bad" : m.stuck.status === "at_risk" ? "warn" : "good") + '">manager decision: ' + esc(d.managerDecision) + '</span><span class="badge">quality guard: ' + esc(d.qualityGuard) + '</span><span class="badge">fuel: ' + esc(m.fuelUsedPercent === null ? "needs calibration" : m.fuelUsedPercent + "%") + '</span></div>' +
1700
+ '<div class="metrics">' +
1701
+ '<div class="metric"><strong>' + esc(roi.spendRisk) + '</strong><span>spend risk</span></div>' +
1702
+ '<div class="metric"><strong>' + esc(roi.expectedSaving) + '</strong><span>possible saving</span></div>' +
1703
+ '<div class="metric"><strong>' + esc(roi.qualityRisk) + '</strong><span>quality risk</span></div>' +
1704
+ '<div class="metric"><strong>' + esc(roi.bestModelTier) + '</strong><span>recommended tier</span></div>' +
1705
+ '</div>' +
1706
+ '</div>' +
1707
+ '<div class="panel rescue">' +
1708
+ '<h3>Manager action</h3>' +
1709
+ '<p class="decision">' + esc(rec.nextAction || d.next) + '</p>' +
1710
+ '<div class="actions"><button class="primary" onclick="copyPrompt()">Copy rescue prompt</button><button class="ghost" onclick="prefillPlanner()">Plan safer rerun</button></div>' +
1711
+ '<pre id="prompt-main">' + esc(rec.prompt || d.next) + '</pre>' +
1712
+ '</div>' +
1713
+ '</div>' +
1714
+ '<div class="plan-grid">' +
1715
+ '<div class="plan-card"><strong>What the manager sees</strong><p>' + esc(d.happened) + '</p></div>' +
1716
+ '<div class="plan-card"><strong>Why it matters</strong><p>' + esc(d.cause) + '</p></div>' +
1717
+ '<div class="plan-card"><strong>Proof of progress</strong><p>' + esc(d.changed) + '</p></div>' +
1718
+ '</div>' +
1719
+ '<details><summary>Technical evidence</summary><pre>' + esc(JSON.stringify({ command:m.command.join(" "), changedFiles:m.diffEvidence.changedFiles, parsedErrors:m.errors, stuckSignals:m.stuck.signals, scopeRisk:m.preflight.scopeRisk }, null, 2)) + '</pre></details>' +
1720
+ '<details><summary>Truth labels</summary><pre>Cost/Fuel: ' + (m.fuelUsedPercent === null ? 'unknown until calibrated' : 'manual calibration') + '\\nProgress proof: observed from git diff and command result\\nError parsing: calculated from terminal logs\\nRescue advice: generated from evidence packet</pre></details>';
1721
+ }
1722
+ function copyPrompt() {
1723
+ const text = document.getElementById("prompt-main")?.textContent || "";
1724
+ navigator.clipboard?.writeText(text);
1725
+ }
1726
+ function labelStatus(status) {
1727
+ if (status === "stuck") return "needs rescue";
1728
+ if (status === "at_risk") return "check this";
1729
+ return "moving";
1730
+ }
1731
+ function shortCommand(command) {
1732
+ const text = String(command || "");
1733
+ return text.length > 92 ? text.slice(0, 89) + "..." : text;
1734
+ }
1735
+ function summaryLine(m) {
1736
+ if (m.errorCount > 0) return m.errorCount + " parsed error(s), " + m.changedFileCount + " file change(s)";
1737
+ if (m.exitCode !== 0) return "command failed, no parsed error";
1738
+ return m.changedFileCount + " file change(s)";
1739
+ }
1740
+ function renderEmptyMonitor() {
1741
+ document.getElementById("monitor-view").innerHTML = '<div class="panel empty"><h2>No observed runs yet</h2><p class="hero-copy">Start with a managed plan, then wrap an agent command with <code>aim run --</code>. This screen will show whether the agent is progressing, wasting spend, or needs rescue.</p></div>';
1742
+ }
1743
+ function seedTask(m) {
1744
+ const command = Array.isArray(m.command) ? m.command.join(" ") : String(m.command || "");
1745
+ return command.replace(/^.*?(codex|claude)\\s+["']?/i, "").replace(/["']?$/, "").slice(0, 260);
1746
+ }
1747
+ function estimateRoi(m) {
1748
+ const noChanges = m.diffEvidence.changedFiles.length === 0;
1749
+ const failed = m.exitCode !== 0;
1750
+ const broad = m.preflight.scopeRisk === "high";
1751
+ return {
1752
+ spendRisk: failed && noChanges ? "High" : m.stuck.status === "at_risk" ? "Medium" : "Low",
1753
+ expectedSaving: failed || broad ? "30-70%" : "10-25%",
1754
+ qualityRisk: broad ? "High" : failed ? "Medium" : "Low",
1755
+ bestModelTier: broad || failed ? "Strong first" : "Cheap ok"
1756
+ };
1757
+ }
1758
+ async function planTask() {
1759
+ const input = document.getElementById("task-input");
1760
+ const result = document.getElementById("planner-result");
1761
+ if (!input || !result) return;
1762
+ const text = input.value.trim();
1763
+ if (!text) {
1764
+ result.innerHTML = '<div class="panel"><h3>Missing goal</h3><p class="hero-copy">Describe the outcome before creating a managed plan.</p></div>';
1765
+ return;
1766
+ }
1767
+ const fuelValue = Number(document.getElementById("fuel-input")?.value ?? 24);
1768
+ const quality = document.getElementById("quality-input")?.value ?? "high";
1769
+ result.innerHTML = '<div class="panel"><h3>Creating plan...</h3><p class="hero-copy">The manager is building budget, routing, proof, mission steps, and stop rules.</p></div>';
1770
+ let plan;
1771
+ try {
1772
+ plan = await fetch("/api/plans", {
1773
+ method: "POST",
1774
+ headers: { "content-type": "application/json" },
1775
+ body: JSON.stringify({ goal: text, fuelPercent: fuelValue, quality })
1776
+ }).then(async (response) => {
1777
+ const body = await response.json();
1778
+ if (!response.ok) throw new Error(body.error || "Plan request failed.");
1779
+ return body;
1780
+ });
1781
+ } catch (error) {
1782
+ result.innerHTML = '<div class="panel"><h3>Plan failed</h3><p class="hero-copy">' + esc(error.message) + '</p></div>';
1783
+ return;
1784
+ }
1785
+ state.selectedPlan = plan.id;
1786
+ state.plans = [plan, ...state.plans.filter((item) => item.id !== plan.id)];
1787
+ renderPlans();
1788
+ renderPlan(plan);
1789
+ }
1790
+ function copyPlan() {
1791
+ navigator.clipboard?.writeText(window.lastPlanText || "");
1792
+ }
1793
+ function prefillPlanner() {
1794
+ setView("plan");
1795
+ const input = document.getElementById("task-input");
1796
+ if (input && state.selected) {
1797
+ const selected = state.missions.find((m) => m.id === state.selected);
1798
+ input.value = selected ? shortCommand(selected.command) : input.value;
1799
+ }
1800
+ planTask();
1801
+ }
1802
+ function diagnose(m) {
1803
+ const firstError = m.errors[0];
1804
+ const firstSignal = m.stuck.signals[0];
1805
+ const changed = m.diffEvidence.changedFiles.length
1806
+ ? m.diffEvidence.changedFiles.join(", ")
1807
+ : "No file changes were detected during this run.";
1808
+ if (m.stuck.status === "stuck") {
1809
+ return {
1810
+ title: "The agent is stuck",
1811
+ description: "The current AI run is spending effort without proving useful progress. The manager should stop broad execution, protect budget, and switch into diagnosis.",
1812
+ managerDecision: "stop and rescue",
1813
+ qualityGuard: "do not continue blindly",
1814
+ happened: firstError ? firstError.raw : firstSignal ? firstSignal.evidence : "The command failed.",
1815
+ cause: firstError?.sourceFile ? "The failure points to " + firstError.sourceFile + ". A cheaper rerun without diagnosis is likely to repeat the same failure." : "The agent needs a diagnosis pass before more implementation.",
1816
+ changed,
1817
+ next: "Run a narrow diagnostic prompt and ask the agent for the smallest fix."
1818
+ };
1819
+ }
1820
+ if (m.stuck.status === "at_risk") {
1821
+ return {
1822
+ title: "This run needs attention",
1823
+ description: "The agent may still be useful, but the manager cannot prove that more tokens will improve the result. Confirm the next step before spending more.",
1824
+ managerDecision: "check before spend",
1825
+ qualityGuard: "needs proof",
1826
+ happened: m.exitCode === 127 ? "The command could not be executed correctly." : "The run ended with warning signals.",
1827
+ cause: firstSignal ? firstSignal.evidence : "The system could not prove clean progress. This is where AI budgets usually leak.",
1828
+ changed,
1829
+ next: "Ask for diagnosis first, then rerun with one verification command."
1830
+ };
1831
+ }
1832
+ return {
1833
+ title: "The task appears to be moving",
1834
+ description: "The run has enough evidence to continue, but it should still move through small verified missions instead of one unlimited agent session.",
1835
+ managerDecision: "continue with checkpoint",
1836
+ qualityGuard: "verify next",
1837
+ happened: "The run completed without major stuck signals.",
1838
+ cause: "No immediate blocker detected.",
1839
+ changed,
1840
+ next: "Continue only with a clear verification command."
1841
+ };
1842
+ }
1843
+ load();
1844
+ setInterval(load, 5000);
1845
+ </script>
1846
+ </body>
1847
+ </html>`;
1848
+ }
1849
+
1850
+ function sendJson(response, data, status = 200) {
1851
+ send(response, status, JSON.stringify(data, null, 2), "application/json; charset=utf-8");
1852
+ }
1853
+
1854
+ function send(response, status, body, contentType) {
1855
+ response.writeHead(status, { "content-type": contentType, "cache-control": "no-store" });
1856
+ response.end(body);
1857
+ }
1858
+
1859
+ function clampPercent(value) {
1860
+ if (!Number.isFinite(value) || value < 0 || value > 100) {
1861
+ throw new Error("Fuel percent must be a number from 0 to 100.");
1862
+ }
1863
+ return Number(value.toFixed(2));
1864
+ }
1865
+
1866
+ function dedupe(items, keyFn) {
1867
+ const seen = new Set();
1868
+ return items.filter((item) => {
1869
+ const key = keyFn(item);
1870
+ if (seen.has(key)) return false;
1871
+ seen.add(key);
1872
+ return true;
1873
+ });
1874
+ }