@stevecastaneda/paseo-progress 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +109 -0
- package/client/attachments.tsx +95 -0
- package/client/dashboard-query.ts +32 -0
- package/client/deliverables.tsx +67 -0
- package/client/dialog-stack.tsx +104 -0
- package/client/directory.ts +90 -0
- package/client/launcher.tsx +111 -0
- package/client/motion.tsx +173 -0
- package/client/panel-opened.ts +13 -0
- package/client/panel.tsx +480 -0
- package/client/pill-popover.tsx +122 -0
- package/client/pill-state.ts +34 -0
- package/client/pills.tsx +235 -0
- package/client/preview.tsx +228 -0
- package/client/questions.tsx +248 -0
- package/client/row.tsx +34 -0
- package/client/section-title.tsx +24 -0
- package/client/show-more.tsx +37 -0
- package/client/spinner.tsx +35 -0
- package/client/status-badge.tsx +30 -0
- package/client/surfaces.ts +45 -0
- package/client/ticket-dialog.tsx +203 -0
- package/client/when.tsx +15 -0
- package/dist/server/cli.js +528 -0
- package/dist/server/launcher.js +90 -0
- package/dist/server/lock.js +34 -0
- package/dist/server/paths.js +6 -0
- package/dist/server/progress-file.js +123 -0
- package/dist/shared/attachments.js +86 -0
- package/dist/shared/dashboard.js +369 -0
- package/dist/shared/events.js +117 -0
- package/dist/shared/format.js +37 -0
- package/dist/shared/show.js +72 -0
- package/images/1-panel.png +0 -0
- package/images/2-ticket.png +0 -0
- package/images/3-question.png +0 -0
- package/images/4-pill.png +0 -0
- package/index.client.tsx +33 -0
- package/index.server.ts +19 -0
- package/npm-shrinkwrap.json +4041 -0
- package/package.json +62 -0
- package/paseo-plugin.json +7 -0
- package/server/cli.ts +541 -0
- package/server/dashboard.ts +46 -0
- package/server/launcher.ts +97 -0
- package/server/lock.ts +32 -0
- package/server/open.ts +107 -0
- package/server/paths.ts +7 -0
- package/server/progress-file.ts +141 -0
- package/server/skill.ts +85 -0
- package/shared/attachments.ts +100 -0
- package/shared/dashboard.ts +491 -0
- package/shared/events.ts +131 -0
- package/shared/format.ts +39 -0
- package/shared/rpc.ts +109 -0
- package/shared/show.ts +68 -0
- package/shared/ticket-story.ts +93 -0
- package/skills/paseo-progress/SKILL.md +79 -0
- package/tsconfig.json +24 -0
|
@@ -0,0 +1,491 @@
|
|
|
1
|
+
import { EVENT_VERSION, eventSchema, type DELIVERABLE_KINDS, type FileLink, type ProgressEvent, type QuestionOption, type TicketStatus } from "./events.ts";
|
|
2
|
+
|
|
3
|
+
// One recorded change to a ticket, oldest first. `stage: ""` clears the stage.
|
|
4
|
+
export interface TicketChange {
|
|
5
|
+
at: string;
|
|
6
|
+
status?: TicketStatus;
|
|
7
|
+
stage?: string;
|
|
8
|
+
note?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface Ticket {
|
|
12
|
+
id: string;
|
|
13
|
+
title: string;
|
|
14
|
+
estimateMin: number;
|
|
15
|
+
status: TicketStatus;
|
|
16
|
+
// When the ticket entered its current status.
|
|
17
|
+
statusSince: string;
|
|
18
|
+
// When the ticket last started working.
|
|
19
|
+
workingSince?: string;
|
|
20
|
+
stage?: string;
|
|
21
|
+
stageSince?: string;
|
|
22
|
+
note?: string;
|
|
23
|
+
// The ticket this one waits for, while that ticket isn't done or skipped yet.
|
|
24
|
+
// Waiting is the order of work, not a problem, so it never counts as stuck.
|
|
25
|
+
waitingFor?: { id: string; title: string };
|
|
26
|
+
waitsFor?: string;
|
|
27
|
+
// Every status, stage and note change, starting with when it was added.
|
|
28
|
+
history: TicketChange[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type StuckItem =
|
|
32
|
+
| { kind: "blocked"; key: string; ticketId: string; title: string; since: string; note?: string }
|
|
33
|
+
// A working ticket with no update for longer than its estimate. `since` is its last update.
|
|
34
|
+
| { kind: "overdue"; key: string; ticketId: string; title: string; since: string; estimateMin: number }
|
|
35
|
+
| { kind: "manual"; key: string; id: string; ticketId?: string; title: string; reason: string; since: string };
|
|
36
|
+
|
|
37
|
+
export interface Question {
|
|
38
|
+
id: string;
|
|
39
|
+
title: string;
|
|
40
|
+
question: string;
|
|
41
|
+
options: QuestionOption[];
|
|
42
|
+
default: string;
|
|
43
|
+
waits: boolean;
|
|
44
|
+
background?: string;
|
|
45
|
+
files: FileLink[];
|
|
46
|
+
raisedBy?: string;
|
|
47
|
+
ticketId?: string;
|
|
48
|
+
askedAt: string;
|
|
49
|
+
answer?: { choice: string; words?: string; changedCourse: boolean; at: string };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface Deliverable {
|
|
53
|
+
id: string;
|
|
54
|
+
title: string;
|
|
55
|
+
path?: string;
|
|
56
|
+
url?: string;
|
|
57
|
+
ticketId?: string;
|
|
58
|
+
// Short name of the ticket, like "Ticket 03", when the ticket still exists.
|
|
59
|
+
ticketLabel?: string;
|
|
60
|
+
kind?: (typeof DELIVERABLE_KINDS)[number];
|
|
61
|
+
addedAt: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface Activity {
|
|
65
|
+
id: string;
|
|
66
|
+
text: string;
|
|
67
|
+
ticketId?: string;
|
|
68
|
+
at: string;
|
|
69
|
+
editedAt?: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// One comma-separated part of the headline, e.g. "1 stuck" in the alert color.
|
|
73
|
+
export interface HeadlinePart {
|
|
74
|
+
text: string;
|
|
75
|
+
tone: "normal" | "danger";
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Something in the file the dashboard could not use. The panel lists these so
|
|
79
|
+
// one bad write is visible without hiding the rest.
|
|
80
|
+
export interface FileIssue {
|
|
81
|
+
line: number;
|
|
82
|
+
reason: string;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface ProgressSegment {
|
|
86
|
+
id: string;
|
|
87
|
+
estimateMin: number;
|
|
88
|
+
status: TicketStatus;
|
|
89
|
+
waiting?: boolean;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface Dashboard {
|
|
93
|
+
run: {
|
|
94
|
+
title: string;
|
|
95
|
+
subtitle?: string;
|
|
96
|
+
itemLabel: string;
|
|
97
|
+
startedAt: string;
|
|
98
|
+
// Set by `finish`: the run is over and the next work starts a new one.
|
|
99
|
+
finished?: { at: string; outcome: string };
|
|
100
|
+
} | null;
|
|
101
|
+
updatedAt: string | null;
|
|
102
|
+
// Nothing recorded for STALE_AFTER_MIN while tickets remain: the work may have stopped.
|
|
103
|
+
stale: boolean;
|
|
104
|
+
headline: HeadlinePart[];
|
|
105
|
+
stuck: StuckItem[];
|
|
106
|
+
// Open questions in ask order; answered ones, most recently answered first.
|
|
107
|
+
questions: { open: Question[]; answered: Question[] };
|
|
108
|
+
// Newest first.
|
|
109
|
+
deliverables: Deliverable[];
|
|
110
|
+
// Newest first.
|
|
111
|
+
activity: Activity[];
|
|
112
|
+
ticker: { text: string; since: string } | null;
|
|
113
|
+
// Share of estimated minutes in done tickets. Skipped tickets are left out.
|
|
114
|
+
// `tookMin`: once every counted ticket is done, the real time from the run's
|
|
115
|
+
// start to the last one finishing.
|
|
116
|
+
progress: { percent: number; doneMin: number; totalMin: number; tookMin?: number; segments: ProgressSegment[] };
|
|
117
|
+
tickets: Ticket[];
|
|
118
|
+
issues: FileIssue[];
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// The id the next add of each kind gets. Ids are never reused, even after a
|
|
122
|
+
// remove. Question ids stay unique across runs, so an answer like "Q7 A" can't
|
|
123
|
+
// land on a question from an earlier run; the rest restart with each run.
|
|
124
|
+
export interface NextIds {
|
|
125
|
+
ticket: string;
|
|
126
|
+
question: string;
|
|
127
|
+
deliverable: string;
|
|
128
|
+
activity: string;
|
|
129
|
+
stuck: string;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export interface ParsedLine {
|
|
133
|
+
line: number;
|
|
134
|
+
event: ProgressEvent;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Reads the JSONL text. A last line without a newline may be half written by
|
|
138
|
+
// a command that is still running, so it is skipped without an issue.
|
|
139
|
+
export function parseProgress(text: string): { events: ParsedLine[]; issues: FileIssue[] } {
|
|
140
|
+
const events: ParsedLine[] = [];
|
|
141
|
+
const issues: FileIssue[] = [];
|
|
142
|
+
// Whether or not the text ends in a newline, the last split piece is either
|
|
143
|
+
// empty or unfinished.
|
|
144
|
+
text.split("\n").slice(0, -1).forEach((raw, index) => {
|
|
145
|
+
const line = index + 1;
|
|
146
|
+
if (!raw.trim()) return;
|
|
147
|
+
let json: unknown;
|
|
148
|
+
try {
|
|
149
|
+
json = JSON.parse(raw);
|
|
150
|
+
} catch {
|
|
151
|
+
issues.push({ line, reason: "not valid JSON" });
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
const parsed = eventSchema.safeParse(json);
|
|
155
|
+
if (parsed.success) events.push({ line, event: parsed.data });
|
|
156
|
+
else issues.push({ line, reason: describeInvalid(json) });
|
|
157
|
+
});
|
|
158
|
+
return { events, issues };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function describeInvalid(json: unknown): string {
|
|
162
|
+
const version = typeof json === "object" && json !== null && "v" in json ? (json as { v: unknown }).v : undefined;
|
|
163
|
+
if (typeof version === "number" && version !== EVENT_VERSION) return `written by a newer version of the plugin (v${version})`;
|
|
164
|
+
const type = typeof json === "object" && json !== null && "type" in json ? String((json as { type: unknown }).type) : null;
|
|
165
|
+
return type ? `invalid ${type} event` : "not a progress event";
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export const STALE_AFTER_MIN = 15;
|
|
169
|
+
|
|
170
|
+
// Every time-dependent rule asks `reached(at)` instead of comparing with `now`
|
|
171
|
+
// itself. Each answer also records the moment it would flip, so `validUntil`
|
|
172
|
+
// is the first millisecond at which the same events can produce a different
|
|
173
|
+
// dashboard, and a new rule can't forget to tell the cache.
|
|
174
|
+
function clock(now: Date) {
|
|
175
|
+
let validUntil = Infinity;
|
|
176
|
+
return {
|
|
177
|
+
reached(at: number): boolean {
|
|
178
|
+
if (now.getTime() >= at) return true;
|
|
179
|
+
validUntil = Math.min(validUntil, at);
|
|
180
|
+
return false;
|
|
181
|
+
},
|
|
182
|
+
get validUntil() {
|
|
183
|
+
return validUntil;
|
|
184
|
+
},
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function isSettled(ticket: Ticket): boolean {
|
|
189
|
+
return ticket.status === "done" || ticket.status === "skipped";
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Folds the events of the latest run into what the panel shows at `now`, how
|
|
193
|
+
// long that stays true while the events don't change, and the ids new items get.
|
|
194
|
+
// Everything before the last `run.start` belongs to earlier runs and is ignored.
|
|
195
|
+
export function reduceProgress(parsed: { events: ParsedLine[]; issues: FileIssue[] }, now: Date): { dashboard: Dashboard; validUntil: number; nextIds: NextIds } {
|
|
196
|
+
const time = clock(now);
|
|
197
|
+
let start = 0;
|
|
198
|
+
parsed.events.forEach(({ event }, index) => {
|
|
199
|
+
if (event.type === "run.start") start = index;
|
|
200
|
+
});
|
|
201
|
+
const events = parsed.events.slice(start);
|
|
202
|
+
const issues = [...parsed.issues];
|
|
203
|
+
let run: Dashboard["run"] = null;
|
|
204
|
+
const tickets = new Map<string, Ticket>();
|
|
205
|
+
const questions = new Map<string, Question>();
|
|
206
|
+
const deliverables = new Map<string, Deliverable>();
|
|
207
|
+
const activity = new Map<string, Activity>();
|
|
208
|
+
let ticker: Dashboard["ticker"] = null;
|
|
209
|
+
const manualStuck = new Map<string, { id: string; reason: string; ticket?: string; since: string }>();
|
|
210
|
+
let updatedAt: string | null = null;
|
|
211
|
+
|
|
212
|
+
for (const { line, event } of events) {
|
|
213
|
+
updatedAt = event.ts;
|
|
214
|
+
switch (event.type) {
|
|
215
|
+
case "run.start":
|
|
216
|
+
run = { title: event.title, subtitle: event.subtitle, itemLabel: event.itemLabel ?? "Ticket", startedAt: event.ts };
|
|
217
|
+
break;
|
|
218
|
+
case "run.finish":
|
|
219
|
+
if (run) run.finished = { at: event.ts, outcome: event.outcome };
|
|
220
|
+
ticker = null;
|
|
221
|
+
break;
|
|
222
|
+
case "ticket.add":
|
|
223
|
+
tickets.set(event.id, {
|
|
224
|
+
id: event.id,
|
|
225
|
+
title: event.title,
|
|
226
|
+
estimateMin: event.estimateMin,
|
|
227
|
+
status: event.status ?? "not_started",
|
|
228
|
+
statusSince: event.ts,
|
|
229
|
+
workingSince: event.status === "working" ? event.ts : undefined,
|
|
230
|
+
history: [{ at: event.ts, status: event.status ?? "not_started" }],
|
|
231
|
+
waitsFor: event.waitsFor,
|
|
232
|
+
});
|
|
233
|
+
break;
|
|
234
|
+
case "ticket.update": {
|
|
235
|
+
const ticket = tickets.get(event.id);
|
|
236
|
+
if (!ticket) {
|
|
237
|
+
issues.push({ line, reason: `unknown ticket ${event.id}` });
|
|
238
|
+
break;
|
|
239
|
+
}
|
|
240
|
+
if (event.title !== undefined) ticket.title = event.title;
|
|
241
|
+
if (event.estimateMin !== undefined) ticket.estimateMin = event.estimateMin;
|
|
242
|
+
const change: TicketChange = { at: event.ts };
|
|
243
|
+
if (event.status !== undefined && event.status !== ticket.status) change.status = event.status;
|
|
244
|
+
if (event.stage !== undefined && event.stage !== (ticket.stage ?? "")) change.stage = event.stage;
|
|
245
|
+
if (event.note) change.note = event.note;
|
|
246
|
+
if (Object.keys(change).length > 1) ticket.history.push(change);
|
|
247
|
+
if (event.status !== undefined && event.status !== ticket.status) {
|
|
248
|
+
ticket.status = event.status;
|
|
249
|
+
ticket.statusSince = event.ts;
|
|
250
|
+
if (event.status === "working") ticket.workingSince = event.ts;
|
|
251
|
+
}
|
|
252
|
+
if (event.stage !== undefined && event.stage !== (ticket.stage ?? "")) {
|
|
253
|
+
ticket.stage = event.stage || undefined;
|
|
254
|
+
ticket.stageSince = event.stage ? event.ts : undefined;
|
|
255
|
+
}
|
|
256
|
+
if (event.note !== undefined) ticket.note = event.note || undefined;
|
|
257
|
+
if (event.waitsFor !== undefined) ticket.waitsFor = event.waitsFor || undefined;
|
|
258
|
+
break;
|
|
259
|
+
}
|
|
260
|
+
case "ticket.remove":
|
|
261
|
+
if (!tickets.delete(event.id)) issues.push({ line, reason: `unknown ticket ${event.id}` });
|
|
262
|
+
break;
|
|
263
|
+
case "question.ask":
|
|
264
|
+
questions.set(event.id, {
|
|
265
|
+
id: event.id,
|
|
266
|
+
title: event.title,
|
|
267
|
+
question: event.question,
|
|
268
|
+
options: event.options ?? [],
|
|
269
|
+
default: event.default,
|
|
270
|
+
waits: event.waits ?? false,
|
|
271
|
+
background: event.background,
|
|
272
|
+
files: event.files ?? [],
|
|
273
|
+
raisedBy: event.raisedBy,
|
|
274
|
+
ticketId: event.ticket,
|
|
275
|
+
askedAt: event.ts,
|
|
276
|
+
});
|
|
277
|
+
break;
|
|
278
|
+
case "question.answer": {
|
|
279
|
+
const question = questions.get(event.id);
|
|
280
|
+
if (!question) issues.push({ line, reason: `unknown question ${event.id}` });
|
|
281
|
+
else question.answer = { choice: event.choice, words: event.words, changedCourse: event.changedCourse, at: event.ts };
|
|
282
|
+
break;
|
|
283
|
+
}
|
|
284
|
+
case "question.update": {
|
|
285
|
+
const question = questions.get(event.id);
|
|
286
|
+
if (!question) {
|
|
287
|
+
issues.push({ line, reason: `unknown question ${event.id}` });
|
|
288
|
+
break;
|
|
289
|
+
}
|
|
290
|
+
if (event.question !== undefined) question.question = event.question;
|
|
291
|
+
if (event.background !== undefined) question.background = event.background;
|
|
292
|
+
if (event.files !== undefined) question.files = [...question.files, ...event.files];
|
|
293
|
+
break;
|
|
294
|
+
}
|
|
295
|
+
case "question.remove":
|
|
296
|
+
if (!questions.delete(event.id)) issues.push({ line, reason: `unknown question ${event.id}` });
|
|
297
|
+
break;
|
|
298
|
+
case "deliverable.add":
|
|
299
|
+
deliverables.set(event.id, {
|
|
300
|
+
id: event.id,
|
|
301
|
+
title: event.title,
|
|
302
|
+
path: event.path,
|
|
303
|
+
url: event.url,
|
|
304
|
+
ticketId: event.ticket,
|
|
305
|
+
kind: event.kind,
|
|
306
|
+
addedAt: event.ts,
|
|
307
|
+
});
|
|
308
|
+
break;
|
|
309
|
+
case "deliverable.remove":
|
|
310
|
+
if (!deliverables.delete(event.id)) issues.push({ line, reason: `unknown deliverable ${event.id}` });
|
|
311
|
+
break;
|
|
312
|
+
case "activity.add":
|
|
313
|
+
activity.set(event.id, { id: event.id, text: event.text, ticketId: event.ticket, at: event.ts });
|
|
314
|
+
break;
|
|
315
|
+
case "activity.update": {
|
|
316
|
+
const entry = activity.get(event.id);
|
|
317
|
+
if (!entry) issues.push({ line, reason: `unknown activity ${event.id}` });
|
|
318
|
+
else Object.assign(entry, { text: event.text, editedAt: event.ts });
|
|
319
|
+
break;
|
|
320
|
+
}
|
|
321
|
+
case "activity.remove":
|
|
322
|
+
if (!activity.delete(event.id)) issues.push({ line, reason: `unknown activity ${event.id}` });
|
|
323
|
+
break;
|
|
324
|
+
case "ticker.set":
|
|
325
|
+
ticker = { text: event.text, since: event.ts };
|
|
326
|
+
break;
|
|
327
|
+
case "ticker.clear":
|
|
328
|
+
ticker = null;
|
|
329
|
+
break;
|
|
330
|
+
case "stuck.set":
|
|
331
|
+
manualStuck.set(event.id, { id: event.id, reason: event.reason, ticket: event.ticket, since: event.ts });
|
|
332
|
+
break;
|
|
333
|
+
case "stuck.clear":
|
|
334
|
+
if (!manualStuck.delete(event.id)) issues.push({ line, reason: `unknown stuck item ${event.id}` });
|
|
335
|
+
break;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const list = [...tickets.values()];
|
|
340
|
+
for (const ticket of list) {
|
|
341
|
+
const other = ticket.waitsFor ? tickets.get(ticket.waitsFor) : undefined;
|
|
342
|
+
ticket.waitingFor = other && !isSettled(other) && !isSettled(ticket)
|
|
343
|
+
? { id: other.id, title: other.title }
|
|
344
|
+
: undefined;
|
|
345
|
+
}
|
|
346
|
+
const itemLabel = run?.itemLabel ?? "Ticket";
|
|
347
|
+
const stuck = stuckItems(list, [...activity.values()], [...manualStuck.values()], time.reached);
|
|
348
|
+
const allQuestions = [...questions.values()];
|
|
349
|
+
const open = allQuestions.filter((question) => !question.answer);
|
|
350
|
+
// A finished run has nothing left to stall, so it never goes stale.
|
|
351
|
+
const finished = Boolean(run?.finished) || (list.length > 0 && list.every(isSettled));
|
|
352
|
+
const answered = allQuestions
|
|
353
|
+
.filter((question) => question.answer)
|
|
354
|
+
.sort((a, b) => Date.parse(b.answer!.at) - Date.parse(a.answer!.at));
|
|
355
|
+
const dashboard: Dashboard = {
|
|
356
|
+
run,
|
|
357
|
+
updatedAt,
|
|
358
|
+
stale: !finished && updatedAt !== null && time.reached(Date.parse(updatedAt) + STALE_AFTER_MIN * 60_000),
|
|
359
|
+
headline: headline(list, itemLabel, open.length, stuck.length),
|
|
360
|
+
stuck,
|
|
361
|
+
questions: { open, answered },
|
|
362
|
+
activity: [...activity.values()].reverse(),
|
|
363
|
+
ticker,
|
|
364
|
+
deliverables: [...deliverables.values()].reverse().map((deliverable) => {
|
|
365
|
+
const ticket = deliverable.ticketId ? tickets.get(deliverable.ticketId) : undefined;
|
|
366
|
+
return ticket ? { ...deliverable, ticketLabel: shortTitle(ticket.title) } : deliverable;
|
|
367
|
+
}),
|
|
368
|
+
progress: progress(list, run?.startedAt),
|
|
369
|
+
tickets: list,
|
|
370
|
+
issues: issues.sort((a, b) => a.line - b.line),
|
|
371
|
+
};
|
|
372
|
+
return { dashboard, validUntil: time.validUntil, nextIds: nextIds(parsed.events.map(({ event }) => event), events.map(({ event }) => event)) };
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function nextIds(all: ProgressEvent[], inRun: ProgressEvent[]): NextIds {
|
|
376
|
+
const used = (events: ProgressEvent[], type: ProgressEvent["type"]) => events.flatMap((event) => (event.type === type && "id" in event ? [event.id] : []));
|
|
377
|
+
return {
|
|
378
|
+
ticket: nextId("T", used(inRun, "ticket.add")),
|
|
379
|
+
question: nextId("Q", used(all, "question.ask"), 1),
|
|
380
|
+
deliverable: nextId("D", used(inRun, "deliverable.add"), 1),
|
|
381
|
+
activity: nextId("A", used(inRun, "activity.add"), 1),
|
|
382
|
+
stuck: nextId("S", used(inRun, "stuck.set"), 1),
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function progress(tickets: Ticket[], startedAt: string | undefined): Dashboard["progress"] {
|
|
387
|
+
const counted = tickets.filter((ticket) => ticket.status !== "skipped");
|
|
388
|
+
const totalMin = counted.reduce((sum, ticket) => sum + ticket.estimateMin, 0);
|
|
389
|
+
const doneMin = counted.filter((ticket) => ticket.status === "done").reduce((sum, ticket) => sum + ticket.estimateMin, 0);
|
|
390
|
+
// All done: the last ticket to settle, done or skipped, ended the work.
|
|
391
|
+
const allDone = counted.length > 0 && counted.every((ticket) => ticket.status === "done") && tickets.every(isSettled);
|
|
392
|
+
const endedAt = Math.max(...tickets.map((ticket) => Date.parse(ticket.statusSince)));
|
|
393
|
+
return {
|
|
394
|
+
percent: totalMin ? Math.round((doneMin / totalMin) * 100) : 0,
|
|
395
|
+
doneMin,
|
|
396
|
+
totalMin,
|
|
397
|
+
tookMin: allDone && startedAt ? Math.max(0, (endedAt - Date.parse(startedAt)) / 60_000) : undefined,
|
|
398
|
+
segments: counted.map(({ id, estimateMin, status, waitingFor }) => ({ id, estimateMin, status, waiting: Boolean(waitingFor) })),
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function lastTicketUpdate(ticket: Ticket, activity: Activity[]): number {
|
|
403
|
+
return Math.max(
|
|
404
|
+
...ticket.history.map((change) => Date.parse(change.at)),
|
|
405
|
+
...activity.filter((entry) => entry.ticketId === ticket.id).map((entry) => Date.parse(entry.at)),
|
|
406
|
+
);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function stuckItems(tickets: Ticket[], activity: Activity[], manual: Array<{ id: string; reason: string; ticket?: string; since: string }>, reached: (at: number) => boolean): StuckItem[] {
|
|
410
|
+
const items: StuckItem[] = [];
|
|
411
|
+
for (const ticket of tickets) {
|
|
412
|
+
// Blocked only on another ticket in the run is waiting, not stuck.
|
|
413
|
+
if (ticket.status === "blocked" && !ticket.waitingFor) {
|
|
414
|
+
items.push({ kind: "blocked", key: `blocked:${ticket.id}`, ticketId: ticket.id, title: ticket.title, since: ticket.statusSince, note: ticket.note });
|
|
415
|
+
} else if (ticket.status === "working") {
|
|
416
|
+
// Any sign of work resets the clock: a status, stage or note change, or activity tagged to the ticket.
|
|
417
|
+
const lastUpdate = lastTicketUpdate(ticket, activity);
|
|
418
|
+
// Overdue once more than the estimate has passed, so a millisecond after it.
|
|
419
|
+
if (reached(lastUpdate + ticket.estimateMin * 60_000 + 1)) {
|
|
420
|
+
items.push({
|
|
421
|
+
kind: "overdue",
|
|
422
|
+
key: `overdue:${ticket.id}`,
|
|
423
|
+
ticketId: ticket.id,
|
|
424
|
+
title: ticket.title,
|
|
425
|
+
since: new Date(lastUpdate).toISOString(),
|
|
426
|
+
estimateMin: ticket.estimateMin,
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
for (const item of manual) {
|
|
432
|
+
const ticket = item.ticket ? tickets.find((candidate) => candidate.id === item.ticket) : undefined;
|
|
433
|
+
items.push({
|
|
434
|
+
kind: "manual",
|
|
435
|
+
key: `manual:${item.id}`,
|
|
436
|
+
id: item.id,
|
|
437
|
+
ticketId: item.ticket,
|
|
438
|
+
title: ticket?.title ?? item.reason,
|
|
439
|
+
reason: item.reason,
|
|
440
|
+
since: item.since,
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
return items;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// "Ticket 03: Frozen links survive deletes" → "Ticket 03".
|
|
447
|
+
export function shortTitle(title: string): string {
|
|
448
|
+
const index = title.indexOf(":");
|
|
449
|
+
return index > 0 && index <= 24 ? title.slice(0, index) : title;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function plural(count: number, noun: string): string {
|
|
453
|
+
return `${count} ${count === 1 ? noun : `${noun}s`}`;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function headline(tickets: Ticket[], itemLabel: string, waiting: number, stuck: number): HeadlinePart[] {
|
|
457
|
+
const counted = tickets.filter((ticket) => ticket.status !== "skipped");
|
|
458
|
+
const done = counted.filter((ticket) => ticket.status === "done").length;
|
|
459
|
+
const parts: HeadlinePart[] = [{ text: `${done} of ${plural(counted.length, itemLabel.toLowerCase())} done`, tone: "normal" }];
|
|
460
|
+
if (waiting) parts.push({ text: `${plural(waiting, "question")} waiting for you`, tone: "normal" });
|
|
461
|
+
if (stuck) parts.push({ text: `${stuck} stuck`, tone: "danger" });
|
|
462
|
+
return parts;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
export function headlineText(dashboard: Dashboard): string {
|
|
466
|
+
return dashboard.headline.map((part) => part.text).join(", ");
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function nextId(prefix: string, used: Iterable<string>, width = 2): string {
|
|
470
|
+
let max = 0;
|
|
471
|
+
for (const id of used) {
|
|
472
|
+
const match = new RegExp(`^${prefix}(\\d+)$`).exec(id);
|
|
473
|
+
if (match) max = Math.max(max, Number(match[1]));
|
|
474
|
+
}
|
|
475
|
+
return `${prefix}${String(max + 1).padStart(width, "0")}`;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
export const EMPTY_DASHBOARD: Dashboard = {
|
|
479
|
+
run: null,
|
|
480
|
+
updatedAt: null,
|
|
481
|
+
stale: false,
|
|
482
|
+
headline: [],
|
|
483
|
+
stuck: [],
|
|
484
|
+
questions: { open: [], answered: [] },
|
|
485
|
+
deliverables: [],
|
|
486
|
+
activity: [],
|
|
487
|
+
ticker: null,
|
|
488
|
+
progress: { percent: 0, doneMin: 0, totalMin: 0, segments: [] },
|
|
489
|
+
tickets: [],
|
|
490
|
+
issues: [],
|
|
491
|
+
};
|
package/shared/events.ts
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
// One line of `.scratch/progress.jsonl`. The command-line tool writes these;
|
|
4
|
+
// the dashboard reducer reads them. `v` lets later versions change a shape
|
|
5
|
+
// without misreading old files.
|
|
6
|
+
export const PROGRESS_FILE = ".scratch/progress.jsonl";
|
|
7
|
+
// Present once someone has opened the Progress panel for this worktree.
|
|
8
|
+
export const PANEL_OPENED_FILE = ".scratch/progress-panel-opened";
|
|
9
|
+
export const EVENT_VERSION = 1;
|
|
10
|
+
|
|
11
|
+
export const TICKET_STATUSES = ["not_started", "working", "blocked", "done", "skipped"] as const;
|
|
12
|
+
export const ticketStatusSchema = z.enum(TICKET_STATUSES);
|
|
13
|
+
export type TicketStatus = z.infer<typeof ticketStatusSchema>;
|
|
14
|
+
|
|
15
|
+
const text = z.string().trim().min(1);
|
|
16
|
+
const minutes = z.number().int().positive().max(100_000);
|
|
17
|
+
const base = { v: z.literal(EVENT_VERSION), ts: z.iso.datetime() };
|
|
18
|
+
|
|
19
|
+
export const questionOptionSchema = z.object({
|
|
20
|
+
letter: z.string().regex(/^[A-Z]$/),
|
|
21
|
+
label: text,
|
|
22
|
+
consequence: text.optional(),
|
|
23
|
+
});
|
|
24
|
+
export type QuestionOption = z.infer<typeof questionOptionSchema>;
|
|
25
|
+
|
|
26
|
+
const httpUrl = z.url().refine((value) => /^https?:\/\//i.test(value), "Use an HTTP or HTTPS URL");
|
|
27
|
+
|
|
28
|
+
// Something attached to a question: a local path or a web link.
|
|
29
|
+
export const fileLinkSchema = z
|
|
30
|
+
.object({ path: text.optional(), url: httpUrl.optional(), label: text.optional() })
|
|
31
|
+
.refine((file) => Boolean(file.path) !== Boolean(file.url), "Give a path or a URL");
|
|
32
|
+
export type FileLink = z.infer<typeof fileLinkSchema>;
|
|
33
|
+
|
|
34
|
+
export const DELIVERABLE_KINDS = ["file", "folder", "report", "screenshot", "link"] as const;
|
|
35
|
+
|
|
36
|
+
export const eventSchema = z.discriminatedUnion("type", [
|
|
37
|
+
z.object({
|
|
38
|
+
...base,
|
|
39
|
+
type: z.literal("run.start"),
|
|
40
|
+
title: text,
|
|
41
|
+
subtitle: text.optional(),
|
|
42
|
+
itemLabel: text.optional(),
|
|
43
|
+
}),
|
|
44
|
+
// Closes the run. Nothing more is recorded on it; new work needs `run.start`.
|
|
45
|
+
z.object({ ...base, type: z.literal("run.finish"), outcome: text }),
|
|
46
|
+
z.object({
|
|
47
|
+
...base,
|
|
48
|
+
type: z.literal("ticket.add"),
|
|
49
|
+
id: text,
|
|
50
|
+
title: text,
|
|
51
|
+
estimateMin: minutes,
|
|
52
|
+
status: ticketStatusSchema.optional(),
|
|
53
|
+
// Another ticket in the run this one can't start until it's done.
|
|
54
|
+
waitsFor: text.optional(),
|
|
55
|
+
}),
|
|
56
|
+
z.object({
|
|
57
|
+
...base,
|
|
58
|
+
type: z.literal("ticket.update"),
|
|
59
|
+
id: text,
|
|
60
|
+
title: text.optional(),
|
|
61
|
+
estimateMin: minutes.optional(),
|
|
62
|
+
status: ticketStatusSchema.optional(),
|
|
63
|
+
// The step inside the ticket, like "Build" or "Fixes". Empty clears it.
|
|
64
|
+
stage: z.string().trim().optional(),
|
|
65
|
+
note: z.string().trim().optional(),
|
|
66
|
+
// Empty clears it.
|
|
67
|
+
waitsFor: z.string().trim().optional(),
|
|
68
|
+
}),
|
|
69
|
+
z.object({ ...base, type: z.literal("ticket.remove"), id: text }),
|
|
70
|
+
// A blocker that is not a ticket status, like a failing external service.
|
|
71
|
+
z.object({ ...base, type: z.literal("stuck.set"), id: text, reason: text, ticket: text.optional() }),
|
|
72
|
+
z.object({ ...base, type: z.literal("stuck.clear"), id: text }),
|
|
73
|
+
// The agent asks, picks `default`, and keeps working on it until answered.
|
|
74
|
+
z.object({
|
|
75
|
+
...base,
|
|
76
|
+
type: z.literal("question.ask"),
|
|
77
|
+
id: text,
|
|
78
|
+
title: text,
|
|
79
|
+
question: text,
|
|
80
|
+
options: z.array(questionOptionSchema).max(26).optional(),
|
|
81
|
+
// An option letter, or a word such as "Your check" when there are no options.
|
|
82
|
+
default: text,
|
|
83
|
+
// True when the agent will not act on the default and waits for the answer.
|
|
84
|
+
waits: z.boolean().optional(),
|
|
85
|
+
background: text.optional(),
|
|
86
|
+
files: z.array(fileLinkSchema).optional(),
|
|
87
|
+
raisedBy: text.optional(),
|
|
88
|
+
// The ticket this question came up on.
|
|
89
|
+
ticket: text.optional(),
|
|
90
|
+
}),
|
|
91
|
+
z.object({
|
|
92
|
+
...base,
|
|
93
|
+
type: z.literal("question.answer"),
|
|
94
|
+
id: text,
|
|
95
|
+
choice: text,
|
|
96
|
+
// The user's own words, quoted under the answer.
|
|
97
|
+
words: text.optional(),
|
|
98
|
+
changedCourse: z.boolean(),
|
|
99
|
+
}),
|
|
100
|
+
// Adds detail to an open question; its reference and default stay.
|
|
101
|
+
z.object({
|
|
102
|
+
...base,
|
|
103
|
+
type: z.literal("question.update"),
|
|
104
|
+
id: text,
|
|
105
|
+
question: text.optional(),
|
|
106
|
+
background: text.optional(),
|
|
107
|
+
files: z.array(fileLinkSchema).optional(),
|
|
108
|
+
}),
|
|
109
|
+
z.object({ ...base, type: z.literal("question.remove"), id: text }),
|
|
110
|
+
// `path` is relative to the worktree root, or absolute when outside it.
|
|
111
|
+
z.object({
|
|
112
|
+
...base,
|
|
113
|
+
type: z.literal("deliverable.add"),
|
|
114
|
+
id: text,
|
|
115
|
+
title: text,
|
|
116
|
+
path: text.optional(),
|
|
117
|
+
url: httpUrl.optional(),
|
|
118
|
+
ticket: text.optional(),
|
|
119
|
+
kind: z.enum(DELIVERABLE_KINDS).optional(),
|
|
120
|
+
}).refine((event) => Boolean(event.path) !== Boolean(event.url), "A deliverable has a path or a URL"),
|
|
121
|
+
z.object({ ...base, type: z.literal("deliverable.remove"), id: text }),
|
|
122
|
+
z.object({ ...base, type: z.literal("activity.add"), id: text, text, ticket: text.optional() }),
|
|
123
|
+
z.object({ ...base, type: z.literal("activity.update"), id: text, text }),
|
|
124
|
+
z.object({ ...base, type: z.literal("activity.remove"), id: text }),
|
|
125
|
+
// One line at the top saying what the agent is doing right now.
|
|
126
|
+
z.object({ ...base, type: z.literal("ticker.set"), text }),
|
|
127
|
+
z.object({ ...base, type: z.literal("ticker.clear") }),
|
|
128
|
+
]);
|
|
129
|
+
|
|
130
|
+
export type ProgressEvent = z.infer<typeof eventSchema>;
|
|
131
|
+
export type ProgressEventType = ProgressEvent["type"];
|
package/shared/format.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Display helpers shared by the panel and the `show` command. Times are shown
|
|
2
|
+
// in the viewer's local time zone.
|
|
3
|
+
|
|
4
|
+
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
|
5
|
+
|
|
6
|
+
export function formatMinutes(totalMin: number): string {
|
|
7
|
+
const whole = Math.max(0, Math.floor(totalMin));
|
|
8
|
+
if (whole < 1) return "<1 min";
|
|
9
|
+
const hours = Math.floor(whole / 60);
|
|
10
|
+
const minutes = whole % 60;
|
|
11
|
+
if (!hours) return `${minutes} min`;
|
|
12
|
+
return minutes ? `${hours} h ${minutes} min` : `${hours} h`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Estimated work done out of the total, in the units people estimate in:
|
|
16
|
+
// "5 of 10 min", "1 h 30 min of 2 h". Under an hour on both sides, the unit is
|
|
17
|
+
// said once.
|
|
18
|
+
// Once everything is done, how long it really took instead: "1 h 20 min".
|
|
19
|
+
export function formatWorkDone(doneMin: number, totalMin: number, tookMin?: number): string {
|
|
20
|
+
if (tookMin !== undefined) return formatMinutes(tookMin);
|
|
21
|
+
if (totalMin < 60 && doneMin >= 1) return `${Math.floor(doneMin)} of ${formatMinutes(totalMin)}`;
|
|
22
|
+
return `${doneMin < 1 ? "0 min" : formatMinutes(doneMin)} of ${formatMinutes(totalMin)}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function formatClock(iso: string): string {
|
|
26
|
+
const date = new Date(iso);
|
|
27
|
+
const hours = date.getHours();
|
|
28
|
+
const minutes = String(date.getMinutes()).padStart(2, "0");
|
|
29
|
+
return `${MONTHS[date.getMonth()]} ${date.getDate()}, ${hours % 12 || 12}:${minutes} ${hours < 12 ? "AM" : "PM"}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function formatAgo(iso: string, nowMs: number): string {
|
|
33
|
+
const minutes = (nowMs - Date.parse(iso)) / 60_000;
|
|
34
|
+
return minutes < 1 ? "just now" : `${formatMinutes(minutes)} ago`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function minutesSince(iso: string, nowMs: number): number {
|
|
38
|
+
return Math.max(0, (nowMs - Date.parse(iso)) / 60_000);
|
|
39
|
+
}
|