gsd-pi 2.18.0 → 2.19.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.
Files changed (73) hide show
  1. package/dist/resources/extensions/gsd/auto-dashboard.ts +14 -2
  2. package/dist/resources/extensions/gsd/auto-prompts.ts +45 -15
  3. package/dist/resources/extensions/gsd/auto.ts +276 -19
  4. package/dist/resources/extensions/gsd/captures.ts +384 -0
  5. package/dist/resources/extensions/gsd/commands.ts +139 -3
  6. package/dist/resources/extensions/gsd/complexity-classifier.ts +322 -0
  7. package/dist/resources/extensions/gsd/dashboard-overlay.ts +10 -0
  8. package/dist/resources/extensions/gsd/metrics.ts +48 -0
  9. package/dist/resources/extensions/gsd/model-cost-table.ts +65 -0
  10. package/dist/resources/extensions/gsd/model-router.ts +256 -0
  11. package/dist/resources/extensions/gsd/post-unit-hooks.ts +2 -1
  12. package/dist/resources/extensions/gsd/preferences.ts +73 -0
  13. package/dist/resources/extensions/gsd/prompt-loader.ts +45 -9
  14. package/dist/resources/extensions/gsd/prompts/reassess-roadmap.md +6 -0
  15. package/dist/resources/extensions/gsd/prompts/replan-slice.md +8 -0
  16. package/dist/resources/extensions/gsd/prompts/triage-captures.md +62 -0
  17. package/dist/resources/extensions/gsd/tests/captures.test.ts +438 -0
  18. package/dist/resources/extensions/gsd/tests/complexity-classifier.test.ts +181 -0
  19. package/dist/resources/extensions/gsd/tests/feature-branch-lifecycle-integration.test.ts +434 -0
  20. package/dist/resources/extensions/gsd/tests/milestone-transition-worktree.test.ts +144 -0
  21. package/dist/resources/extensions/gsd/tests/model-cost-table.test.ts +69 -0
  22. package/dist/resources/extensions/gsd/tests/model-router.test.ts +167 -0
  23. package/dist/resources/extensions/gsd/tests/remote-questions.test.ts +227 -1
  24. package/dist/resources/extensions/gsd/tests/routing-history.test.ts +215 -62
  25. package/dist/resources/extensions/gsd/tests/triage-dispatch.test.ts +224 -0
  26. package/dist/resources/extensions/gsd/tests/triage-resolution.test.ts +215 -0
  27. package/dist/resources/extensions/gsd/tests/visualizer-data.test.ts +198 -0
  28. package/dist/resources/extensions/gsd/tests/visualizer-views.test.ts +255 -0
  29. package/dist/resources/extensions/gsd/triage-resolution.ts +200 -0
  30. package/dist/resources/extensions/gsd/triage-ui.ts +175 -0
  31. package/dist/resources/extensions/gsd/visualizer-data.ts +154 -0
  32. package/dist/resources/extensions/gsd/visualizer-overlay.ts +193 -0
  33. package/dist/resources/extensions/gsd/visualizer-views.ts +293 -0
  34. package/dist/resources/extensions/remote-questions/discord-adapter.ts +33 -0
  35. package/dist/resources/extensions/remote-questions/format.ts +12 -6
  36. package/dist/resources/extensions/remote-questions/manager.ts +8 -0
  37. package/package.json +1 -1
  38. package/src/resources/extensions/gsd/auto-dashboard.ts +14 -2
  39. package/src/resources/extensions/gsd/auto-prompts.ts +45 -15
  40. package/src/resources/extensions/gsd/auto.ts +276 -19
  41. package/src/resources/extensions/gsd/captures.ts +384 -0
  42. package/src/resources/extensions/gsd/commands.ts +139 -3
  43. package/src/resources/extensions/gsd/complexity-classifier.ts +322 -0
  44. package/src/resources/extensions/gsd/dashboard-overlay.ts +10 -0
  45. package/src/resources/extensions/gsd/metrics.ts +48 -0
  46. package/src/resources/extensions/gsd/model-cost-table.ts +65 -0
  47. package/src/resources/extensions/gsd/model-router.ts +256 -0
  48. package/src/resources/extensions/gsd/post-unit-hooks.ts +2 -1
  49. package/src/resources/extensions/gsd/preferences.ts +73 -0
  50. package/src/resources/extensions/gsd/prompt-loader.ts +45 -9
  51. package/src/resources/extensions/gsd/prompts/reassess-roadmap.md +6 -0
  52. package/src/resources/extensions/gsd/prompts/replan-slice.md +8 -0
  53. package/src/resources/extensions/gsd/prompts/triage-captures.md +62 -0
  54. package/src/resources/extensions/gsd/tests/captures.test.ts +438 -0
  55. package/src/resources/extensions/gsd/tests/complexity-classifier.test.ts +181 -0
  56. package/src/resources/extensions/gsd/tests/feature-branch-lifecycle-integration.test.ts +434 -0
  57. package/src/resources/extensions/gsd/tests/milestone-transition-worktree.test.ts +144 -0
  58. package/src/resources/extensions/gsd/tests/model-cost-table.test.ts +69 -0
  59. package/src/resources/extensions/gsd/tests/model-router.test.ts +167 -0
  60. package/src/resources/extensions/gsd/tests/remote-questions.test.ts +227 -1
  61. package/src/resources/extensions/gsd/tests/routing-history.test.ts +215 -62
  62. package/src/resources/extensions/gsd/tests/triage-dispatch.test.ts +224 -0
  63. package/src/resources/extensions/gsd/tests/triage-resolution.test.ts +215 -0
  64. package/src/resources/extensions/gsd/tests/visualizer-data.test.ts +198 -0
  65. package/src/resources/extensions/gsd/tests/visualizer-views.test.ts +255 -0
  66. package/src/resources/extensions/gsd/triage-resolution.ts +200 -0
  67. package/src/resources/extensions/gsd/triage-ui.ts +175 -0
  68. package/src/resources/extensions/gsd/visualizer-data.ts +154 -0
  69. package/src/resources/extensions/gsd/visualizer-overlay.ts +193 -0
  70. package/src/resources/extensions/gsd/visualizer-views.ts +293 -0
  71. package/src/resources/extensions/remote-questions/discord-adapter.ts +33 -0
  72. package/src/resources/extensions/remote-questions/format.ts +12 -6
  73. package/src/resources/extensions/remote-questions/manager.ts +8 -0
@@ -0,0 +1,193 @@
1
+ import type { Theme } from "@gsd/pi-coding-agent";
2
+ import { truncateToWidth, visibleWidth, matchesKey, Key } from "@gsd/pi-tui";
3
+ import { loadVisualizerData, type VisualizerData } from "./visualizer-data.js";
4
+ import {
5
+ renderProgressView,
6
+ renderDepsView,
7
+ renderMetricsView,
8
+ renderTimelineView,
9
+ } from "./visualizer-views.js";
10
+
11
+ const TAB_LABELS = ["1 Progress", "2 Deps", "3 Metrics", "4 Timeline"];
12
+
13
+ export class GSDVisualizerOverlay {
14
+ private tui: { requestRender: () => void };
15
+ private theme: Theme;
16
+ private onClose: () => void;
17
+
18
+ activeTab = 0;
19
+ scrollOffsets: number[] = [0, 0, 0, 0];
20
+ loading = true;
21
+ disposed = false;
22
+ cachedWidth?: number;
23
+ cachedLines?: string[];
24
+ refreshTimer: ReturnType<typeof setInterval>;
25
+ data: VisualizerData | null = null;
26
+ basePath: string;
27
+
28
+ constructor(
29
+ tui: { requestRender: () => void },
30
+ theme: Theme,
31
+ onClose: () => void,
32
+ ) {
33
+ this.tui = tui;
34
+ this.theme = theme;
35
+ this.onClose = onClose;
36
+ this.basePath = process.cwd();
37
+
38
+ loadVisualizerData(this.basePath).then((d) => {
39
+ this.data = d;
40
+ this.loading = false;
41
+ this.tui.requestRender();
42
+ });
43
+
44
+ this.refreshTimer = setInterval(() => {
45
+ loadVisualizerData(this.basePath).then((d) => {
46
+ if (this.disposed) return;
47
+ this.data = d;
48
+ this.invalidate();
49
+ this.tui.requestRender();
50
+ });
51
+ }, 2000);
52
+ }
53
+
54
+ handleInput(data: string): void {
55
+ if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
56
+ this.dispose();
57
+ this.onClose();
58
+ return;
59
+ }
60
+
61
+ if (matchesKey(data, Key.tab)) {
62
+ this.activeTab = (this.activeTab + 1) % 4;
63
+ this.invalidate();
64
+ this.tui.requestRender();
65
+ return;
66
+ }
67
+
68
+ if (data === "1" || data === "2" || data === "3" || data === "4") {
69
+ this.activeTab = parseInt(data, 10) - 1;
70
+ this.invalidate();
71
+ this.tui.requestRender();
72
+ return;
73
+ }
74
+
75
+ if (matchesKey(data, Key.down) || matchesKey(data, "j")) {
76
+ this.scrollOffsets[this.activeTab]++;
77
+ this.invalidate();
78
+ this.tui.requestRender();
79
+ return;
80
+ }
81
+
82
+ if (matchesKey(data, Key.up) || matchesKey(data, "k")) {
83
+ this.scrollOffsets[this.activeTab] = Math.max(0, this.scrollOffsets[this.activeTab] - 1);
84
+ this.invalidate();
85
+ this.tui.requestRender();
86
+ return;
87
+ }
88
+
89
+ if (data === "g") {
90
+ this.scrollOffsets[this.activeTab] = 0;
91
+ this.invalidate();
92
+ this.tui.requestRender();
93
+ return;
94
+ }
95
+
96
+ if (data === "G") {
97
+ this.scrollOffsets[this.activeTab] = 999;
98
+ this.invalidate();
99
+ this.tui.requestRender();
100
+ return;
101
+ }
102
+ }
103
+
104
+ render(width: number): string[] {
105
+ if (this.cachedLines && this.cachedWidth === width) {
106
+ return this.cachedLines;
107
+ }
108
+
109
+ const th = this.theme;
110
+ const innerWidth = width - 4;
111
+ const content: string[] = [];
112
+
113
+ // Tab bar
114
+ const tabs = TAB_LABELS.map((label, i) => {
115
+ if (i === this.activeTab) {
116
+ return th.fg("accent", `[${label}]`);
117
+ }
118
+ return th.fg("dim", `[${label}]`);
119
+ });
120
+ content.push(" " + tabs.join(" "));
121
+ content.push("");
122
+
123
+ if (this.loading) {
124
+ const loadingText = "Loading…";
125
+ const vis = visibleWidth(loadingText);
126
+ const leftPad = Math.max(0, Math.floor((innerWidth - vis) / 2));
127
+ content.push(" ".repeat(leftPad) + loadingText);
128
+ } else if (this.data) {
129
+ let viewLines: string[] = [];
130
+ switch (this.activeTab) {
131
+ case 0:
132
+ viewLines = renderProgressView(this.data, th, innerWidth);
133
+ break;
134
+ case 1:
135
+ viewLines = renderDepsView(this.data, th, innerWidth);
136
+ break;
137
+ case 2:
138
+ viewLines = renderMetricsView(this.data, th, innerWidth);
139
+ break;
140
+ case 3:
141
+ viewLines = renderTimelineView(this.data, th, innerWidth);
142
+ break;
143
+ }
144
+ content.push(...viewLines);
145
+ }
146
+
147
+ // Apply scroll
148
+ const viewportHeight = Math.max(5, process.stdout.rows ? process.stdout.rows - 8 : 24);
149
+ const chromeHeight = 2;
150
+ const visibleContentRows = Math.max(1, viewportHeight - chromeHeight);
151
+ const maxScroll = Math.max(0, content.length - visibleContentRows);
152
+ this.scrollOffsets[this.activeTab] = Math.min(this.scrollOffsets[this.activeTab], maxScroll);
153
+ const offset = this.scrollOffsets[this.activeTab];
154
+ const visibleContent = content.slice(offset, offset + visibleContentRows);
155
+
156
+ const lines = this.wrapInBox(visibleContent, width);
157
+
158
+ // Footer hint
159
+ const hint = th.fg("dim", "Tab/1-4 switch · ↑↓ scroll · g/G top/end · esc close");
160
+ const hintVis = visibleWidth(hint);
161
+ const hintPad = Math.max(0, Math.floor((width - hintVis) / 2));
162
+ lines.push(" ".repeat(hintPad) + hint);
163
+
164
+ this.cachedWidth = width;
165
+ this.cachedLines = lines;
166
+ return lines;
167
+ }
168
+
169
+ private wrapInBox(inner: string[], width: number): string[] {
170
+ const th = this.theme;
171
+ const border = (s: string) => th.fg("borderAccent", s);
172
+ const innerWidth = width - 4;
173
+ const lines: string[] = [];
174
+ lines.push(border("╭" + "─".repeat(width - 2) + "╮"));
175
+ for (const line of inner) {
176
+ const truncated = truncateToWidth(line, innerWidth);
177
+ const padWidth = Math.max(0, innerWidth - visibleWidth(truncated));
178
+ lines.push(border("│") + " " + truncated + " ".repeat(padWidth) + " " + border("│"));
179
+ }
180
+ lines.push(border("╰" + "─".repeat(width - 2) + "╯"));
181
+ return lines;
182
+ }
183
+
184
+ invalidate(): void {
185
+ this.cachedWidth = undefined;
186
+ this.cachedLines = undefined;
187
+ }
188
+
189
+ dispose(): void {
190
+ this.disposed = true;
191
+ clearInterval(this.refreshTimer);
192
+ }
193
+ }
@@ -0,0 +1,293 @@
1
+ // View renderers for the GSD workflow visualizer overlay.
2
+
3
+ import type { Theme } from "@gsd/pi-coding-agent";
4
+ import { truncateToWidth, visibleWidth } from "@gsd/pi-tui";
5
+ import type { VisualizerData, VisualizerMilestone } from "./visualizer-data.js";
6
+ import { formatCost, formatTokenCount } from "./metrics.js";
7
+
8
+ // ─── Local Helpers ───────────────────────────────────────────────────────────
9
+
10
+ function formatDuration(ms: number): string {
11
+ const s = Math.floor(ms / 1000);
12
+ if (s < 60) return `${s}s`;
13
+ const m = Math.floor(s / 60);
14
+ const rs = s % 60;
15
+ if (m < 60) return `${m}m ${rs}s`;
16
+ const h = Math.floor(m / 60);
17
+ const rm = m % 60;
18
+ return `${h}h ${rm}m`;
19
+ }
20
+
21
+ function padRight(content: string, width: number): string {
22
+ const vis = visibleWidth(content);
23
+ return content + " ".repeat(Math.max(0, width - vis));
24
+ }
25
+
26
+ function joinColumns(left: string, right: string, width: number): string {
27
+ const leftW = visibleWidth(left);
28
+ const rightW = visibleWidth(right);
29
+ if (leftW + rightW + 2 > width) {
30
+ return truncateToWidth(`${left} ${right}`, width);
31
+ }
32
+ return left + " ".repeat(width - leftW - rightW) + right;
33
+ }
34
+
35
+ // ─── Progress View ───────────────────────────────────────────────────────────
36
+
37
+ export function renderProgressView(
38
+ data: VisualizerData,
39
+ th: Theme,
40
+ width: number,
41
+ ): string[] {
42
+ const lines: string[] = [];
43
+
44
+ for (const ms of data.milestones) {
45
+ // Milestone header line
46
+ const statusGlyph =
47
+ ms.status === "complete"
48
+ ? th.fg("success", "✓")
49
+ : ms.status === "active"
50
+ ? th.fg("accent", "▸")
51
+ : th.fg("dim", "○");
52
+ const statusLabel =
53
+ ms.status === "complete"
54
+ ? th.fg("success", "complete")
55
+ : ms.status === "active"
56
+ ? th.fg("accent", "active")
57
+ : th.fg("dim", "pending");
58
+ const msLeft = `${ms.id}: ${ms.title}`;
59
+ const msRight = `${statusGlyph} ${statusLabel}`;
60
+ lines.push(joinColumns(msLeft, msRight, width));
61
+
62
+ if (ms.slices.length === 0 && ms.dependsOn.length > 0) {
63
+ lines.push(th.fg("dim", ` (depends on ${ms.dependsOn.join(", ")})`));
64
+ continue;
65
+ }
66
+
67
+ if (ms.status === "pending" && ms.dependsOn.length > 0) {
68
+ lines.push(th.fg("dim", ` (depends on ${ms.dependsOn.join(", ")})`));
69
+ continue;
70
+ }
71
+
72
+ for (const sl of ms.slices) {
73
+ // Slice line
74
+ const slGlyph = sl.done
75
+ ? th.fg("success", "✓")
76
+ : sl.active
77
+ ? th.fg("accent", "▸")
78
+ : th.fg("dim", "○");
79
+ const riskColor =
80
+ sl.risk === "high"
81
+ ? "warning"
82
+ : sl.risk === "medium"
83
+ ? "text"
84
+ : "dim";
85
+ const riskBadge = th.fg(riskColor, sl.risk);
86
+ const slLeft = ` ${slGlyph} ${sl.id}: ${sl.title}`;
87
+ lines.push(joinColumns(slLeft, riskBadge, width));
88
+
89
+ // Show tasks for active slice
90
+ if (sl.active && sl.tasks.length > 0) {
91
+ for (const task of sl.tasks) {
92
+ const tGlyph = task.done
93
+ ? th.fg("success", "✓")
94
+ : task.active
95
+ ? th.fg("accent", "▸")
96
+ : th.fg("dim", "○");
97
+ lines.push(` ${tGlyph} ${task.id}: ${task.title}`);
98
+ }
99
+ }
100
+ }
101
+ }
102
+
103
+ return lines;
104
+ }
105
+
106
+ // ─── Dependencies View ───────────────────────────────────────────────────────
107
+
108
+ export function renderDepsView(
109
+ data: VisualizerData,
110
+ th: Theme,
111
+ width: number,
112
+ ): string[] {
113
+ const lines: string[] = [];
114
+
115
+ // Milestone Dependencies
116
+ lines.push(th.fg("accent", th.bold("Milestone Dependencies")));
117
+ lines.push("");
118
+
119
+ const msDeps = data.milestones.filter((ms) => ms.dependsOn.length > 0);
120
+ if (msDeps.length === 0) {
121
+ lines.push(th.fg("dim", " No milestone dependencies."));
122
+ } else {
123
+ for (const ms of msDeps) {
124
+ for (const dep of ms.dependsOn) {
125
+ lines.push(
126
+ ` ${th.fg("text", dep)} ${th.fg("accent", "──►")} ${th.fg("text", ms.id)}`,
127
+ );
128
+ }
129
+ }
130
+ }
131
+
132
+ lines.push("");
133
+
134
+ // Slice Dependencies (active milestone)
135
+ lines.push(th.fg("accent", th.bold("Slice Dependencies (active milestone)")));
136
+ lines.push("");
137
+
138
+ const activeMs = data.milestones.find((ms) => ms.status === "active");
139
+ if (!activeMs) {
140
+ lines.push(th.fg("dim", " No active milestone."));
141
+ } else {
142
+ const slDeps = activeMs.slices.filter((sl) => sl.depends.length > 0);
143
+ if (slDeps.length === 0) {
144
+ lines.push(th.fg("dim", " No slice dependencies."));
145
+ } else {
146
+ for (const sl of slDeps) {
147
+ for (const dep of sl.depends) {
148
+ lines.push(
149
+ ` ${th.fg("text", dep)} ${th.fg("accent", "──►")} ${th.fg("text", sl.id)}`,
150
+ );
151
+ }
152
+ }
153
+ }
154
+ }
155
+
156
+ return lines;
157
+ }
158
+
159
+ // ─── Metrics View ────────────────────────────────────────────────────────────
160
+
161
+ export function renderMetricsView(
162
+ data: VisualizerData,
163
+ th: Theme,
164
+ width: number,
165
+ ): string[] {
166
+ const lines: string[] = [];
167
+
168
+ if (data.totals === null) {
169
+ lines.push(th.fg("dim", "No metrics data available."));
170
+ return lines;
171
+ }
172
+
173
+ const totals = data.totals;
174
+
175
+ // Summary line
176
+ lines.push(
177
+ th.fg("accent", th.bold("Summary")),
178
+ );
179
+ lines.push(
180
+ ` Cost: ${th.fg("text", formatCost(totals.cost))} ` +
181
+ `Tokens: ${th.fg("text", formatTokenCount(totals.tokens.total))} ` +
182
+ `Units: ${th.fg("text", String(totals.units))}`,
183
+ );
184
+ lines.push("");
185
+
186
+ const barWidth = Math.max(10, width - 40);
187
+
188
+ // By Phase
189
+ if (data.byPhase.length > 0) {
190
+ lines.push(th.fg("accent", th.bold("By Phase")));
191
+ lines.push("");
192
+
193
+ const maxPhaseCost = Math.max(...data.byPhase.map((p) => p.cost));
194
+
195
+ for (const phase of data.byPhase) {
196
+ const pct = totals.cost > 0 ? (phase.cost / totals.cost) * 100 : 0;
197
+ const fillLen =
198
+ maxPhaseCost > 0
199
+ ? Math.round((phase.cost / maxPhaseCost) * barWidth)
200
+ : 0;
201
+ const bar =
202
+ th.fg("accent", "█".repeat(fillLen)) +
203
+ th.fg("dim", "░".repeat(barWidth - fillLen));
204
+ const label = padRight(phase.phase, 14);
205
+ const costStr = formatCost(phase.cost);
206
+ const pctStr = `${pct.toFixed(1)}%`;
207
+ const tokenStr = formatTokenCount(phase.tokens.total);
208
+ lines.push(` ${label} ${bar} ${costStr} ${pctStr} ${tokenStr}`);
209
+ }
210
+
211
+ lines.push("");
212
+ }
213
+
214
+ // By Model
215
+ if (data.byModel.length > 0) {
216
+ lines.push(th.fg("accent", th.bold("By Model")));
217
+ lines.push("");
218
+
219
+ const maxModelCost = Math.max(...data.byModel.map((m) => m.cost));
220
+
221
+ for (const model of data.byModel) {
222
+ const pct = totals.cost > 0 ? (model.cost / totals.cost) * 100 : 0;
223
+ const fillLen =
224
+ maxModelCost > 0
225
+ ? Math.round((model.cost / maxModelCost) * barWidth)
226
+ : 0;
227
+ const bar =
228
+ th.fg("accent", "█".repeat(fillLen)) +
229
+ th.fg("dim", "░".repeat(barWidth - fillLen));
230
+ const label = padRight(model.model, 20);
231
+ const costStr = formatCost(model.cost);
232
+ const pctStr = `${pct.toFixed(1)}%`;
233
+ lines.push(` ${label} ${bar} ${costStr} ${pctStr}`);
234
+ }
235
+ }
236
+
237
+ return lines;
238
+ }
239
+
240
+ // ─── Timeline View ──────────────────────────────────────────────────────────
241
+
242
+ export function renderTimelineView(
243
+ data: VisualizerData,
244
+ th: Theme,
245
+ width: number,
246
+ ): string[] {
247
+ const lines: string[] = [];
248
+
249
+ if (data.units.length === 0) {
250
+ lines.push(th.fg("dim", "No execution history."));
251
+ return lines;
252
+ }
253
+
254
+ // Show up to 20 most recent (units are sorted by startedAt asc, show most recent)
255
+ const recent = data.units.slice(-20).reverse();
256
+
257
+ const maxDuration = Math.max(
258
+ ...recent.map((u) => u.finishedAt - u.startedAt),
259
+ );
260
+ const timeBarWidth = Math.max(4, Math.min(12, width - 60));
261
+
262
+ for (const unit of recent) {
263
+ const dt = new Date(unit.startedAt);
264
+ const hh = String(dt.getHours()).padStart(2, "0");
265
+ const mm = String(dt.getMinutes()).padStart(2, "0");
266
+ const time = `${hh}:${mm}`;
267
+
268
+ const duration = unit.finishedAt - unit.startedAt;
269
+ const glyph =
270
+ unit.finishedAt > 0
271
+ ? th.fg("success", "✓")
272
+ : th.fg("accent", "▸");
273
+
274
+ const typeLabel = padRight(unit.type, 16);
275
+ const idLabel = padRight(unit.id, 14);
276
+
277
+ const fillLen =
278
+ maxDuration > 0
279
+ ? Math.round((duration / maxDuration) * timeBarWidth)
280
+ : 0;
281
+ const bar =
282
+ th.fg("accent", "█".repeat(fillLen)) +
283
+ th.fg("dim", "░".repeat(timeBarWidth - fillLen));
284
+
285
+ const durStr = formatDuration(duration);
286
+ const costStr = formatCost(unit.cost);
287
+
288
+ const line = ` ${time} ${glyph} ${typeLabel} ${idLabel} ${bar} ${durStr} ${costStr}`;
289
+ lines.push(truncateToWidth(line, width));
290
+ }
291
+
292
+ return lines;
293
+ }
@@ -12,6 +12,7 @@ const NUMBER_EMOJIS = ["1️⃣", "2️⃣", "3️⃣", "4️⃣", "5️⃣"];
12
12
  export class DiscordAdapter implements ChannelAdapter {
13
13
  readonly name = "discord" as const;
14
14
  private botUserId: string | null = null;
15
+ private guildId: string | null = null;
15
16
  private readonly token: string;
16
17
  private readonly channelId: string;
17
18
 
@@ -24,6 +25,17 @@ export class DiscordAdapter implements ChannelAdapter {
24
25
  const res = await this.discordApi("GET", "/users/@me");
25
26
  if (!res.id) throw new Error("Discord auth failed: invalid token");
26
27
  this.botUserId = String(res.id);
28
+
29
+ // Resolve guild ID for message URL generation.
30
+ // The channel belongs to a guild — fetch channel info to discover it.
31
+ try {
32
+ const channelInfo = await this.discordApi("GET", `/channels/${this.channelId}`);
33
+ if (channelInfo.guild_id) {
34
+ this.guildId = String(channelInfo.guild_id);
35
+ }
36
+ } catch {
37
+ // Non-fatal — message URLs will be omitted if guild ID can't be resolved
38
+ }
27
39
  }
28
40
 
29
41
  async sendPrompt(prompt: RemotePrompt): Promise<RemoteDispatchResult> {
@@ -46,12 +58,18 @@ export class DiscordAdapter implements ChannelAdapter {
46
58
  }
47
59
  }
48
60
 
61
+ // Build message URL if guild ID is available
62
+ const messageUrl = this.guildId
63
+ ? `https://discord.com/channels/${this.guildId}/${this.channelId}/${messageId}`
64
+ : undefined;
65
+
49
66
  return {
50
67
  ref: {
51
68
  id: prompt.id,
52
69
  channel: "discord",
53
70
  messageId,
54
71
  channelId: this.channelId,
72
+ threadUrl: messageUrl,
55
73
  },
56
74
  };
57
75
  }
@@ -67,6 +85,21 @@ export class DiscordAdapter implements ChannelAdapter {
67
85
  return this.checkReplies(prompt, ref);
68
86
  }
69
87
 
88
+ /**
89
+ * Acknowledge that an answer was received by adding a ✅ reaction to the
90
+ * original prompt message. Best-effort — failures are silently ignored.
91
+ */
92
+ async acknowledgeAnswer(ref: RemotePromptRef): Promise<void> {
93
+ try {
94
+ await this.discordApi(
95
+ "PUT",
96
+ `/channels/${ref.channelId}/messages/${ref.messageId}/reactions/${encodeURIComponent("✅")}/@me`,
97
+ );
98
+ } catch {
99
+ // Best-effort — don't let acknowledgement failures affect the flow
100
+ }
101
+ }
102
+
70
103
  private async checkReactions(prompt: RemotePrompt, ref: RemotePromptRef): Promise<RemoteAnswer | null> {
71
104
  const reactions: Array<{ emoji: string; count: number }> = [];
72
105
  for (const emoji of NUMBER_EMOJIS) {
@@ -69,18 +69,24 @@ export function formatForDiscord(prompt: RemotePrompt): { embeds: DiscordEmbed[]
69
69
  return `${emoji} **${opt.label}** — ${opt.description}`;
70
70
  });
71
71
 
72
- const footerText = supportsReactions
73
- ? (q.allowMultiple
74
- ? "Reply with comma-separated choices (`1,3`) or react with matching numbers"
75
- : "Reply with a number or react with the matching number")
76
- : `Question ${questionIndex + 1}/${prompt.questions.length} reply with one line per question or use semicolons`;
72
+ const footerParts: string[] = [];
73
+ if (supportsReactions) {
74
+ footerParts.push(q.allowMultiple
75
+ ? "Reply with comma-separated choices (`1,3`) or react with matching numbers"
76
+ : "Reply with a number or react with the matching number");
77
+ } else {
78
+ footerParts.push(`Question ${questionIndex + 1}/${prompt.questions.length} — reply with one line per question or use semicolons`);
79
+ }
80
+ if (prompt.context?.source) {
81
+ footerParts.push(`Source: ${prompt.context.source}`);
82
+ }
77
83
 
78
84
  return {
79
85
  title: q.header,
80
86
  description: q.question,
81
87
  color: 0x7c3aed,
82
88
  fields: [{ name: "Options", value: optionLines.join("\n") }],
83
- footer: { text: footerText },
89
+ footer: { text: footerParts.join(" · ") },
84
90
  };
85
91
  });
86
92
 
@@ -76,6 +76,14 @@ export async function tryRemoteQuestions(
76
76
  }
77
77
 
78
78
  markPromptAnswered(prompt.id, answer);
79
+
80
+ // Acknowledge receipt with a ✅ on Discord (Slack threads are self-evident)
81
+ if (config.channel === "discord" && dispatch.ref) {
82
+ try {
83
+ await (adapter as import("./discord-adapter.js").DiscordAdapter).acknowledgeAnswer(dispatch.ref);
84
+ } catch { /* best-effort */ }
85
+ }
86
+
79
87
  return {
80
88
  content: [{ type: "text", text: JSON.stringify({ answers: formatForTool(answer) }) }],
81
89
  details: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gsd-pi",
3
- "version": "2.18.0",
3
+ "version": "2.19.0",
4
4
  "description": "GSD — Get Shit Done coding agent",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -10,7 +10,7 @@ import type { ExtensionContext, ExtensionCommandContext } from "@gsd/pi-coding-a
10
10
  import type { GSDState } from "./types.js";
11
11
  import { getCurrentBranch } from "./worktree.js";
12
12
  import { getActiveHook } from "./post-unit-hooks.js";
13
- import { getLedger, getProjectTotals, formatCost, formatTokenCount } from "./metrics.js";
13
+ import { getLedger, getProjectTotals, formatCost, formatTokenCount, formatTierSavings } from "./metrics.js";
14
14
  import {
15
15
  resolveMilestoneFile,
16
16
  resolveSliceFile,
@@ -39,6 +39,8 @@ export interface AutoDashboardData {
39
39
  projectedRemainingCost?: number;
40
40
  /** Whether token profile has been auto-downgraded due to budget prediction */
41
41
  profileDowngraded?: boolean;
42
+ /** Number of pending captures awaiting triage (0 if none or file missing) */
43
+ pendingCaptureCount: number;
42
44
  }
43
45
 
44
46
  // ─── Unit Description Helpers ─────────────────────────────────────────────────
@@ -239,6 +241,7 @@ export function updateProgressWidget(
239
241
  unitId: string,
240
242
  state: GSDState,
241
243
  accessors: WidgetStateAccessors,
244
+ tierBadge?: string,
242
245
  ): void {
243
246
  if (!ctx.hasUI) return;
244
247
 
@@ -319,7 +322,8 @@ export function updateProgressWidget(
319
322
 
320
323
  const target = task ? `${task.id}: ${task.title}` : unitId;
321
324
  const actionLeft = `${pad}${theme.fg("accent", "▸")} ${theme.fg("accent", verb)} ${theme.fg("text", target)}`;
322
- const phaseBadge = theme.fg("dim", phaseLabel);
325
+ const tierTag = tierBadge ? theme.fg("dim", `[${tierBadge}] `) : "";
326
+ const phaseBadge = `${tierTag}${theme.fg("dim", phaseLabel)}`;
323
327
  lines.push(rightAlign(actionLeft, phaseBadge, width));
324
328
  lines.push("");
325
329
 
@@ -414,6 +418,14 @@ export function updateProgressWidget(
414
418
  ? `${modelPhase}${theme.fg("dim", modelDisplay)}`
415
419
  : "";
416
420
  lines.push(rightAlign(`${pad}${sLeft}`, sRight, width));
421
+
422
+ // Dynamic routing savings summary
423
+ if (mLedger && mLedger.units.some(u => u.tier)) {
424
+ const savings = formatTierSavings(mLedger.units);
425
+ if (savings) {
426
+ lines.push(truncateToWidth(theme.fg("dim", `${pad}${savings}`), width));
427
+ }
428
+ }
417
429
  }
418
430
 
419
431
  const hintParts: string[] = [];