@fyeeme/pi-goal 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/tool.ts ADDED
@@ -0,0 +1,557 @@
1
+ /**
2
+ * pi-goal — tool definition.
3
+ *
4
+ * Ported from oh-my-pi `packages/coding-agent/src/goals/tools/goal-tool.ts`
5
+ * (GoalTool + renderer). Adaptations for pi's extension API:
6
+ *
7
+ * omp surface → pi adaptation
8
+ * ─────────────────────────────────────────────────────────────────────────
9
+ * ArkType schema + type() → TypeBox schema
10
+ * AgentTool class + ToolSession → ToolDefinition + closure deps
11
+ * concurrency: "exclusive" → executionMode: "sequential"
12
+ * renderStatusLine/framedBlock/mergeCallAndResult
13
+ * → split renderCall/renderResult with
14
+ * pi-tui Text components (pi-todo
15
+ * precedent)
16
+ * ToolError → throw Error (pi contract:
17
+ * tools fail by throwing)
18
+ * strict/intent fields → dropped (omp host-internal)
19
+ *
20
+ * omp operation semantics are kept verbatim for create/get/resume/drop:
21
+ * create requires objective and only works without an existing live goal;
22
+ * get reads state; resume reactivates paused goals; drop discards.
23
+ *
24
+ * DELIBERATE DEVIATION from omp (absorbed from CC 2.1.261's goal design):
25
+ * completion is no longer self-graded. `complete` requires an `evidence`
26
+ * audit and is gated by an independent evaluator subprocess (src/evaluator.ts)
27
+ * that re-verifies the repo itself; a refuted claim keeps the goal active and
28
+ * returns the evaluator's findings. `impossible` is a new op: the agent
29
+ * reports the goal unachievable, the evaluator independently confirms (the
30
+ * claim is evidence, not proof); confirmation pauses the goal, refutation
31
+ * counts toward a dispute cap that pauses it for a human decision.
32
+ */
33
+
34
+ import { readFileSync } from "node:fs";
35
+ import * as path from "node:path";
36
+ import { fileURLToPath } from "node:url";
37
+ import type { AgentToolResult } from "@earendil-works/pi-agent-core";
38
+ import { StringEnum } from "@earendil-works/pi-ai";
39
+ import type { Theme, ThemeColor, ToolDefinition } from "@earendil-works/pi-coding-agent";
40
+ import { type Component, Text } from "@earendil-works/pi-tui";
41
+ import { type Static, Type } from "typebox";
42
+ import type { GoalEvaluatorOutcome, GoalEvaluatorRequest } from "./evaluator.ts";
43
+ import { formatDuration, formatNumber } from "./format.ts";
44
+ import type { GoalRuntime } from "./runtime.ts";
45
+ import { completionBudgetReport, remainingTokens } from "./runtime.ts";
46
+ import type { Goal, GoalModeState, GoalStatus, GoalToolDetails } from "./state.ts";
47
+
48
+ // Prompt lives in a static .md asset next to this module (published with the
49
+ // package). Loaded at runtime: pi loads extensions through jiti, which does
50
+ // not support bundler-style text imports (pi-todo precedent).
51
+ const goalDescription = readFileSync(
52
+ path.join(path.dirname(fileURLToPath(import.meta.url)), "prompts", "goal.md"),
53
+ "utf8",
54
+ );
55
+
56
+ export const GOAL_OPS = ["create", "get", "complete", "resume", "drop", "impossible"] as const;
57
+
58
+ /** Unconfirmed impossibility claims tolerated before the goal is paused for
59
+ * a human decision (CC 2.1.261 caps its idle check-ins the same way). */
60
+ export const IMPOSSIBLE_REPORT_CAP = 2;
61
+
62
+ export const GoalParamsSchema = Type.Object({
63
+ // StringEnum (not Type.Union/Type.Literal): TypeBox literal unions do not
64
+ // serialize for Google's API (docs/extensions.md, Custom Tools).
65
+ op: StringEnum(GOAL_OPS, { description: "goal operation" }),
66
+ objective: Type.Optional(Type.String({ description: "goal objective" })),
67
+ token_budget: Type.Optional(Type.Integer({ description: "token budget" })),
68
+ evidence: Type.Optional(
69
+ Type.String({
70
+ description:
71
+ "op=complete 时必填:逐交付物的当前仓库状态审计(读了哪些文件、跑了哪些检查、观察到什么输出)——独立评估器以此为起点复核。",
72
+ }),
73
+ ),
74
+ reason: Type.Optional(
75
+ Type.String({
76
+ description:
77
+ "op=impossible 时必填:为何目标在本会话确实无法达成(自相矛盾 / 依赖不可用资源 / 合理尝试已穷尽),附证据——评估器将独立核实。",
78
+ }),
79
+ ),
80
+ });
81
+
82
+ export type GoalToolInput = Static<typeof GoalParamsSchema>;
83
+
84
+ export interface GoalToolResponse {
85
+ goal: Goal | null;
86
+ remainingTokens: number | null;
87
+ completionBudgetReport: string | null;
88
+ }
89
+
90
+ export function buildGoalToolResponse(
91
+ goal: Goal | null | undefined,
92
+ options?: { includeCompletionReport?: boolean },
93
+ ): GoalToolResponse {
94
+ const resolvedGoal = goal ?? null;
95
+ return {
96
+ goal: resolvedGoal,
97
+ remainingTokens: remainingTokens(resolvedGoal),
98
+ completionBudgetReport:
99
+ options?.includeCompletionReport && resolvedGoal?.status === "complete"
100
+ ? completionBudgetReport(resolvedGoal)
101
+ : null,
102
+ };
103
+ }
104
+
105
+ function validateCreateParams(params: GoalToolInput): { objective: string; tokenBudget?: number } {
106
+ const objective = params.objective?.trim();
107
+ if (!objective) {
108
+ throw new Error("objective is required when op=create");
109
+ }
110
+ const tokenBudget = params.token_budget;
111
+ if (tokenBudget !== undefined && (!Number.isInteger(tokenBudget) || tokenBudget <= 0)) {
112
+ throw new Error("token_budget must be a positive integer when provided");
113
+ }
114
+ return { objective, tokenBudget };
115
+ }
116
+
117
+ export type GoalEvaluatorFn = (
118
+ request: GoalEvaluatorRequest,
119
+ opts: { cwd?: string; signal?: AbortSignal },
120
+ ) => Promise<GoalEvaluatorOutcome>;
121
+
122
+ export interface GoalToolDeps {
123
+ /** The session goal runtime (create/resume/drop/complete flow through it). */
124
+ getRuntime(): GoalRuntime;
125
+ /** Current in-memory goal state (get reads it). */
126
+ getState(): GoalModeState | undefined;
127
+ /** Independent completion/impossibility evaluator (src/evaluator.ts).
128
+ * Gates `complete` and adjudicates `impossible`. */
129
+ runEvaluator: GoalEvaluatorFn;
130
+ }
131
+
132
+ function describeEvaluatorVerdict(verdict: string): string {
133
+ switch (verdict) {
134
+ case "confirmed":
135
+ return "completion confirmed";
136
+ case "rejected":
137
+ return "completion rejected";
138
+ case "unavailable-fallback":
139
+ return "evaluator unavailable";
140
+ case "impossible-confirmed":
141
+ return "impossibility confirmed";
142
+ case "impossible-refuted":
143
+ return "impossibility refuted";
144
+ case "impossible-disputed":
145
+ return "impossibility disputed";
146
+ default:
147
+ return "evaluator unavailable";
148
+ }
149
+ }
150
+
151
+ export function createGoalTool(deps: GoalToolDeps): ToolDefinition<typeof GoalParamsSchema, GoalToolDetails> {
152
+ return {
153
+ name: "goal",
154
+ label: "Goal",
155
+ description: goalDescription,
156
+ promptSnippet: "Manage the active goal-mode objective (create/get/complete/resume/drop/impossible)",
157
+ parameters: GoalParamsSchema,
158
+ executionMode: "sequential",
159
+
160
+ async execute(
161
+ _toolCallId: string,
162
+ params: GoalToolInput,
163
+ signal: AbortSignal | undefined,
164
+ _onUpdate: undefined,
165
+ ctx: { cwd?: string } | undefined,
166
+ ): Promise<AgentToolResult<GoalToolDetails>> {
167
+ const runtime = deps.getRuntime();
168
+ let response: GoalToolResponse;
169
+ /** Evaluator resolution for gated ops (complete/impossible); undefined
170
+ * on the ungated ops. */
171
+ let evaluator: GoalToolDetails["evaluator"];
172
+ if (params.op === "create") {
173
+ const created = await runtime.createGoal(validateCreateParams(params));
174
+ response = buildGoalToolResponse(created.goal);
175
+ } else if (params.op === "get") {
176
+ const state = deps.getState();
177
+ response = buildGoalToolResponse(state?.goal ?? null);
178
+ } else if (params.op === "resume") {
179
+ const resumed = await runtime.resumeGoal();
180
+ response = buildGoalToolResponse(resumed.goal);
181
+ } else if (params.op === "drop") {
182
+ const dropped = await runtime.dropGoal();
183
+ response = buildGoalToolResponse(dropped ?? null);
184
+ } else if (params.op === "complete") {
185
+ ({ response, evaluator } = await runGatedComplete(deps, params, ctx?.cwd, signal));
186
+ } else {
187
+ ({ response, evaluator } = await runGatedImpossible(deps, params, ctx?.cwd, signal));
188
+ }
189
+
190
+ let text: string;
191
+ if (response.goal) {
192
+ text = `Goal: ${response.goal.objective}\nStatus: ${response.goal.status}\nTokens: ${response.goal.tokensUsed} used`;
193
+ if (response.goal.tokenBudget !== undefined) {
194
+ text += ` / ${response.goal.tokenBudget} budget`;
195
+ }
196
+ if (response.remainingTokens !== null) {
197
+ text += `\nRemaining tokens: ${response.remainingTokens}`;
198
+ }
199
+ if (response.completionBudgetReport) {
200
+ text += `\n\n${response.completionBudgetReport}`;
201
+ }
202
+ } else {
203
+ text = "No active goal.";
204
+ }
205
+ if (evaluator) {
206
+ text += `\n\n${evaluatorText(evaluator)}`;
207
+ }
208
+ return {
209
+ content: [{ type: "text", text }],
210
+ details: {
211
+ op: params.op,
212
+ goal: response.goal,
213
+ remainingTokens: response.remainingTokens,
214
+ completionBudgetReport: response.completionBudgetReport,
215
+ evaluator,
216
+ },
217
+ };
218
+ },
219
+
220
+ renderCall: (args, theme) => renderGoalCall(args, theme),
221
+ // omp goalToolRenderer.renderResult falls back to the call args when the
222
+ // result carries no details (error paths): `details?.op ?? args?.op`.
223
+ renderResult: (result, options, theme, context) => renderGoalResult(result, options, theme, context?.args),
224
+ };
225
+ }
226
+
227
+ // =============================================================================
228
+ // Evaluator-gated ops (complete / impossible)
229
+ // =============================================================================
230
+
231
+ function requireClaim(value: string | undefined, op: "complete" | "impossible"): string {
232
+ const claim = value?.trim();
233
+ if (!claim) {
234
+ throw new Error(
235
+ op === "complete"
236
+ ? "evidence is required when op=complete — pass your per-deliverable audit of the current repo state (the independent evaluator re-verifies it)"
237
+ : "reason is required when op=impossible — state why the goal cannot be achieved, with evidence (the independent evaluator will confirm it)",
238
+ );
239
+ }
240
+ return claim;
241
+ }
242
+
243
+ /** Preserve omp's complete-error semantics BEFORE spending an evaluator run:
244
+ * no goal / already complete / dropped → same errors the runtime throws. */
245
+ function requireCompletableGoal(deps: GoalToolDeps): GoalModeState {
246
+ const state = deps.getState();
247
+ if (!state?.goal) {
248
+ throw new Error("cannot complete goal because no goal is active");
249
+ }
250
+ if (state.goal.status === "complete") {
251
+ throw new Error("goal is already complete");
252
+ }
253
+ if (state.goal.status === "dropped") {
254
+ throw new Error("cannot complete a dropped goal");
255
+ }
256
+ return state;
257
+ }
258
+
259
+ async function runGatedComplete(
260
+ deps: GoalToolDeps,
261
+ params: GoalToolInput,
262
+ cwd: string | undefined,
263
+ signal: AbortSignal | undefined,
264
+ ): Promise<{ response: GoalToolResponse; evaluator: NonNullable<GoalToolDetails["evaluator"]> }> {
265
+ const state = requireCompletableGoal(deps);
266
+ const evidence = requireClaim(params.evidence, "complete");
267
+ const outcome = await deps.runEvaluator(
268
+ { mode: "complete", objective: state.goal.objective, claim: evidence },
269
+ { cwd, signal },
270
+ );
271
+ if (outcome.status === "refuted") {
272
+ // The claim does not survive independent verification: no state change.
273
+ return {
274
+ response: buildGoalToolResponse(state.goal),
275
+ evaluator: { verdict: "rejected", reason: outcome.reason },
276
+ };
277
+ }
278
+ const completed = await deps.getRuntime().completeGoalFromTool();
279
+ if (outcome.status === "unavailable") {
280
+ // Evaluator unreachable (spawn failure / timeout / unparseable output):
281
+ // fall back to the omp self-audit behavior, honestly labeled.
282
+ return {
283
+ response: buildGoalToolResponse(completed, { includeCompletionReport: true }),
284
+ evaluator: { verdict: "unavailable-fallback", reason: outcome.detail },
285
+ };
286
+ }
287
+ return {
288
+ response: buildGoalToolResponse(completed, { includeCompletionReport: true }),
289
+ evaluator: { verdict: "confirmed", reason: outcome.reason },
290
+ };
291
+ }
292
+
293
+ async function runGatedImpossible(
294
+ deps: GoalToolDeps,
295
+ params: GoalToolInput,
296
+ cwd: string | undefined,
297
+ signal: AbortSignal | undefined,
298
+ ): Promise<{ response: GoalToolResponse; evaluator: NonNullable<GoalToolDetails["evaluator"]> }> {
299
+ const reason = requireClaim(params.reason, "impossible");
300
+ const state = deps.getState();
301
+ if (!state?.enabled || (state.goal.status !== "active" && state.goal.status !== "budget-limited")) {
302
+ throw new Error("cannot report impossibility because no active goal exists");
303
+ }
304
+ const reported = await deps.getRuntime().recordImpossibleReport();
305
+ const outcome = await deps.runEvaluator(
306
+ { mode: "impossible", objective: reported.objective, claim: reason },
307
+ { cwd, signal },
308
+ );
309
+ if (outcome.status === "confirmed") {
310
+ await deps.getRuntime().pauseGoal({ reason: "impossible-confirmed" });
311
+ return {
312
+ response: buildGoalToolResponse(deps.getState()?.goal ?? { ...reported, status: "paused" }),
313
+ evaluator: { verdict: "impossible-confirmed", reason: outcome.reason },
314
+ };
315
+ }
316
+ if (outcome.status === "refuted") {
317
+ const reports = reported.impossibleReports ?? 0;
318
+ if (reports >= IMPOSSIBLE_REPORT_CAP) {
319
+ await deps.getRuntime().pauseGoal({ reason: "impossible-disputed" });
320
+ return {
321
+ response: buildGoalToolResponse(deps.getState()?.goal ?? { ...reported, status: "paused" }),
322
+ evaluator: { verdict: "impossible-disputed", reason: outcome.reason },
323
+ };
324
+ }
325
+ return {
326
+ response: buildGoalToolResponse(reported),
327
+ evaluator: { verdict: "impossible-refuted", reason: outcome.reason },
328
+ };
329
+ }
330
+ // Evaluator unavailable: keep the goal active (no state change on an
331
+ // unadjudicated claim) and route the dispute to the user.
332
+ return {
333
+ response: buildGoalToolResponse(reported),
334
+ evaluator: { verdict: "unavailable", reason: outcome.detail },
335
+ };
336
+ }
337
+
338
+ /** Model-facing evaluator section appended to the tool-result text. */
339
+ function evaluatorText(evaluator: NonNullable<GoalToolDetails["evaluator"]>): string {
340
+ switch (evaluator.verdict) {
341
+ case "confirmed":
342
+ return `Independent evaluator CONFIRMED completion. Evidence:\n${evaluator.reason || "(no reason returned)"}`;
343
+ case "rejected":
344
+ return (
345
+ `Completion REJECTED by the independent evaluator — the goal remains active.\n\n` +
346
+ `Evaluator findings:\n${evaluator.reason || "(no reason returned)"}\n\n` +
347
+ `Fix the gaps (or gather stronger current-state evidence) and call goal({op:"complete", evidence: ...}) again. ` +
348
+ `Do not re-claim with the same evidence.`
349
+ );
350
+ case "unavailable-fallback":
351
+ return (
352
+ `Independent evaluator unavailable (${evaluator.reason || "unknown reason"}) — ` +
353
+ `completed via self-audit fallback. Mention this in the done report so the user knows the claim was not independently verified.`
354
+ );
355
+ case "impossible-confirmed":
356
+ return (
357
+ `The independent evaluator CONFIRMED the impossibility claim — the goal is now paused.\n\n` +
358
+ `Evaluator evidence:\n${evaluator.reason || "(no reason returned)"}\n\n` +
359
+ `Report this honestly to the user: what was attempted, why the goal cannot be achieved this session, and what would unblock it. ` +
360
+ `Do not drop or re-create the goal; the user decides.`
361
+ );
362
+ case "impossible-refuted":
363
+ return (
364
+ `Impossibility claim REFUTED by the independent evaluator — the goal remains active.\n\n` +
365
+ `Evaluator findings:\n${evaluator.reason || "(no reason returned)"}\n\n` +
366
+ `Your claim is evidence, not proof: keep working toward the goal. ` +
367
+ `Repeated unconfirmed impossibility claims pause the goal for the user.`
368
+ );
369
+ case "impossible-disputed":
370
+ return (
371
+ `Impossibility claim refuted again — the goal is now paused for a human decision.\n\n` +
372
+ `Evaluator findings:\n${evaluator.reason || "(no reason returned)"}\n\n` +
373
+ `Report the disagreement to the user with both sides' evidence and stop; the user decides whether to resume, drop, or restate the goal.`
374
+ );
375
+ default:
376
+ return (
377
+ `Independent evaluator unavailable (${evaluator.reason || "unknown reason"}) — the impossibility claim was NOT adjudicated. ` +
378
+ `The goal remains active. Surface the situation to the user directly instead of repeating the claim; they can run /goal pause.`
379
+ );
380
+ }
381
+ }
382
+
383
+ // =============================================================================
384
+ // Rendering (ported from omp goalToolRenderer, pi-tui primitives)
385
+ // =============================================================================
386
+
387
+ /**
388
+ * Rendering (ported from omp goalToolRenderer, pi-tui primitives)
389
+ *
390
+ * omp renders the header with renderStatusLine + a framed block; pi's split
391
+ * renderCall/renderResult slots take the same strings as Text lines. Glyphs,
392
+ * separators, badge brackets, and truncation caps are omp's unicode symbol
393
+ * set verbatim:
394
+ * status.pending ⏳ · tool.goal ◎ · status.error ✘ · status.warning ⚠
395
+ * sep.dot " · " · format.bracketLeft/Right ⟦⟧
396
+ * TRUNCATE_LENGTHS.TITLE 60 (call) / LONG 100 (result)
397
+ */
398
+
399
+ const STATUS_ICONS = {
400
+ pending: "⏳",
401
+ goal: "◎",
402
+ error: "✘",
403
+ warning: "⚠",
404
+ } as const;
405
+
406
+ /** omp sep.dot / format.bracketLeft / format.bracketRight (unicode set). */
407
+ const SEP_DOT = " · ";
408
+ const BRACKET_LEFT = "⟦";
409
+ const BRACKET_RIGHT = "⟧";
410
+
411
+ /** omp TRUNCATE_LENGTHS.TITLE / LONG. */
412
+ const TITLE_CAP = 60;
413
+ const LONG_CAP = 100;
414
+
415
+ function describeOp(op: string | undefined): string {
416
+ switch (op) {
417
+ case "create":
418
+ return "set";
419
+ case "complete":
420
+ return "complete";
421
+ case "get":
422
+ return "check";
423
+ case "resume":
424
+ return "resume";
425
+ case "drop":
426
+ return "drop";
427
+ case "impossible":
428
+ return "report impossible";
429
+ default:
430
+ return op ?? "?";
431
+ }
432
+ }
433
+
434
+ function goalBadgeColor(status: GoalStatus): ThemeColor {
435
+ switch (status) {
436
+ case "complete":
437
+ return "success";
438
+ case "budget-limited":
439
+ return "warning";
440
+ case "paused":
441
+ case "dropped":
442
+ return "muted";
443
+ default:
444
+ return "accent";
445
+ }
446
+ }
447
+
448
+ /** omp truncateToWidth shape: clip to cap with an ellipsis. */
449
+ function clip(text: string, cap: number): string {
450
+ const single = text.replace(/[\t\n\r]+/g, " ");
451
+ return single.length > cap ? `${single.slice(0, cap - 1)}…` : single;
452
+ }
453
+
454
+ export interface GoalRenderArgs {
455
+ op?: GoalToolInput["op"];
456
+ objective?: string;
457
+ token_budget?: number;
458
+ evidence?: string;
459
+ reason?: string;
460
+ }
461
+
462
+ /** pi ToolRenderContext.args slice used for the omp args-op fallback. */
463
+ export interface GoalRenderContext {
464
+ args?: GoalRenderArgs;
465
+ }
466
+
467
+ export function renderGoalCall(args: GoalRenderArgs, theme: Theme): Component {
468
+ const description = describeOp(args.op);
469
+ const trimmedObjective = args.objective?.trim();
470
+ const meta: string[] = [];
471
+ if (args.op === "create" && trimmedObjective) {
472
+ meta.push(theme.italic(theme.fg("muted", `"${clip(trimmedObjective, TITLE_CAP)}"`)));
473
+ }
474
+ if (args.op === "create" && args.token_budget !== undefined) {
475
+ meta.push(`budget ${formatNumber(args.token_budget)}`);
476
+ }
477
+ let line = `${theme.fg("muted", STATUS_ICONS.pending)} ${theme.fg("accent", "Goal")}: ${theme.fg("muted", description)}`;
478
+ if (meta.length > 0) {
479
+ line += ` ${theme.fg("dim", meta.join(SEP_DOT))}`;
480
+ }
481
+ return new Text(line, 0, 0);
482
+ }
483
+
484
+ interface GoalRenderResult {
485
+ content: Array<{ type: string; text?: string }>;
486
+ details?: GoalToolDetails;
487
+ isError?: boolean;
488
+ }
489
+
490
+ export function renderGoalResult(
491
+ result: GoalRenderResult,
492
+ _options: { isPartial: boolean },
493
+ theme: Theme,
494
+ args?: GoalRenderArgs,
495
+ ): Component {
496
+ const fallbackText = result.content?.find((c) => c.type === "text")?.text ?? "";
497
+ const details = result.details;
498
+ // omp: details carry the op; call args are the fallback (error paths).
499
+ const op = details?.op ?? args?.op;
500
+ const description = describeOp(op);
501
+
502
+ if (result.isError) {
503
+ // omp: ✘ header + two-space error detail inside the frame.
504
+ const header = `${theme.fg("error", STATUS_ICONS.error)} ${theme.fg("accent", "Goal")}: ${theme.fg("muted", description)}`;
505
+ const detail = ` ${theme.fg("error", clip(fallbackText || "Goal tool failed", LONG_CAP))}`;
506
+ return new Text(`${header}\n${detail}`, 0, 0);
507
+ }
508
+
509
+ const goal = details?.goal ?? null;
510
+ if (!goal) {
511
+ // omp: ⚠ header with "no active goal" dim meta.
512
+ const line =
513
+ `${theme.fg("warning", STATUS_ICONS.warning)} ${theme.fg("accent", "Goal")}: ${theme.fg("muted", description)}` +
514
+ ` ${theme.fg("dim", `${SEP_DOT}no active goal`)}`;
515
+ return new Text(line, 0, 0);
516
+ }
517
+
518
+ // omp header: ◎ (tool.goal, accent) + badge ⟦status⟧ in goalBadgeColor.
519
+ const badgeColor = goalBadgeColor(goal.status);
520
+ const header =
521
+ `${theme.fg("accent", STATUS_ICONS.goal)} ${theme.fg("accent", "Goal")}: ${theme.fg("muted", description)}` +
522
+ ` ${theme.fg(badgeColor, `${BRACKET_LEFT}${goal.status}${BRACKET_RIGHT}`)}`;
523
+
524
+ const lines: string[] = [header];
525
+ const objectiveText = clip(goal.objective.trim(), LONG_CAP);
526
+ lines.push(theme.italic(theme.fg("muted", `"${objectiveText}"`)));
527
+
528
+ const used = formatNumber(goal.tokensUsed);
529
+ const tokensLine =
530
+ goal.tokenBudget !== undefined
531
+ ? `${used} / ${formatNumber(goal.tokenBudget)} tokens (${formatNumber(Math.max(0, goal.tokenBudget - goal.tokensUsed))} left)`
532
+ : `${used} tokens`;
533
+ const metaParts = [tokensLine];
534
+ if (goal.timeUsedSeconds > 0) {
535
+ metaParts.push(`${formatDuration(goal.timeUsedSeconds * 1000)} elapsed`);
536
+ }
537
+ lines.push(theme.fg("dim", metaParts.join(SEP_DOT)));
538
+
539
+ const report = details?.completionBudgetReport;
540
+ if (report) {
541
+ for (const line of report.split("\n")) {
542
+ lines.push(theme.fg("muted", line));
543
+ }
544
+ }
545
+
546
+ const evaluator = details?.evaluator;
547
+ if (evaluator) {
548
+ const favorable = evaluator.verdict === "confirmed";
549
+ const icon = favorable ? STATUS_ICONS.goal : STATUS_ICONS.warning;
550
+ const color: ThemeColor = favorable ? "success" : "warning";
551
+ lines.push(
552
+ theme.fg(color, `${icon} ${clip(`${describeEvaluatorVerdict(evaluator.verdict)} — ${evaluator.reason || "(no reason returned)"}`, LONG_CAP * 2)}`),
553
+ );
554
+ }
555
+
556
+ return new Text(lines.join("\n"), 0, 0);
557
+ }