@quantmind-br/pi-goal-x 0.0.0-stage → 0.35.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +131 -2
  3. package/assets/badge-dark.svg +18 -0
  4. package/assets/badge-light.svg +18 -0
  5. package/docs/advanced-usage.md +106 -0
  6. package/docs/agent-flow-design.md +306 -0
  7. package/docs/agentic-runtime-prd.md +773 -0
  8. package/docs/architecture.md +368 -0
  9. package/docs/controls.md +140 -0
  10. package/docs/pi-autoresearch-survey.md +50 -0
  11. package/docs/pi-goal-x-unified-dashboard-plan.md +1667 -0
  12. package/docs/prompt-caching.md +31 -0
  13. package/docs/unified-dashboard.md +286 -0
  14. package/extensions/auditor-selector.ts +74 -0
  15. package/extensions/goal-accounting.ts +107 -0
  16. package/extensions/goal-activity.ts +202 -0
  17. package/extensions/goal-auditor.ts +480 -0
  18. package/extensions/goal-commands.ts +866 -0
  19. package/extensions/goal-compaction.ts +197 -0
  20. package/extensions/goal-completion.ts +173 -0
  21. package/extensions/goal-context.ts +12 -0
  22. package/extensions/goal-contract.ts +58 -0
  23. package/extensions/goal-controls.ts +202 -0
  24. package/extensions/goal-core-tools.ts +549 -0
  25. package/extensions/goal-core.ts +78 -0
  26. package/extensions/goal-detail.ts +65 -0
  27. package/extensions/goal-dialog-controls.ts +31 -0
  28. package/extensions/goal-draft.ts +244 -0
  29. package/extensions/goal-drafting.ts +535 -0
  30. package/extensions/goal-events.ts +692 -0
  31. package/extensions/goal-format.ts +376 -0
  32. package/extensions/goal-interruption.ts +53 -0
  33. package/extensions/goal-ledger-index.ts +68 -0
  34. package/extensions/goal-ledger.ts +1007 -0
  35. package/extensions/goal-live-retention.ts +239 -0
  36. package/extensions/goal-oracle.ts +346 -0
  37. package/extensions/goal-policy.ts +370 -0
  38. package/extensions/goal-pool.ts +103 -0
  39. package/extensions/goal-prompt-cache.ts +80 -0
  40. package/extensions/goal-questionnaire.ts +1073 -0
  41. package/extensions/goal-record.ts +388 -0
  42. package/extensions/goal-recovery.ts +256 -0
  43. package/extensions/goal-runtime.ts +277 -0
  44. package/extensions/goal-scheduler-state.ts +100 -0
  45. package/extensions/goal-scheduler.ts +436 -0
  46. package/extensions/goal-service.ts +843 -0
  47. package/extensions/goal-session-health.ts +130 -0
  48. package/extensions/goal-session-safety.ts +65 -0
  49. package/extensions/goal-settings.ts +1370 -0
  50. package/extensions/goal-state.ts +1069 -0
  51. package/extensions/goal-status.ts +305 -0
  52. package/extensions/goal-task-confirmation.ts +174 -0
  53. package/extensions/goal-task-count.ts +59 -0
  54. package/extensions/goal-task-derive.ts +46 -0
  55. package/extensions/goal-task-index.ts +57 -0
  56. package/extensions/goal-task-tools.ts +595 -0
  57. package/extensions/goal-tool-inputs.ts +102 -0
  58. package/extensions/goal-tool-names.ts +73 -0
  59. package/extensions/goal-tools.ts +16 -0
  60. package/extensions/goal-widget.ts +455 -0
  61. package/extensions/goal.ts +42 -0
  62. package/extensions/network-error-backoff.ts +55 -0
  63. package/extensions/prompts/goal-prompts.ts +330 -0
  64. package/extensions/storage/atomic-rename.ts +52 -0
  65. package/extensions/storage/goal-files.ts +695 -0
  66. package/extensions/storage/goal-lock.ts +131 -0
  67. package/extensions/storage/goal-root.ts +95 -0
  68. package/extensions/widgets/auditor-dashboard-model.ts +174 -0
  69. package/extensions/widgets/dialog-scaffold.ts +26 -0
  70. package/extensions/widgets/goal-controls-dialog.ts +329 -0
  71. package/extensions/widgets/goal-dashboard-model.ts +530 -0
  72. package/extensions/widgets/goal-dashboard-renderer.ts +674 -0
  73. package/extensions/widgets/goal-escape-dialog.ts +148 -0
  74. package/extensions/widgets/goal-notifications.ts +9 -0
  75. package/extensions/widgets/goal-widget.ts +723 -0
  76. package/extensions/widgets/task-list-overlay.ts +409 -0
  77. package/extensions/widgets/text-cache.ts +30 -0
  78. package/package.json +117 -4
  79. package/scripts/recover-session-checkpoints.mjs +289 -0
@@ -0,0 +1,530 @@
1
+ import type { GoalCommands } from "../goal-controls.ts";
2
+ import { schedulerSummaryParts } from "../goal-scheduler-state.ts";
3
+ import { taskIndex } from "../goal-task-index.ts";
4
+ /**
5
+ * Shared dashboard view model (plan §6) — the single source of truth for the
6
+ * persistent compact dashboard, the expanded dashboard, `/goal-status`, audit
7
+ * transitions, golden tests, and documentation examples.
8
+ *
9
+ * Pure data derivation only: this module must never import TUI rendering
10
+ * components. Width-safe truncation, borders, and color belong to the
11
+ * renderer; everything factual here is derived from persisted goal state and
12
+ * the durable ledger.
13
+ */
14
+
15
+ import { displayObjectiveTitle, formatDuration, statusLabel } from "../goal-core.ts";
16
+ import type { GoalLedgerEvent } from "../goal-ledger.ts";
17
+ import type { GoalRecord, GoalTask } from "../goal-record.ts";
18
+ import { deriveGoalActivity, type GoalActivityItem } from "../goal-activity.ts";
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // Types (plan §6)
22
+ // ---------------------------------------------------------------------------
23
+
24
+ export type DashboardStatusCode = "running" | "idle" | "paused" | "blocked" | "budget_limited" | "complete";
25
+
26
+ export interface GoalDashboardModel {
27
+ scheduling: string[];
28
+ idlePacing?: string;
29
+ goalId: string;
30
+ title: string;
31
+
32
+ status: {
33
+ code: DashboardStatusCode;
34
+ label: string;
35
+ /** Footer-status label (goal-core statusLabel), e.g. `running`, `paused (agent)`. */
36
+ footerLabel: string;
37
+ reason?: string;
38
+ suggestedAction?: string;
39
+ };
40
+
41
+ focused: boolean;
42
+ /** §auditor-toggle: whether the independent completion auditor is enabled for this goal (derived from the goal's persisted skipAuditor; undefined = on). */
43
+ auditorEnabled: boolean;
44
+ filePath?: string;
45
+
46
+ usage: {
47
+ activeSeconds: number;
48
+ tokens: number;
49
+ /** Footer-style usage content, e.g. `49h49m36s 19M` (empty when unused). */
50
+ footerBits: string;
51
+ };
52
+
53
+ budget?: {
54
+ used: number;
55
+ total: number;
56
+ percentage: number;
57
+ remaining: number;
58
+ };
59
+
60
+ taskProgress?: {
61
+ completed: number;
62
+ total: number;
63
+ percentage: number;
64
+ };
65
+
66
+ taskTree: DashboardTaskNode[];
67
+
68
+ currentTask?: {
69
+ id: string;
70
+ title: string;
71
+ depth: number;
72
+ completedSubtasks: number;
73
+ totalSubtasks: number;
74
+ subtaskPercentage: number;
75
+ verificationContract?: string;
76
+ evidence?: string;
77
+ /**
78
+ * True when no valid persisted currentTaskId existed and the current
79
+ * task was inferred from the first pending task for display only —
80
+ * never persisted (§7.4).
81
+ */
82
+ inferred?: boolean;
83
+ };
84
+
85
+ goalVerificationContract?: string;
86
+ otherOpenGoals: number;
87
+ recentActivity: GoalActivityItem[];
88
+ }
89
+
90
+ export interface DashboardTaskNode {
91
+ id: string;
92
+ title: string;
93
+ status: "pending" | "complete" | "skipped";
94
+ depth: number;
95
+ isCurrent: boolean;
96
+ verificationContract?: string;
97
+ evidence?: string;
98
+ /** Completion timestamp (when status is complete) — the scroll anchor (§9.6). */
99
+ completedAt?: string;
100
+ /** Direct-child subtask total — 0 for a leaf task (§9.3 rule: direct children only). */
101
+ totalSubtasks: number;
102
+ /** Direct children complete or skipped (same "done" rule as §9.1). */
103
+ completedSubtasks: number;
104
+ }
105
+
106
+ export interface GoalDashboardModelOptions {
107
+ commands?: GoalCommands;
108
+ maxAutonomousRuns?: number;
109
+ idleContinuationBackoff?: boolean;
110
+ /** Report the runs line for a finite allowance (default on). */
111
+ showAutonomousRuns?: boolean;
112
+ focused: boolean;
113
+ otherOpenGoals: number;
114
+ ledgerEvents?: readonly GoalLedgerEvent[];
115
+ activityLimit?: number;
116
+ /** §9.5: omit task sections when tasks are disabled by settings. */
117
+ tasksDisabled?: boolean;
118
+ }
119
+
120
+ // ---------------------------------------------------------------------------
121
+ // Status derivation
122
+ // ---------------------------------------------------------------------------
123
+
124
+ export interface DashboardGoalStatus {
125
+ code: DashboardStatusCode;
126
+ label: string;
127
+ /** Footer-status label (goal-core statusLabel: running / paused (agent) /
128
+ * blocked / budget limited / active / complete, with sisyphus prefix) — the
129
+ * widget status line shows the same terminology as the pi footer. */
130
+ footerLabel: string;
131
+ reason?: string;
132
+ suggestedAction?: string;
133
+ }
134
+
135
+ /**
136
+ * Map the persisted lifecycle status to a display code. Labels are explicit
137
+ * words so the dashboard stays understandable without status symbols (§5.2).
138
+ */
139
+ export function deriveGoalStatus(goal: GoalRecord): DashboardGoalStatus {
140
+ switch (goal.status) {
141
+ case "active":
142
+ return goal.autoContinue
143
+ ? { code: "running", label: "In progress", footerLabel: statusLabel(goal) }
144
+ : { code: "idle", label: "Idle", footerLabel: statusLabel(goal) };
145
+ case "paused": {
146
+ const who = goal.stopReason === "agent" ? " (agent)" : goal.stopReason === "user" ? " (user)" : "";
147
+ const status: DashboardGoalStatus = { code: "paused", label: `Paused${who}`, footerLabel: statusLabel(goal) };
148
+ if (goal.pauseReason) status.reason = goal.pauseReason;
149
+ if (goal.pauseSuggestedAction) status.suggestedAction = goal.pauseSuggestedAction;
150
+ return status;
151
+ }
152
+ case "blocked": {
153
+ const status: DashboardGoalStatus = { code: "blocked", label: "Blocked", footerLabel: statusLabel(goal) };
154
+ if (goal.pauseReason) status.reason = goal.pauseReason;
155
+ if (goal.pauseSuggestedAction) status.suggestedAction = goal.pauseSuggestedAction;
156
+ return status;
157
+ }
158
+ case "budget_limited":
159
+ return { code: "budget_limited", label: "Budget limited", footerLabel: statusLabel(goal) };
160
+ case "complete":
161
+ return { code: "complete", label: "Complete", footerLabel: statusLabel(goal) };
162
+ }
163
+ }
164
+
165
+ // ---------------------------------------------------------------------------
166
+ // Task progress (plan §9)
167
+ // ---------------------------------------------------------------------------
168
+
169
+ export interface TaskProgress {
170
+ completed: number;
171
+ total: number;
172
+ percentage: number;
173
+ }
174
+
175
+ /**
176
+ * Overall progress across TOP-LEVEL tasks only (§9.1): a top-level task
177
+ * counts as done when it is complete or skipped, keeping the main percentage
178
+ * aligned with major milestones.
179
+ */
180
+ export function deriveTopLevelTaskProgress(goal: GoalRecord): TaskProgress | undefined {
181
+ const tasks = goal.taskList?.tasks;
182
+ if (!tasks || tasks.length === 0) return undefined;
183
+ const completed = tasks.filter((t) => t.status === "complete" || t.status === "skipped").length;
184
+ return { completed, total: tasks.length, percentage: percentageOf(completed, tasks.length) };
185
+ }
186
+
187
+ function percentageOf(done: number, total: number): number {
188
+ if (total <= 0) return 0;
189
+ return Math.min(100, Math.max(0, Math.round((done / total) * 100)));
190
+ }
191
+
192
+ // ---------------------------------------------------------------------------
193
+ // Task tree (plan §9.2)
194
+ // ---------------------------------------------------------------------------
195
+
196
+ /**
197
+ * Flatten the recursive task tree into display rows. `currentTaskId` marks
198
+ * the current node; pass the EFFECTIVE current id (persisted, or inferred)
199
+ * so the tree and the current-task block always agree.
200
+ */
201
+ export function flattenTaskTree(tasks: readonly GoalTask[] | undefined, currentTaskId?: string): DashboardTaskNode[] {
202
+ const index = taskIndex(tasks);
203
+ return index.ordered.map(({task: t, depth}) => ({
204
+ id: t.id, title: t.title, status: t.status, depth, isCurrent: t.id === currentTaskId,
205
+ verificationContract: t.verificationContract, evidence: t.evidence, completedAt: t.completedAt,
206
+ totalSubtasks: t.subtasks?.length ?? 0,
207
+ completedSubtasks: t.subtasks?.filter(c => c.status === "complete" || c.status === "skipped").length ?? 0,
208
+ }));
209
+ }
210
+
211
+ // ---------------------------------------------------------------------------
212
+ // Current task (plan §7.2, §9.3)
213
+ // ---------------------------------------------------------------------------
214
+
215
+ export interface CurrentTaskSubtaskProgress {
216
+ completedSubtasks: number;
217
+ totalSubtasks: number;
218
+ subtaskPercentage: number;
219
+ }
220
+
221
+ /**
222
+ * Subtask progress for the current task (§9.3 preferred rule): for a parent
223
+ * task, direct-child completion; for a leaf task, all-zero so the renderer
224
+ * can omit the ratio rather than show something confusing.
225
+ */
226
+ export function deriveCurrentTaskSubtaskProgress(current: { id: string }, tasks: readonly GoalTask[]): CurrentTaskSubtaskProgress {
227
+ const parent = findTask(tasks, current.id);
228
+ const children = parent?.subtasks;
229
+ if (!children || children.length === 0) {
230
+ return { completedSubtasks: 0, totalSubtasks: 0, subtaskPercentage: 0 };
231
+ }
232
+ const completed = children.filter((c) => c.status === "complete" || c.status === "skipped").length;
233
+ return {
234
+ completedSubtasks: completed,
235
+ totalSubtasks: children.length,
236
+ subtaskPercentage: percentageOf(completed, children.length),
237
+ };
238
+ }
239
+
240
+ export interface DashboardCurrentTask extends CurrentTaskSubtaskProgress {
241
+ id: string;
242
+ title: string;
243
+ depth: number;
244
+ verificationContract?: string;
245
+ evidence?: string;
246
+ inferred?: boolean;
247
+ }
248
+
249
+ /**
250
+ * Resolve the current task: the persisted currentTaskId when it references an
251
+ * existing pending task; otherwise (missing, invalid, or removed) fall back
252
+ * to the first pending task in tree order, marked `inferred` so the fallback
253
+ * is never persisted (§7.4). `currentTaskId` may point at any tree node —
254
+ * top-level task or subtask (§7.2).
255
+ */
256
+ export function deriveCurrentTask(goal: GoalRecord, nodes: DashboardTaskNode[]): DashboardCurrentTask | undefined {
257
+ const tasks = goal.taskList?.tasks;
258
+ if (!tasks || tasks.length === 0 || nodes.length === 0) return undefined;
259
+ let node: DashboardTaskNode | undefined;
260
+ if (goal.currentTaskId) {
261
+ node = nodes.find((n) => n.id === goal.currentTaskId && n.status === "pending");
262
+ }
263
+ const inferred = node === undefined;
264
+ if (inferred) node = nodes.find((n) => n.status === "pending");
265
+ if (!node) return undefined;
266
+ const subtaskProgress = deriveCurrentTaskSubtaskProgress(node, tasks);
267
+ return {
268
+ id: node.id,
269
+ title: node.title,
270
+ depth: node.depth,
271
+ ...subtaskProgress,
272
+ verificationContract: node.verificationContract,
273
+ evidence: node.evidence,
274
+ ...(inferred ? { inferred: true } : {}),
275
+ };
276
+ }
277
+
278
+ // ---------------------------------------------------------------------------
279
+ // Task-list viewport / scroll (plan §9.6)
280
+ //
281
+ // The task list is a window over the rendered rows. Pure functions only: the
282
+ // widget owns the mutable offset and the re-anchor rule; the renderer owns
283
+ // the box drawing. Anchoring means the default viewport is positioned so the
284
+ // most recently completed task is visible — "scrolled down" to recent work
285
+ // instead of always showing the earliest tasks.
286
+ // ---------------------------------------------------------------------------
287
+
288
+ /**
289
+ * Index of the most recently completed node: the node with status
290
+ * "complete" and the greatest completedAt; ties resolve to the LAST such
291
+ * node in plan order. Returns -1 when nothing qualifies (e.g. legacy tasks
292
+ * without a completion timestamp), in which case the viewport stays at the
293
+ * top.
294
+ */
295
+ export function latestCompletedNodeIndex(nodes: readonly DashboardTaskNode[]): number {
296
+ let bestIndex = -1;
297
+ let bestAt = "";
298
+ for (let i = 0; i < nodes.length; i++) {
299
+ const node = nodes[i]!;
300
+ if (node.status !== "complete" || !node.completedAt) continue;
301
+ // `>=` keeps the LAST node among equal timestamps (most recent plan
302
+ // position), matching how a viewer reads "most recently done".
303
+ if (bestIndex === -1 || node.completedAt >= bestAt) {
304
+ bestIndex = i;
305
+ bestAt = node.completedAt;
306
+ }
307
+ }
308
+ return bestIndex;
309
+ }
310
+
311
+ /** Highest valid scroll offset for a list of totalRows shown in rows rows. */
312
+ export function maxScrollOffset(totalRows: number, rows: number): number {
313
+ return Math.max(0, totalRows - rows);
314
+ }
315
+
316
+ /** Clamp an offset into [0, maxScrollOffset]. */
317
+ export function clampScrollOffset(offset: number, totalRows: number, rows: number): number {
318
+ return Math.min(Math.max(0, Math.floor(offset)), maxScrollOffset(totalRows, rows));
319
+ }
320
+
321
+ /**
322
+ * Default viewport offset, bottom-anchored to the most recently completed
323
+ * task: the anchor is the LAST visible row, so the view shows the recent
324
+ * completions (and whatever follows them in plan order) instead of the
325
+ * earliest tasks. With no completed (or timestamped) tasks the viewport
326
+ * starts at the top.
327
+ */
328
+ export function anchoredScrollOffset(nodes: readonly DashboardTaskNode[], rows: number): number {
329
+ const anchor = latestCompletedNodeIndex(nodes);
330
+ if (anchor < 0 || rows <= 0) return 0;
331
+ return clampScrollOffset(anchor - (rows - 1), nodes.length, rows);
332
+ }
333
+
334
+ /**
335
+ * Task-row budget for the compact viewport by terminal width (§5.5 buckets:
336
+ * wide ≥100, medium 70–99, narrow 50–69, minimal <50). The expanded view
337
+ * derives its own rows from the render height.
338
+ */
339
+ export function compactTaskViewportRows(width: number): number {
340
+ if (width >= 100) return 5;
341
+ if (width >= 70) return 4;
342
+ if (width >= 50) return 3;
343
+ return 2;
344
+ }
345
+
346
+ /**
347
+ * Task-row budget for the expanded dashboard's task-tree window by terminal
348
+ * width. The widget uses this to keep the expanded panel bounded and
349
+ * scrollable; callers that want the full tree pass rows = totalRows.
350
+ */
351
+ export function expandedTaskViewportRows(width: number): number {
352
+ if (width >= 100) return 20;
353
+ if (width >= 70) return 16;
354
+ if (width >= 50) return 12;
355
+ return 8;
356
+ }
357
+
358
+ /** PgUp/PgDn step: one viewport (a page) of rows. */
359
+ export function taskViewportPageSize(rows: number): number {
360
+ return Math.max(1, Math.floor(rows));
361
+ }
362
+
363
+ export interface TaskListViewport {
364
+ totalRows: number;
365
+ rows: number;
366
+ offset: number;
367
+ maxOffset: number;
368
+ /** Rows hidden above the window (offset > 0). */
369
+ hiddenAbove: number;
370
+ /** Rows hidden below the window (offset + rows < total). */
371
+ hiddenBelow: number;
372
+ }
373
+
374
+ /**
375
+ * Derive the viewport window for a list of totalRows given a clamped offset
376
+ * and row budget. The renderer uses hiddenAbove/hiddenBelow to draw the
377
+ * `↑ N more` / `… +N more` indicator rows.
378
+ */
379
+ export function deriveTaskListViewport(totalRows: number, rows: number, offset: number): TaskListViewport {
380
+ const safeRows = Math.max(0, Math.floor(rows));
381
+ const clamped = clampScrollOffset(offset, totalRows, safeRows);
382
+ const maxOffset = maxScrollOffset(totalRows, safeRows);
383
+ const visible = Math.min(safeRows, Math.max(0, totalRows - clamped));
384
+ return {
385
+ totalRows,
386
+ rows: safeRows,
387
+ offset: clamped,
388
+ maxOffset,
389
+ hiddenAbove: clamped,
390
+ hiddenBelow: Math.max(0, totalRows - (clamped + visible)),
391
+ };
392
+ }
393
+
394
+ function findTask(tasks: readonly GoalTask[], id: string): GoalTask | undefined {
395
+ for (const t of tasks) {
396
+ if (t.id === id) return t;
397
+ if (t.subtasks && t.subtasks.length > 0) {
398
+ const found = findTask(t.subtasks, id);
399
+ if (found) return found;
400
+ }
401
+ }
402
+ return undefined;
403
+ }
404
+
405
+ // ---------------------------------------------------------------------------
406
+ // Activity (re-exported from the durable-ledger mapping, §12)
407
+ // ---------------------------------------------------------------------------
408
+
409
+ export { deriveGoalActivity };
410
+ export type { GoalActivityItem, GoalActivityKind } from "../goal-activity.ts";
411
+
412
+ // ---------------------------------------------------------------------------
413
+ // Formatting helpers
414
+ // ---------------------------------------------------------------------------
415
+
416
+ /** Compact elapsed duration, e.g. `12m47s` (shared formatter from goal-core). */
417
+ export function formatDashboardDuration(seconds: number): string {
418
+ return formatDuration(seconds);
419
+ }
420
+
421
+ /** Audit header duration label from milliseconds (§15.3). */
422
+ export function formatAuditElapsed(elapsedMs: number): string {
423
+ return formatDuration(Math.floor(Math.max(0, elapsedMs) / 1000));
424
+ }
425
+
426
+ /**
427
+ * Compact token count, e.g. `18.2K`, `2.5M`, `999`. Shared display label for
428
+ * the dashboard header and budget rows.
429
+ */
430
+ export function formatCompactTokens(value: number): string {
431
+ const safe = Math.max(0, Math.floor(value));
432
+ if (safe >= 1_000_000) return `${trimZero((safe / 1_000_000).toFixed(1))}M`;
433
+ if (safe >= 1_000) return `${trimZero((safe / 1_000).toFixed(1))}K`;
434
+ return String(safe);
435
+ }
436
+
437
+ function trimZero(value: string): string {
438
+ return value.replace(/\.0$/, "");
439
+ }
440
+
441
+ /** Budget summary, e.g. `18.2K / 50K · 36%`. */
442
+ export function formatBudget(used: number, total: number): string {
443
+ const pct = total > 0 ? Math.min(100, Math.max(0, Math.round((used / total) * 100))) : 0;
444
+ return `${formatCompactTokens(used)} / ${formatCompactTokens(total)} · ${pct}%`;
445
+ }
446
+
447
+ // ---------------------------------------------------------------------------
448
+ // Whole-model derivation
449
+ // ---------------------------------------------------------------------------
450
+
451
+ const presentationCache = new WeakMap<object, Map<string, {taskProgress: TaskProgress | undefined; taskTree: DashboardTaskNode[]; currentTask: DashboardCurrentTask | undefined; taskTitles: Map<string, string>}>>();
452
+ function taskPresentation(goal: GoalRecord, disabled: boolean) {
453
+ const index = taskIndex(disabled ? [] : goal.taskList?.tasks);
454
+ let entries = presentationCache.get(index);
455
+ if (!entries) { entries = new Map(); presentationCache.set(index, entries); }
456
+ const key = `${disabled}:${goal.currentTaskId ?? ""}`;
457
+ const hit = entries.get(key);
458
+ if (hit) return hit;
459
+ const taskProgress = disabled ? undefined : deriveTopLevelTaskProgress(goal);
460
+ const tree = disabled ? [] : flattenTaskTree(index.tasks);
461
+ const currentTask = disabled ? undefined : deriveCurrentTask(goal, tree);
462
+ const taskTree = tree.map(n => n.id === currentTask?.id ? {...n, isCurrent: true} : n);
463
+ const value = {taskProgress, taskTree, currentTask, taskTitles: new Map(taskTree.map(n => [n.id, n.title]))};
464
+ if (entries.size >= 64) entries.delete(entries.keys().next().value!);
465
+ entries.set(key, value);
466
+ return value;
467
+ }
468
+
469
+ /**
470
+ * Derive the unified dashboard model for one goal. Returns null when there is
471
+ * no goal record; surfaces that need the "no goal / focus required" panel
472
+ * (plan §4.3) render that from their own context with `otherOpenGoals`.
473
+ */
474
+ export function deriveGoalDashboardModel(
475
+ goal: GoalRecord | null,
476
+ options: GoalDashboardModelOptions,
477
+ ): GoalDashboardModel | null {
478
+ if (!goal) return null;
479
+ const { focused, otherOpenGoals, ledgerEvents = [], activityLimit, tasksDisabled = false } = options;
480
+
481
+ const status = deriveGoalStatus(goal);
482
+ if (goal.status === "active" && goal.scheduler?.phase === "interrupted") { status.code = "idle"; status.label = "Waiting for you"; status.footerLabel = "waiting for you"; }
483
+ if (goal.status === "active" && goal.scheduler?.phase === "waiting") { status.code = "idle"; status.label = "Waiting"; status.footerLabel = "waiting"; }
484
+ // §9.5: with tasks disabled, omit task sections entirely (status,
485
+ // verification, usage, path, and focus remain).
486
+ const {taskProgress, taskTree, currentTask, taskTitles} = taskPresentation(goal, tasksDisabled);
487
+
488
+ const budget =
489
+ goal.tokenBudget !== undefined
490
+ ? {
491
+ used: goal.usage.tokensUsed,
492
+ total: goal.tokenBudget,
493
+ percentage: percentageOf(goal.usage.tokensUsed, goal.tokenBudget),
494
+ remaining: Math.max(0, goal.tokenBudget - goal.usage.tokensUsed),
495
+ }
496
+ : undefined;
497
+
498
+ const recentActivity = deriveGoalActivity(ledgerEvents, goal.id, { taskTitles, limit: activityLimit });
499
+
500
+ // Footer-status usage bits (goal-core footerStatus parity): compact
501
+ // duration + compact token count, e.g. `49h49m36s 19M`. The status line
502
+ // renders them inside brackets; empty when the goal has no usage yet.
503
+ const footerUsageBits: string[] = [];
504
+ if (goal.usage.activeSeconds > 0) footerUsageBits.push(formatDuration(goal.usage.activeSeconds));
505
+ if (goal.usage.tokensUsed > 0) footerUsageBits.push(formatCompactTokens(goal.usage.tokensUsed));
506
+
507
+ const scheduling = schedulerSummaryParts(goal.scheduler, options.maxAutonomousRuns, options.showAutonomousRuns !== false, options.idleContinuationBackoff === true, options.commands);
508
+ return {
509
+ scheduling: [scheduling.runs, ...scheduling.instructions.split("\n")].filter(Boolean),
510
+ idlePacing: scheduling.idlePacing,
511
+ goalId: goal.id,
512
+ title: displayObjectiveTitle(goal.objective),
513
+ status,
514
+ focused,
515
+ auditorEnabled: goal.skipAuditor !== true,
516
+ filePath: goal.activePath ?? goal.archivedPath,
517
+ usage: {
518
+ activeSeconds: goal.usage.activeSeconds,
519
+ tokens: goal.usage.tokensUsed,
520
+ footerBits: footerUsageBits.join(" "),
521
+ },
522
+ budget,
523
+ taskProgress,
524
+ taskTree,
525
+ currentTask,
526
+ goalVerificationContract: goal.verificationContract,
527
+ otherOpenGoals,
528
+ recentActivity,
529
+ };
530
+ }