@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,674 @@
1
+ import type { GoalKeybinding } from "../goal-controls.ts";
2
+ import { bindingKeys, commandHint, type GoalCommands } from "../goal-controls.ts";
3
+ /**
4
+ * Unified dashboard renderer (plan §5, §4).
5
+ *
6
+ * Renders the shared dashboard view model (goal-dashboard-model.ts) as the
7
+ * compact above-editor widget, the expanded full dashboard (which replaces the
8
+ * task overlay), and the unfocused panel. Pure presentation: all data comes
9
+ * from the model; this file owns the §5 visual spec (borders, status symbols,
10
+ * progress bars, responsive layouts, width-safe ANSI/Unicode handling) and
11
+ * must never emit a line wider than the requested terminal width.
12
+ *
13
+ * Layout modes (§5.5): wide ≥100, medium 70–99, narrow 50–69, minimal <50.
14
+ */
15
+
16
+ import type { Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
17
+ import { visibleWidth } from "@earendil-works/pi-tui";
18
+ import { truncateToWidth, wrapTextWithAnsi } from "./text-cache.ts";
19
+ import {
20
+ anchoredScrollOffset,
21
+ compactTaskViewportRows,
22
+ deriveTaskListViewport,
23
+ formatAuditElapsed,
24
+ formatBudget,
25
+ type DashboardStatusCode,
26
+ type DashboardTaskNode,
27
+ type GoalDashboardModel,
28
+ type TaskListViewport,
29
+ } from "./goal-dashboard-model.ts";
30
+ import type { GoalActivityItem } from "../goal-activity.ts";
31
+ import { truncateText } from "../goal-core.ts";
32
+ import { DEFAULT_GOAL_KEYBINDINGS, formatGoalKeybinding, type GoalDashboardKeybindings } from "../goal-settings.ts";
33
+
34
+ // mdLink is deliberately excluded: the chrome must stay neutral gray, not
35
+ // blue-tinged ("more grey than blue").
36
+ type RenderColor = Extract<ThemeColor, "accent" | "warning" | "success" | "error" | "dim" | "muted" | "text" | "mdHeading">;
37
+
38
+ // ── §5.1 border system ──────────────────────────────────────────────────────
39
+
40
+ const H = "─";
41
+ const V = "│";
42
+
43
+ /** Box chrome: the entire frame — corners, top/bottom dashes, left/right
44
+ * edges and interior rules — is one tone: the theme's neutral gray (muted).
45
+ * No blue tinge: the chrome is grey, while the content inside is
46
+ * colour-coded. */
47
+ function frame(theme: Theme, value: string): string {
48
+ return theme.fg("muted", value);
49
+ }
50
+
51
+ function fit(value: string, width: number): string {
52
+ return visibleWidth(value) > width ? truncateToWidth(value, width, "…") : value;
53
+ }
54
+
55
+ function boxHeader(theme: Theme, width: number, left: string, right = ""): string {
56
+ const inner = Math.max(4, width - 2);
57
+ // One-tone frame line: corners, leading/trailing dashes and fill all carry
58
+ // the frame color; only the brand inside stays accent.
59
+ const l = `${frame(theme, H)} ${left}`;
60
+ const r = right ? ` ${right} ${frame(theme, H)}` : "";
61
+ const fixed = visibleWidth(l) + visibleWidth(r);
62
+ if (fixed > inner - 2) {
63
+ // Too tight: truncate the title so the right-side meta survives.
64
+ const budget = Math.max(4, inner - visibleWidth(r) - 4);
65
+ const l2 = `${frame(theme, H)} ${fit(left, budget)}`;
66
+ const fill = Math.max(1, inner - visibleWidth(l2) - visibleWidth(r));
67
+ return `${frame(theme, "╭")}${l2}${frame(theme, H.repeat(fill))}${r}${frame(theme, "╮")}`;
68
+ }
69
+ const fill = Math.max(1, inner - fixed);
70
+ return `${frame(theme, "╭")}${l}${frame(theme, H.repeat(fill))}${r}${frame(theme, "╮")}`;
71
+ }
72
+
73
+ function boxLine(theme: Theme, width: number, content: string): string {
74
+ const inner = Math.max(2, width - 2);
75
+ const contentFit = fit(content, inner - 1);
76
+ const pad = Math.max(0, inner - 1 - visibleWidth(contentFit));
77
+ return `${frame(theme, V)} ${contentFit}${" ".repeat(pad)}${frame(theme, V)}`;
78
+ }
79
+
80
+ function boxRule(theme: Theme, width: number): string {
81
+ return frame(theme, `├${H.repeat(Math.max(1, width - 2))}┤`);
82
+ }
83
+
84
+ /** Section separator with a label and optional right-side content:
85
+ * `├─ Tasks ──── [████░] · Sub 2/3 [██░░] ┤` (§5.1). The right content
86
+ * (e.g. the header progress bars) always survives; the label truncates when
87
+ * tight. Hardened so label + right never exceed the inner width: the fill is
88
+ * `max(0, …)` and the truncation budget leaves room for the `─ ` prefix and
89
+ * trailing space. */
90
+ function boxSectionRule(theme: Theme, width: number, label: string, right = ""): string {
91
+ const inner = Math.max(4, width - 2);
92
+ const left = frame(theme, `${H} ${label} `);
93
+ const r = right ? ` ${right} ` : "";
94
+ let l = left;
95
+ if (visibleWidth(left) + visibleWidth(r) > inner) {
96
+ const budget = Math.max(4, inner - visibleWidth(r) - 3);
97
+ l = frame(theme, `${H} ${fit(label, budget)} `);
98
+ }
99
+ const fill = Math.max(0, inner - visibleWidth(l) - visibleWidth(r));
100
+ return `${frame(theme, "├")}${l}${frame(theme, H.repeat(fill))}${r}${frame(theme, "┤")}`;
101
+ }
102
+
103
+ function boxFooter(theme: Theme, width: number, content: string, right = ""): string {
104
+ const inner = Math.max(4, width - 2);
105
+ // Optional right slot mirrors boxHeader's right-meta treatment (` ● ─`
106
+ // before the corner), so the auditor dot sits at the bottom-right of the
107
+ // border. The dot survives; the hint (left content) truncates when tight.
108
+ const r = right ? ` ${right} ${frame(theme, H)}` : "";
109
+ if (!content && !r) {
110
+ return frame(theme, `╰${H.repeat(inner)}╯`);
111
+ }
112
+ // The footer is one frame tone: leading dash, hint text and trailing fill
113
+ // all carry the frame color, so the blue-gray spans the whole line.
114
+ const l = content ? frame(theme, `${H} ${fit(content, Math.max(0, inner - 4 - visibleWidth(r)))}`) : "";
115
+ const fill = Math.max(1, inner - visibleWidth(l) - visibleWidth(r));
116
+ return `${frame(theme, "╰")}${l}${frame(theme, H.repeat(fill))}${r}${frame(theme, "╯")}`;
117
+ }
118
+
119
+ // ── §5.2 status colors ──────────────────────────────────────────────────────
120
+
121
+ const STATUS_COLOR: Record<DashboardStatusCode, RenderColor> = {
122
+ running: "accent",
123
+ idle: "muted",
124
+ paused: "muted",
125
+ blocked: "error",
126
+ budget_limited: "mdHeading",
127
+ complete: "success",
128
+ };
129
+
130
+ /**
131
+ * Widget status line — footer-status parity: `goal: <label> [<usage>] (+N open)`.
132
+ * Same terminology/format as the pi footer status line (goal-core statusLabel +
133
+ * usage bracket), without the objective preview (the header title shows it) and
134
+ * without the old `Focused: yes` / `Other goals: N` bits. The open-goals count
135
+ * appears only when other open goals exist.
136
+ */
137
+ function statusLine(theme: Theme, model: GoalDashboardModel): string {
138
+ const color = STATUS_COLOR[model.status.code];
139
+ const usage = model.usage.footerBits ? ` [${model.usage.footerBits}]` : "";
140
+ const open = model.otherOpenGoals > 0 ? ` (+${model.otherOpenGoals} open)` : "";
141
+ return `${theme.fg(color, `goal: ${model.status.footerLabel}`)}${muted(theme, `${usage}${open}`)}`;
142
+ }
143
+
144
+ // ── §5.3 progress bars ──────────────────────────────────────────────────────
145
+
146
+ function progressBar(theme: Theme, pct: number, barWidth: number): string {
147
+ const safeBar = Math.max(2, barWidth);
148
+ const filled = Math.min(safeBar, Math.max(0, Math.round((pct / 100) * safeBar)));
149
+ // The bar is muted like the rest of the progress info: neutral-gray fill
150
+ // with dim empty cells — no accent teal anywhere in the progress element.
151
+ return `[${theme.fg("muted", "█".repeat(filled))}${theme.fg("dim", "░".repeat(safeBar - filled))}]`;
152
+ }
153
+
154
+ // ── Layout modes (§5.5) ─────────────────────────────────────────────────────
155
+
156
+ type LayoutMode = "wide" | "medium" | "narrow" | "minimal";
157
+
158
+ function layoutMode(width: number): LayoutMode {
159
+ if (width >= 100) return "wide";
160
+ if (width >= 70) return "medium";
161
+ if (width >= 50) return "narrow";
162
+ return "minimal";
163
+ }
164
+
165
+ interface LayoutSpec {
166
+ barWidth: number;
167
+ /** Compact progress bar width inside the Tasks header row (bar at the end). */
168
+ headerBarWidth: number;
169
+ /** Subtask progress bar width inside the Tasks header row, beside the task
170
+ * bar (wide/medium/narrow; minimal omits the segment — unused there). */
171
+ subtaskBarWidth: number;
172
+ showPath: boolean;
173
+ showPauseAction: boolean;
174
+ footerHint: string;
175
+ }
176
+
177
+ function specFor(mode: LayoutMode): LayoutSpec {
178
+ switch (mode) {
179
+ case "wide":
180
+ return { barWidth: 26, headerBarWidth: 8, subtaskBarWidth: 4, showPath: true, showPauseAction: true, footerHint: "Ctrl+Shift+T: expand tasks" };
181
+ case "medium":
182
+ return { barWidth: 18, headerBarWidth: 6, subtaskBarWidth: 3, showPath: true, showPauseAction: true, footerHint: "Ctrl+Shift+T: expand tasks" };
183
+ case "narrow":
184
+ return { barWidth: 12, headerBarWidth: 5, subtaskBarWidth: 2, showPath: false, showPauseAction: false, footerHint: "Ctrl+Shift+T: expand" };
185
+ case "minimal":
186
+ return { barWidth: 8, headerBarWidth: 4, subtaskBarWidth: 2, showPath: false, showPauseAction: false, footerHint: "Ctrl+Shift+T: expand" };
187
+ }
188
+ }
189
+
190
+ // ── shared row helpers ──────────────────────────────────────────────────────
191
+
192
+ function muted(theme: Theme, value: string): string {
193
+ return theme.fg("muted", value);
194
+ }
195
+
196
+ function dim(theme: Theme, value: string): string {
197
+ return theme.fg("dim", value);
198
+ }
199
+
200
+ function accent(theme: Theme, value: string): string {
201
+ return theme.fg("accent", value);
202
+ }
203
+
204
+ function success(theme: Theme, value: string): string {
205
+ return theme.fg("success", value);
206
+ }
207
+
208
+ /** Pastel accent helpers (§5): amber for tasks, teal for current/progress,
209
+ * muted gray for chrome, soft red for blockers, muted green for complete. */
210
+ function amber(theme: Theme, value: string): string {
211
+ return theme.fg("mdHeading", value);
212
+ }
213
+
214
+ // ── task tree rows (§9.2) ───────────────────────────────────────────────────
215
+
216
+ /** Task rows: pending amber ·, complete muted-green ✓, skipped gray ~, and
217
+ * the current task teal ▸ with accent text; row text is pastel amber. */
218
+ function taskMarker(node: DashboardTaskNode): { symbol: string; color: RenderColor } {
219
+ if (node.isCurrent) return { symbol: "▸", color: "accent" };
220
+ if (node.status === "complete") return { symbol: "✓", color: "success" };
221
+ if (node.status === "skipped") return { symbol: "~", color: "muted" };
222
+ return { symbol: "·", color: "mdHeading" };
223
+ }
224
+
225
+ function renderTaskRow(theme: Theme, node: DashboardTaskNode, indent: number, available: number): string {
226
+ const marker = taskMarker(node);
227
+ const indentText = " ".repeat(indent);
228
+ const prefix = `${indentText}${marker.symbol} ${node.id} `;
229
+ const contractMark = node.verificationContract ? dim(theme, " ☑") : "";
230
+ const titleBudget = Math.max(4, available - visibleWidth(prefix) - visibleWidth(contractMark));
231
+ const title = fit(node.title, titleBudget);
232
+ const markerText = theme.fg(marker.color, marker.symbol);
233
+ // Colour-coded: the id shares the marker color (amber pending, green
234
+ // complete, gray skipped, teal current); titles stay amber, the current
235
+ // task is fully accent.
236
+ const idText = node.isCurrent ? accent(theme, node.id) : theme.fg(marker.color, node.id);
237
+ const titleText = node.isCurrent ? accent(theme, title) : amber(theme, title);
238
+ return `${indentText}${markerText} ${idText} ${titleText}${contractMark}`;
239
+ }
240
+
241
+ /**
242
+ * Compact task-list rows (§9.2, §9.6): top-level tasks shown in a window over
243
+ * the plan-ordered list with colored markers and truncated titles, an aligned
244
+ * id column, and `↑ N more` / `… +N more` indicator rows so the widget height
245
+ * stays bounded. The window is a viewport (offset + rows) derived from the
246
+ * shared model — the default (anchored) offset is computed by the caller when
247
+ * no explicit offset is given.
248
+ */
249
+ function renderCompactTaskRows(theme: Theme, nodes: DashboardTaskNode[], viewport: TaskListViewport, available: number): string[] {
250
+ const rows: string[] = [];
251
+ if (viewport.hiddenAbove > 0) {
252
+ rows.push(muted(theme, `↑ ${viewport.hiddenAbove} more task${viewport.hiddenAbove === 1 ? "" : "s"}`));
253
+ }
254
+ const shown = nodes.slice(viewport.offset, viewport.offset + viewport.rows);
255
+ const idWidth = shown.length === 0 ? 0 : Math.min(10, Math.max(...shown.map((node) => node.id.length)));
256
+ for (const node of shown) {
257
+ const marker = taskMarker(node);
258
+ const contractMark = node.verificationContract ? dim(theme, " ☑") : "";
259
+ // §9.3 compact subtask marker: direct-child done/total for tasks that
260
+ // have subtasks, muted so it stays annotation — the current-task bar
261
+ // below carries the visual weight.
262
+ const subtaskMark = node.totalSubtasks > 0 ? muted(theme, ` ▸ ${node.completedSubtasks}/${node.totalSubtasks}`) : "";
263
+ const id = node.id.padEnd(idWidth);
264
+ const prefix = `${marker.symbol} ${id} `;
265
+ const titleBudget = Math.max(4, available - visibleWidth(prefix) - visibleWidth(contractMark) - visibleWidth(subtaskMark));
266
+ const title = fit(node.title, titleBudget);
267
+ const markerText = theme.fg(marker.color, marker.symbol);
268
+ // Colour-coded: id shares the marker color; titles amber; current accent.
269
+ const idText = node.isCurrent ? accent(theme, id) : theme.fg(marker.color, id);
270
+ const body = node.isCurrent ? accent(theme, title) : amber(theme, title);
271
+ rows.push(`${markerText} ${idText} ${body}${contractMark}${subtaskMark}`);
272
+ }
273
+ if (viewport.hiddenBelow > 0) {
274
+ rows.push(muted(theme, `… +${viewport.hiddenBelow} more task${viewport.hiddenBelow === 1 ? "" : "s"}`));
275
+ }
276
+ return rows;
277
+ }
278
+
279
+ // ── activity rows ───────────────────────────────────────────────────────────
280
+
281
+ function activityMarker(item: GoalActivityItem): { symbol: string; color: RenderColor } {
282
+ if (item.marker === "done") return { symbol: "✓", color: "success" };
283
+ if (item.marker === "current") return { symbol: "▸", color: "accent" };
284
+ if (item.marker === "skipped") return { symbol: "~", color: "mdHeading" };
285
+ return { symbol: "·", color: "muted" };
286
+ }
287
+
288
+ // ── COMPACT DASHBOARD (§4.1) ────────────────────────────────────────────────
289
+
290
+ /**
291
+ * Persistent summary above the editor while a goal is focused. Visually rich
292
+ * but compact; the footer hints at the expansion shortcut.
293
+ */
294
+ export function renderCompactDashboard(
295
+ model: GoalDashboardModel,
296
+ theme: Theme,
297
+ width: number,
298
+ opts: { footerHint?: string; scrollOffset?: number; keybindings?: GoalDashboardKeybindings; auditorKey?: GoalKeybinding } = {},
299
+ ): string[] {
300
+ const safeWidth = Math.max(10, width);
301
+ const mode = layoutMode(safeWidth);
302
+ const spec = specFor(mode);
303
+ const inner = safeWidth - 2;
304
+ const lines: string[] = [];
305
+
306
+ lines.push(boxHeader(theme, safeWidth, `${accent(theme, "pi-goal-x")} ${frame(theme, `─ ${model.title}`)}`));
307
+
308
+ // Status line.
309
+ if (model.status.code === "complete") {
310
+ lines.push(boxLine(theme, safeWidth, `${success(theme, "✓")} ${success(theme, "All required work is complete.")}`));
311
+ } else {
312
+ lines.push(boxLine(theme, safeWidth, model.idlePacing ? fit(model.idlePacing, inner) : statusLine(theme, model)));
313
+ }
314
+
315
+ for (const row of model.scheduling ?? []) if (row !== model.idlePacing) lines.push(boxLine(theme, safeWidth, muted(theme, fit(row, inner))));
316
+
317
+ // Budget (when configured): fuel gauge + amount, amber until the budget is
318
+ // exhausted, then soft red.
319
+ if (model.budget) {
320
+ const fuel = model.budget.used >= model.budget.total ? "error" : "mdHeading";
321
+ lines.push(boxLine(theme, safeWidth, `${theme.fg(fuel as RenderColor, "⛽")} ${muted(theme, "Budget")} ${theme.fg(fuel as RenderColor, formatBudget(model.budget.used, model.budget.total))}`));
322
+ }
323
+
324
+ // §auditor-toggle: the focused goal's independent-auditor status is the
325
+ // bottom-right border dot (green on / muted gray off, see the footer
326
+ // below); wide/medium footers right-align a note next to it explaining
327
+ // that Ctrl+Shift+A turns the auditor on and off.
328
+
329
+ // §9.1 compact task counts + progress bars live in the Tasks header row
330
+ // (below); the standalone progress lines were removed in favor of it.
331
+
332
+ // §9.2/§9.6 compact task list: a window over the top-level tasks, anchored
333
+ // by default so the most recently completed tasks are visible; when the
334
+ // list overflows the window, the footer advertises Ctrl+Shift+↑↓ to scroll
335
+ // it. The current task's subtask progress lives in the header segment, and
336
+ // per-row subtask markers show which tasks have subtasks.
337
+ const topLevel = model.taskTree.filter((node) => node.depth === 0);
338
+ const compactRows = compactTaskViewportRows(safeWidth);
339
+ const listOverflows = topLevel.length > compactRows;
340
+ if (topLevel.length > 0) {
341
+ const offset = opts.scrollOffset ?? anchoredScrollOffset(topLevel, compactRows);
342
+ const viewport = deriveTaskListViewport(topLevel.length, compactRows, offset);
343
+ // §9.1/§9.3 header row: counts first (`✓N done · M open`, skipped
344
+ // counts as done), compact task progress bar, then the current task's
345
+ // subtask progress bar beside it (` · Sub done/total `, wide/medium;
346
+ // narrow drops the word `Sub` so the full counts fit at 50 cols;
347
+ // minimal omits the segment — counts + task bar alone cannot share 38
348
+ // inner columns with a second bar). All muted: the header stays one
349
+ // frame-tone block. The standalone compact progress lines were removed
350
+ // in favor of this row.
351
+ const headerLabel = model.taskProgress
352
+ ? `Tasks · ✓${model.taskProgress.completed} done · ${model.taskProgress.total - model.taskProgress.completed} open`
353
+ : "Tasks";
354
+ let headerRight = model.taskProgress ? progressBar(theme, model.taskProgress.percentage, spec.headerBarWidth) : "";
355
+ if (model.currentTask && model.currentTask.totalSubtasks > 0 && mode !== "minimal") {
356
+ const subBar = progressBar(theme, model.currentTask.subtaskPercentage, spec.subtaskBarWidth);
357
+ const subWord = mode === "narrow" ? "" : "Sub ";
358
+ headerRight += `${muted(theme, ` · ${subWord}`)}${muted(theme, `${model.currentTask.completedSubtasks}/${model.currentTask.totalSubtasks}`)} ${subBar}`;
359
+ }
360
+ lines.push(boxSectionRule(theme, safeWidth, headerLabel, headerRight));
361
+ for (const row of renderCompactTaskRows(theme, topLevel, viewport, inner)) {
362
+ lines.push(boxLine(theme, safeWidth, row));
363
+ }
364
+ }
365
+
366
+ // §9.4: all top-level tasks done and no current task → "All tasks complete".
367
+ if (!model.currentTask && model.taskProgress && model.taskProgress.completed === model.taskProgress.total && model.status.code !== "complete") {
368
+ lines.push(boxLine(theme, safeWidth, `${muted(theme, "Current")} ${accent(theme, "All tasks complete")}`));
369
+ }
370
+
371
+ // Current task (persisted focus or inferred first pending).
372
+ if (model.currentTask) {
373
+ if (mode === "minimal") {
374
+ lines.push(boxLine(theme, safeWidth, `${accent(theme, "▸")} ${accent(theme, fit(model.currentTask.title, inner - 3))}`));
375
+ } else {
376
+ const titleBudget = inner - visibleWidth(`Current ${model.currentTask.id} · `);
377
+ lines.push(boxLine(theme, safeWidth, `${muted(theme, "Current")} ${accent(theme, model.currentTask.id)} · ${accent(theme, fit(model.currentTask.title, titleBudget))}`));
378
+ }
379
+ }
380
+
381
+ // Current-task subtask progress moved into the Tasks header row (§9.3);
382
+ // the standalone compact Subtasks progress line was removed. (The
383
+ // expanded dashboard's Current-task block still shows it.)
384
+
385
+ // Goal-level verification (§11.1): truncated first line in compact.
386
+ if (model.goalVerificationContract && mode !== "minimal") {
387
+ lines.push(boxLine(theme, safeWidth, `${muted(theme, "Verify")} ${fit(model.goalVerificationContract.replace(/\s+/g, " "), inner - 9)}`));
388
+ }
389
+
390
+ // Blocked details (§4.5).
391
+ if (model.status.code === "blocked") {
392
+ if (model.status.reason) lines.push(boxLine(theme, safeWidth, `${theme.fg("error", "Blocker")} ${muted(theme, fit(model.status.reason, inner - 10))}`));
393
+ if (spec.showPauseAction && model.status.suggestedAction) lines.push(boxLine(theme, safeWidth, `${muted(theme, "Action")} ${fit(model.status.suggestedAction, inner - 9)}`));
394
+ }
395
+
396
+ // Paused details (§4.4).
397
+ if (model.status.code === "paused" && model.status.reason) {
398
+ lines.push(boxLine(theme, safeWidth, `${muted(theme, "Reason")} ${fit(model.status.reason, inner - 9)}`));
399
+ }
400
+
401
+ // File path (§4.1 example shows the active file).
402
+ if (spec.showPath && model.filePath) {
403
+ lines.push(boxLine(theme, safeWidth, `${dim(theme, `File ${model.filePath}`)}`));
404
+ }
405
+
406
+ const keybindings = opts.keybindings ?? DEFAULT_GOAL_KEYBINDINGS.dashboard;
407
+ const toggleShortcut = formatGoalKeybinding(keybindings.toggleExpand);
408
+ const scrollShortcut = `${formatGoalKeybinding(keybindings.scrollUp)}/${formatGoalKeybinding(keybindings.scrollDown)}`;
409
+ const usesDefaultScrollKeys = keybindings.scrollUp === DEFAULT_GOAL_KEYBINDINGS.dashboard.scrollUp && keybindings.scrollDown === DEFAULT_GOAL_KEYBINDINGS.dashboard.scrollDown;
410
+ const scrollHint = usesDefaultScrollKeys
411
+ ? mode === "minimal" ? "↑↓" : "Ctrl+Shift+↑↓"
412
+ : scrollShortcut;
413
+ const expandHint = !bindingKeys(keybindings.toggleExpand).length ? "" : mode === "wide" || mode === "medium"
414
+ ? `${toggleShortcut}: expand tasks`
415
+ : `${toggleShortcut}: expand`;
416
+ const footerHint = opts.footerHint
417
+ ?? (listOverflows
418
+ ? mode === "wide" || mode === "medium"
419
+ ? `${toggleShortcut}: expand · ${scrollHint}: scroll`
420
+ : `${scrollHint}: scroll`
421
+ : expandHint);
422
+ // The auditor dot sits at the bottom-right of the border: green when the
423
+ // focused goal's independent auditor is on, muted gray when off. In
424
+ // wide/medium a right-aligned note (same frame tone as the hint) explains
425
+ // that Ctrl+Shift+A turns the auditor on and off; narrow/minimal keep
426
+ // just the dot. The boxFooter right slot pushes both to the right edge,
427
+ // so the chord and its note are right-aligned by construction.
428
+ const auditorDot = model.auditorEnabled ? success(theme, "●") : muted(theme, "●");
429
+ const auditorKey = opts.auditorKey ?? "ctrl+shift+a";
430
+ const right =
431
+ (mode === "wide" || mode === "medium") && bindingKeys(auditorKey).length
432
+ ? `${frame(theme, `${formatGoalKeybinding(auditorKey)}: toggle auditor`)} ${auditorDot}`
433
+ : auditorDot;
434
+ lines.push(boxFooter(theme, safeWidth, footerHint, right));
435
+ return lines;
436
+ }
437
+
438
+ // ── EXPANDED DASHBOARD (§4.2) ───────────────────────────────────────────────
439
+
440
+ /**
441
+ * The full dashboard: goal header, status, usage, progress, a window over the
442
+ * task tree, current-task details with contract/evidence, verification, and
443
+ * recent activity. Replaces the separate task overlay. The task-tree window
444
+ * defaults to the whole tree; pass `rows` (and optionally `scrollOffset`) to
445
+ * bound the panel for interactive scrolling — the default offset anchors to
446
+ * the most recently completed task.
447
+ */
448
+ export function renderExpandedDashboard(
449
+ model: GoalDashboardModel,
450
+ theme: Theme,
451
+ width: number,
452
+ opts: { scrollOffset?: number; rows?: number; keybindings?: GoalDashboardKeybindings } = {},
453
+ ): string[] {
454
+ const safeWidth = Math.max(10, width);
455
+ const mode = layoutMode(safeWidth);
456
+ const spec = specFor(mode);
457
+ const inner = safeWidth - 2;
458
+ const lines: string[] = [];
459
+
460
+ lines.push(boxHeader(theme, safeWidth, `${accent(theme, "pi-goal-x")} ${frame(theme, `─ ${model.title}`)}`));
461
+
462
+ lines.push(boxLine(theme, safeWidth, statusLine(theme, model)));
463
+ for (const row of model.scheduling ?? []) lines.push(boxLine(theme, safeWidth, muted(theme, fit(row, inner))));
464
+
465
+ if (spec.showPath && model.filePath) {
466
+ lines.push(boxLine(theme, safeWidth, dim(theme, `File: ${model.filePath}`)));
467
+ }
468
+ if (model.budget) {
469
+ lines.push(boxLine(theme, safeWidth, `${theme.fg("mdHeading", "⛽")} ${muted(theme, `Budget ${formatBudget(model.budget.used, model.budget.total)}`)}`));
470
+ }
471
+
472
+ // Progress section (§4.2).
473
+ if (model.taskProgress) {
474
+ lines.push(boxSectionRule(theme, safeWidth, "Progress"));
475
+ const bar = progressBar(theme, model.taskProgress.percentage, Math.max(10, spec.barWidth + 8));
476
+ lines.push(boxLine(theme, safeWidth, `${bar} ${muted(theme, `${model.taskProgress.completed}/${model.taskProgress.total} tasks · ${model.taskProgress.percentage}%`)}`));
477
+ }
478
+
479
+ // Tasks section: a window over the recursive tree (§9.2, §9.6). With no
480
+ // explicit rows the whole tree is shown (backward compatible); with a row
481
+ // budget the panel stays bounded and ↑/↓ keys move the window.
482
+ if (model.taskTree.length > 0) {
483
+ lines.push(boxSectionRule(theme, safeWidth, "Tasks"));
484
+ const totalRows = model.taskTree.length;
485
+ const rows = opts.rows ?? totalRows;
486
+ const offset = opts.scrollOffset ?? anchoredScrollOffset(model.taskTree, rows);
487
+ const viewport = deriveTaskListViewport(totalRows, rows, offset);
488
+ if (viewport.hiddenAbove > 0) {
489
+ lines.push(boxLine(theme, safeWidth, dim(theme, `↑ ${viewport.hiddenAbove} more task${viewport.hiddenAbove === 1 ? "" : "s"}`)));
490
+ }
491
+ for (const node of model.taskTree.slice(viewport.offset, viewport.offset + viewport.rows)) {
492
+ const indent = Math.min(node.depth, 6);
493
+ lines.push(boxLine(theme, safeWidth, renderTaskRow(theme, node, indent, inner)));
494
+ }
495
+ if (viewport.hiddenBelow > 0) {
496
+ lines.push(boxLine(theme, safeWidth, dim(theme, `… +${viewport.hiddenBelow} more task${viewport.hiddenBelow === 1 ? "" : "s"}`)));
497
+ }
498
+ }
499
+
500
+ // Current-task section (§4.2).
501
+ if (model.currentTask) {
502
+ lines.push(...renderCurrentTaskBlock(model, theme, safeWidth, spec.barWidth));
503
+ }
504
+
505
+ // Goal-level verification section (§11.1).
506
+ if (model.goalVerificationContract) {
507
+ lines.push(boxSectionRule(theme, safeWidth, "Verification"));
508
+ lines.push(...wrappedBlock(theme, safeWidth, "", model.goalVerificationContract));
509
+ }
510
+
511
+ // Recent activity section (§12).
512
+ if (model.recentActivity.length > 0) {
513
+ lines.push(...renderActivityBlock(model.recentActivity, theme, safeWidth));
514
+ }
515
+
516
+ const closeKeys = [opts.keybindings?.collapse ?? "escape", opts.keybindings?.toggleExpand ?? DEFAULT_GOAL_KEYBINDINGS.dashboard.toggleExpand].flatMap(bindingKeys);
517
+ lines.push(boxFooter(theme, safeWidth, closeKeys.length ? `${closeKeys.map(formatGoalKeybinding).join("/")}: collapse` : ""));
518
+ return lines;
519
+ }
520
+
521
+ /**
522
+ * The current-task detail block (§4.2): title, subtask progress, contract,
523
+ * evidence, and the inferred-focus note. Shared by the expanded dashboard and
524
+ * the standard /goal-status output (§13.3).
525
+ */
526
+ export function renderCurrentTaskBlock(
527
+ model: GoalDashboardModel,
528
+ theme: Theme,
529
+ width: number,
530
+ barWidth = 16,
531
+ ): string[] {
532
+ const safeWidth = Math.max(10, width);
533
+ const inner = safeWidth - 2;
534
+ const lines: string[] = [];
535
+ lines.push(boxSectionRule(theme, safeWidth, "Current task"));
536
+ lines.push(boxLine(theme, safeWidth, `${accent(theme, model.currentTask!.id)} · ${fit(model.currentTask!.title, inner - visibleWidth(`${model.currentTask!.id} · `))}`));
537
+ if (model.currentTask!.totalSubtasks > 0) {
538
+ const bar = progressBar(theme, model.currentTask!.subtaskPercentage, Math.max(8, barWidth));
539
+ lines.push(boxLine(theme, safeWidth, `${muted(theme, "Subtasks")} ${bar} ${model.currentTask!.completedSubtasks}/${model.currentTask!.totalSubtasks} · ${model.currentTask!.subtaskPercentage}%`));
540
+ }
541
+ if (model.currentTask!.verificationContract) {
542
+ lines.push(...wrappedBlock(theme, safeWidth, "Contract", model.currentTask!.verificationContract));
543
+ }
544
+ if (model.currentTask!.evidence) {
545
+ lines.push(...wrappedBlock(theme, safeWidth, "Evidence", model.currentTask!.evidence));
546
+ }
547
+ if (model.currentTask!.inferred) {
548
+ lines.push(boxLine(theme, safeWidth, dim(theme, "Inferred from the first pending task — no persisted current task.")));
549
+ }
550
+ return lines;
551
+ }
552
+
553
+ /** The recent-activity block (§12), shared by the expanded dashboard and /goal-status. */
554
+ export function renderActivityBlock(items: GoalActivityItem[], theme: Theme, width: number): string[] {
555
+ const safeWidth = Math.max(10, width);
556
+ const inner = safeWidth - 2;
557
+ const lines: string[] = [];
558
+ lines.push(boxSectionRule(theme, safeWidth, "Recent activity"));
559
+ for (const item of items) {
560
+ const marker = activityMarker(item);
561
+ const text = fit(item.text, inner - 4);
562
+ lines.push(boxLine(theme, safeWidth, `${theme.fg(marker.color, marker.symbol)} ${text}`));
563
+ }
564
+ return lines;
565
+ }
566
+
567
+ /** Wrap long contract/evidence text safely at the inner width (§11.1). */
568
+ function wrappedBlock(theme: Theme, width: number, label: string, text: string): string[] {
569
+ const inner = Math.max(4, width - 2);
570
+ const prefix = label ? `${label}: ` : "";
571
+ const indent = " ".repeat(visibleWidth(prefix));
572
+ const wrapped = wrapTextWithAnsi(text.replace(/\s+/g, " ").trim(), Math.max(8, inner - visibleWidth(prefix)));
573
+ const lines: string[] = [];
574
+ for (const [index, segment] of wrapped.entries()) {
575
+ const content = index === 0 ? `${prefix}${segment}` : `${indent}${segment}`;
576
+ lines.push(boxLine(theme, width, muted(theme, content)));
577
+ }
578
+ return lines;
579
+ }
580
+
581
+ // ── UNFOCUSED PANEL (§4.3) ──────────────────────────────────────────────────
582
+
583
+ /**
584
+ * Shown when open goals exist but none is focused. The widget's default
585
+ * no-goal branch renders nothing (surfaces without goals).
586
+ */
587
+ export function renderUnfocusedDashboard(openGoalCount: number, theme: Theme, width: number, commands?: GoalCommands): string[] {
588
+ const safeWidth = Math.max(10, width);
589
+ const lines: string[] = [];
590
+ lines.push(boxHeader(theme, safeWidth, frame(theme, "pi-goal-x ─ Goal focus required")));
591
+ const goals = openGoalCount === 1 ? "1 open goal is available." : `${openGoalCount} open goals are available.`;
592
+ lines.push(boxLine(theme, safeWidth, muted(theme, goals)));
593
+ lines.push(boxLine(theme, safeWidth, muted(theme, `Run ${commandHint("goal-focus", commands)} to choose the goal for this session.`)));
594
+ lines.push(boxFooter(theme, safeWidth, ""));
595
+ return lines;
596
+ }
597
+
598
+ // ── width safety net (defense in depth) ─────────────────────────────────────
599
+
600
+ /** Clamp every rendered line to the terminal width (used by the component). */
601
+ export function clampLinesToWidth(lines: string[], width: number): string[] {
602
+ return lines.map((line) => (visibleWidth(line) > width ? truncateToWidth(line, width, "…") : line));
603
+ }
604
+
605
+ // ── AUDIT DASHBOARD (§15) ───────────────────────────────────────────────────
606
+
607
+ import type { AuditorDashboardModel, AuditResultCard } from "./auditor-dashboard-model.ts";
608
+
609
+ const CHECK_SYMBOL = { passed: "✓", running: "◌", pending: "·", failed: "✗" } as const;
610
+ const CHECK_COLOR = { passed: "success", running: "accent", pending: "muted", failed: "error" } as const;
611
+
612
+ /**
613
+ * Structured audit dashboard (§15.3): five check stages, a progress bar, and
614
+ * the elapsed duration, using the same visual system as the goal dashboard.
615
+ * Raw tools and recent output appear only when showToolDetails is set
616
+ * (expanded/debug audit mode) or when the audit finished with a failure.
617
+ */
618
+ export function renderAuditorDashboard(
619
+ model: AuditorDashboardModel,
620
+ theme: Theme,
621
+ width: number,
622
+ opts: { showToolDetails?: boolean; interruptKey?: GoalKeybinding } = {},
623
+ ): string[] {
624
+ const safeWidth = Math.max(10, width);
625
+ const inner = safeWidth - 2;
626
+ const lines: string[] = [];
627
+ const duration = formatAuditElapsed(model.elapsedMs);
628
+ lines.push(boxHeader(theme, safeWidth, frame(theme, `Independent completion audit ─ ${model.auditorLabel}`), frame(theme, duration)));
629
+
630
+ for (const check of model.checks) {
631
+ const symbol = CHECK_SYMBOL[check.state];
632
+ const color = CHECK_COLOR[check.state];
633
+ lines.push(boxLine(theme, safeWidth, `${theme.fg(color as RenderColor, symbol)} ${check.label}`));
634
+ }
635
+
636
+ if (model.percentage !== undefined) {
637
+ const barWidth = Math.max(8, Math.min(inner - 12, 30));
638
+ const bar = progressBar(theme, model.percentage, barWidth);
639
+ lines.push(boxLine(theme, safeWidth, `${bar} ${theme.fg("muted", `${model.percentage}%`)}`));
640
+ }
641
+
642
+ const showDiagnostics = opts.showToolDetails === true || model.verdict === "disapproved" || model.verdict === "error";
643
+ if (showDiagnostics) {
644
+ if (model.currentTool) {
645
+ const args = model.currentToolArgs ? ` ${dim(theme, truncateText(model.currentToolArgs, Math.max(10, inner - 20)))}` : "";
646
+ lines.push(boxLine(theme, safeWidth, `${accent(theme, "tool")} ${model.currentTool}${args}`));
647
+ }
648
+ if (model.recentOutput.length > 0) {
649
+ lines.push(boxLine(theme, safeWidth, dim(theme, "─".repeat(Math.max(4, inner - 8)))));
650
+ for (const output of model.recentOutput.slice(0, 3)) {
651
+ lines.push(boxLine(theme, safeWidth, dim(theme, truncateText(output, Math.max(8, inner - 4)))));
652
+ }
653
+ }
654
+ }
655
+
656
+ lines.push(boxFooter(theme, safeWidth, model.active && bindingKeys(opts.interruptKey ?? "escape").length ? `${formatGoalKeybinding(opts.interruptKey ?? "escape")}: stop audit` : ""));
657
+ return lines;
658
+ }
659
+
660
+ /** Audit result card (§15.4): APPROVED or CHANGES REQUIRED / ERROR. */
661
+ export function renderAuditResultCard(card: AuditResultCard, theme: Theme, width: number): string[] {
662
+ const safeWidth = Math.max(10, width);
663
+ const inner = safeWidth - 2;
664
+ const lines: string[] = [];
665
+ const success = card.verdict === "approved";
666
+ const accentColor = success ? "success" : "error";
667
+ lines.push(boxHeader(theme, safeWidth, frame(theme, `Audit result ─ ${card.label}`)));
668
+ for (const line of card.lines) {
669
+ const symbol = success ? "✓" : "✗";
670
+ lines.push(boxLine(theme, safeWidth, `${theme.fg(accentColor as RenderColor, symbol)} ${fit(line, inner - 4)}`));
671
+ }
672
+ lines.push(boxFooter(theme, safeWidth, ""));
673
+ return lines;
674
+ }