@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.
Files changed (60) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +109 -0
  3. package/client/attachments.tsx +95 -0
  4. package/client/dashboard-query.ts +32 -0
  5. package/client/deliverables.tsx +67 -0
  6. package/client/dialog-stack.tsx +104 -0
  7. package/client/directory.ts +90 -0
  8. package/client/launcher.tsx +111 -0
  9. package/client/motion.tsx +173 -0
  10. package/client/panel-opened.ts +13 -0
  11. package/client/panel.tsx +480 -0
  12. package/client/pill-popover.tsx +122 -0
  13. package/client/pill-state.ts +34 -0
  14. package/client/pills.tsx +235 -0
  15. package/client/preview.tsx +228 -0
  16. package/client/questions.tsx +248 -0
  17. package/client/row.tsx +34 -0
  18. package/client/section-title.tsx +24 -0
  19. package/client/show-more.tsx +37 -0
  20. package/client/spinner.tsx +35 -0
  21. package/client/status-badge.tsx +30 -0
  22. package/client/surfaces.ts +45 -0
  23. package/client/ticket-dialog.tsx +203 -0
  24. package/client/when.tsx +15 -0
  25. package/dist/server/cli.js +528 -0
  26. package/dist/server/launcher.js +90 -0
  27. package/dist/server/lock.js +34 -0
  28. package/dist/server/paths.js +6 -0
  29. package/dist/server/progress-file.js +123 -0
  30. package/dist/shared/attachments.js +86 -0
  31. package/dist/shared/dashboard.js +369 -0
  32. package/dist/shared/events.js +117 -0
  33. package/dist/shared/format.js +37 -0
  34. package/dist/shared/show.js +72 -0
  35. package/images/1-panel.png +0 -0
  36. package/images/2-ticket.png +0 -0
  37. package/images/3-question.png +0 -0
  38. package/images/4-pill.png +0 -0
  39. package/index.client.tsx +33 -0
  40. package/index.server.ts +19 -0
  41. package/npm-shrinkwrap.json +4041 -0
  42. package/package.json +62 -0
  43. package/paseo-plugin.json +7 -0
  44. package/server/cli.ts +541 -0
  45. package/server/dashboard.ts +46 -0
  46. package/server/launcher.ts +97 -0
  47. package/server/lock.ts +32 -0
  48. package/server/open.ts +107 -0
  49. package/server/paths.ts +7 -0
  50. package/server/progress-file.ts +141 -0
  51. package/server/skill.ts +85 -0
  52. package/shared/attachments.ts +100 -0
  53. package/shared/dashboard.ts +491 -0
  54. package/shared/events.ts +131 -0
  55. package/shared/format.ts +39 -0
  56. package/shared/rpc.ts +109 -0
  57. package/shared/show.ts +68 -0
  58. package/shared/ticket-story.ts +93 -0
  59. package/skills/paseo-progress/SKILL.md +79 -0
  60. package/tsconfig.json +24 -0
@@ -0,0 +1,117 @@
1
+ import { z } from "zod";
2
+ // One line of `.scratch/progress.jsonl`. The command-line tool writes these;
3
+ // the dashboard reducer reads them. `v` lets later versions change a shape
4
+ // without misreading old files.
5
+ export const PROGRESS_FILE = ".scratch/progress.jsonl";
6
+ // Present once someone has opened the Progress panel for this worktree.
7
+ export const PANEL_OPENED_FILE = ".scratch/progress-panel-opened";
8
+ export const EVENT_VERSION = 1;
9
+ export const TICKET_STATUSES = ["not_started", "working", "blocked", "done", "skipped"];
10
+ export const ticketStatusSchema = z.enum(TICKET_STATUSES);
11
+ const text = z.string().trim().min(1);
12
+ const minutes = z.number().int().positive().max(100_000);
13
+ const base = { v: z.literal(EVENT_VERSION), ts: z.iso.datetime() };
14
+ export const questionOptionSchema = z.object({
15
+ letter: z.string().regex(/^[A-Z]$/),
16
+ label: text,
17
+ consequence: text.optional(),
18
+ });
19
+ const httpUrl = z.url().refine((value) => /^https?:\/\//i.test(value), "Use an HTTP or HTTPS URL");
20
+ // Something attached to a question: a local path or a web link.
21
+ export const fileLinkSchema = z
22
+ .object({ path: text.optional(), url: httpUrl.optional(), label: text.optional() })
23
+ .refine((file) => Boolean(file.path) !== Boolean(file.url), "Give a path or a URL");
24
+ export const DELIVERABLE_KINDS = ["file", "folder", "report", "screenshot", "link"];
25
+ export const eventSchema = z.discriminatedUnion("type", [
26
+ z.object({
27
+ ...base,
28
+ type: z.literal("run.start"),
29
+ title: text,
30
+ subtitle: text.optional(),
31
+ itemLabel: text.optional(),
32
+ }),
33
+ // Closes the run. Nothing more is recorded on it; new work needs `run.start`.
34
+ z.object({ ...base, type: z.literal("run.finish"), outcome: text }),
35
+ z.object({
36
+ ...base,
37
+ type: z.literal("ticket.add"),
38
+ id: text,
39
+ title: text,
40
+ estimateMin: minutes,
41
+ status: ticketStatusSchema.optional(),
42
+ // Another ticket in the run this one can't start until it's done.
43
+ waitsFor: text.optional(),
44
+ }),
45
+ z.object({
46
+ ...base,
47
+ type: z.literal("ticket.update"),
48
+ id: text,
49
+ title: text.optional(),
50
+ estimateMin: minutes.optional(),
51
+ status: ticketStatusSchema.optional(),
52
+ // The step inside the ticket, like "Build" or "Fixes". Empty clears it.
53
+ stage: z.string().trim().optional(),
54
+ note: z.string().trim().optional(),
55
+ // Empty clears it.
56
+ waitsFor: z.string().trim().optional(),
57
+ }),
58
+ z.object({ ...base, type: z.literal("ticket.remove"), id: text }),
59
+ // A blocker that is not a ticket status, like a failing external service.
60
+ z.object({ ...base, type: z.literal("stuck.set"), id: text, reason: text, ticket: text.optional() }),
61
+ z.object({ ...base, type: z.literal("stuck.clear"), id: text }),
62
+ // The agent asks, picks `default`, and keeps working on it until answered.
63
+ z.object({
64
+ ...base,
65
+ type: z.literal("question.ask"),
66
+ id: text,
67
+ title: text,
68
+ question: text,
69
+ options: z.array(questionOptionSchema).max(26).optional(),
70
+ // An option letter, or a word such as "Your check" when there are no options.
71
+ default: text,
72
+ // True when the agent will not act on the default and waits for the answer.
73
+ waits: z.boolean().optional(),
74
+ background: text.optional(),
75
+ files: z.array(fileLinkSchema).optional(),
76
+ raisedBy: text.optional(),
77
+ // The ticket this question came up on.
78
+ ticket: text.optional(),
79
+ }),
80
+ z.object({
81
+ ...base,
82
+ type: z.literal("question.answer"),
83
+ id: text,
84
+ choice: text,
85
+ // The user's own words, quoted under the answer.
86
+ words: text.optional(),
87
+ changedCourse: z.boolean(),
88
+ }),
89
+ // Adds detail to an open question; its reference and default stay.
90
+ z.object({
91
+ ...base,
92
+ type: z.literal("question.update"),
93
+ id: text,
94
+ question: text.optional(),
95
+ background: text.optional(),
96
+ files: z.array(fileLinkSchema).optional(),
97
+ }),
98
+ z.object({ ...base, type: z.literal("question.remove"), id: text }),
99
+ // `path` is relative to the worktree root, or absolute when outside it.
100
+ z.object({
101
+ ...base,
102
+ type: z.literal("deliverable.add"),
103
+ id: text,
104
+ title: text,
105
+ path: text.optional(),
106
+ url: httpUrl.optional(),
107
+ ticket: text.optional(),
108
+ kind: z.enum(DELIVERABLE_KINDS).optional(),
109
+ }).refine((event) => Boolean(event.path) !== Boolean(event.url), "A deliverable has a path or a URL"),
110
+ z.object({ ...base, type: z.literal("deliverable.remove"), id: text }),
111
+ z.object({ ...base, type: z.literal("activity.add"), id: text, text, ticket: text.optional() }),
112
+ z.object({ ...base, type: z.literal("activity.update"), id: text, text }),
113
+ z.object({ ...base, type: z.literal("activity.remove"), id: text }),
114
+ // One line at the top saying what the agent is doing right now.
115
+ z.object({ ...base, type: z.literal("ticker.set"), text }),
116
+ z.object({ ...base, type: z.literal("ticker.clear") }),
117
+ ]);
@@ -0,0 +1,37 @@
1
+ // Display helpers shared by the panel and the `show` command. Times are shown
2
+ // in the viewer's local time zone.
3
+ const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
4
+ export function formatMinutes(totalMin) {
5
+ const whole = Math.max(0, Math.floor(totalMin));
6
+ if (whole < 1)
7
+ return "<1 min";
8
+ const hours = Math.floor(whole / 60);
9
+ const minutes = whole % 60;
10
+ if (!hours)
11
+ return `${minutes} min`;
12
+ return minutes ? `${hours} h ${minutes} min` : `${hours} h`;
13
+ }
14
+ // Estimated work done out of the total, in the units people estimate in:
15
+ // "5 of 10 min", "1 h 30 min of 2 h". Under an hour on both sides, the unit is
16
+ // said once.
17
+ // Once everything is done, how long it really took instead: "1 h 20 min".
18
+ export function formatWorkDone(doneMin, totalMin, tookMin) {
19
+ if (tookMin !== undefined)
20
+ return formatMinutes(tookMin);
21
+ if (totalMin < 60 && doneMin >= 1)
22
+ return `${Math.floor(doneMin)} of ${formatMinutes(totalMin)}`;
23
+ return `${doneMin < 1 ? "0 min" : formatMinutes(doneMin)} of ${formatMinutes(totalMin)}`;
24
+ }
25
+ export function formatClock(iso) {
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
+ export function formatAgo(iso, nowMs) {
32
+ const minutes = (nowMs - Date.parse(iso)) / 60_000;
33
+ return minutes < 1 ? "just now" : `${formatMinutes(minutes)} ago`;
34
+ }
35
+ export function minutesSince(iso, nowMs) {
36
+ return Math.max(0, (nowMs - Date.parse(iso)) / 60_000);
37
+ }
@@ -0,0 +1,72 @@
1
+ import { headlineText, shortTitle } from "./dashboard.js";
2
+ import { formatAgo, formatClock, formatMinutes, formatWorkDone, minutesSince } from "./format.js";
3
+ const STATUS = { not_started: "Not started", working: "Working", blocked: "Blocked", done: "Done", skipped: "Skipped" };
4
+ // The dashboard as plain text, so an agent can check what the user sees.
5
+ export function dashboardText(dashboard, nowMs) {
6
+ if (!dashboard.run && !dashboard.tickets.length)
7
+ return "No run yet. Start one with: paseo-progress start \"<title>\"";
8
+ const lines = [];
9
+ const when = (iso) => `${formatClock(iso)} (${formatAgo(iso, nowMs)})`;
10
+ lines.push(dashboard.run?.title ?? "Progress");
11
+ if (dashboard.run?.subtitle)
12
+ lines.push(dashboard.run.subtitle);
13
+ lines.push(headlineText(dashboard));
14
+ if (dashboard.run?.finished) {
15
+ lines.push(`Finished ${when(dashboard.run.finished.at)}: ${dashboard.run.finished.outcome}`);
16
+ lines.push(`This run is closed. Start new work with: paseo-progress start "<title>"`);
17
+ }
18
+ if (dashboard.ticker)
19
+ lines.push(`Now: ${dashboard.ticker.text}`);
20
+ if (dashboard.updatedAt)
21
+ lines.push(`Last updated ${when(dashboard.updatedAt)}`);
22
+ if (dashboard.stale && dashboard.updatedAt)
23
+ lines.push(`POSSIBLY STALE: no update for ${formatMinutes(minutesSince(dashboard.updatedAt, nowMs))}.`);
24
+ if (dashboard.progress.totalMin) {
25
+ lines.push(`${dashboard.progress.percent}% of estimated work done, ${dashboard.progress.tookMin !== undefined ? "took " : ""}${formatWorkDone(dashboard.progress.doneMin, dashboard.progress.totalMin, dashboard.progress.tookMin)}`);
26
+ }
27
+ if (dashboard.issues.length) {
28
+ lines.push("", `Skipped lines: ${dashboard.issues.map((issue) => `line ${issue.line} (${issue.reason})`).join(", ")}`);
29
+ }
30
+ if (dashboard.stuck.length) {
31
+ lines.push("", "Stuck");
32
+ for (const item of dashboard.stuck) {
33
+ const detail = item.kind === "overdue" ? `no update for longer than its ${formatMinutes(item.estimateMin)} estimate`
34
+ : item.kind === "blocked" ? `blocked${item.note ? `: ${item.note}` : ""}`
35
+ : `${item.id}: ${item.reason}`;
36
+ lines.push(` ${item.title} (${detail}), since ${when(item.since)}`);
37
+ }
38
+ }
39
+ lines.push("", `${dashboard.run?.itemLabel ?? "Ticket"}s`);
40
+ for (const ticket of dashboard.tickets) {
41
+ const stage = ticket.status === "working" && ticket.stage ? `, ${ticket.stage} stage` : "";
42
+ const status = ticket.waitingFor ? `Waiting for ${ticket.waitingFor.id}` : STATUS[ticket.status];
43
+ lines.push(` ${ticket.id} ${status}${stage} ${ticket.estimateMin} min ${ticket.title}`);
44
+ }
45
+ if (dashboard.questions.open.length) {
46
+ lines.push("", "Questions waiting");
47
+ for (const question of dashboard.questions.open) {
48
+ lines.push(` ${question.id} ${question.ticketId ? `[${question.ticketId}] ` : ""}(${question.title}): ${question.question} Default ${question.default}${question.waits ? ", waiting for the answer" : ""}`);
49
+ for (const option of question.options)
50
+ lines.push(` ${option.letter}) ${option.label}${option.consequence ? `: ${option.consequence}` : ""}`);
51
+ }
52
+ }
53
+ if (dashboard.questions.answered.length) {
54
+ lines.push("", "Answered");
55
+ for (const question of dashboard.questions.answered) {
56
+ lines.push(` ${question.id} (${question.title}): ${question.answer.choice}${question.answer.changedCourse ? " (changed course)" : ""}`);
57
+ }
58
+ }
59
+ if (dashboard.deliverables.length) {
60
+ lines.push("", "Latest deliverables");
61
+ for (const deliverable of dashboard.deliverables.slice(0, 5)) {
62
+ const ticket = deliverable.ticketId ? `, ${deliverable.ticketLabel ?? shortTitle(deliverable.ticketId)}` : "";
63
+ lines.push(` ${deliverable.id} ${deliverable.title}: ${deliverable.url ?? deliverable.path}${ticket}`);
64
+ }
65
+ }
66
+ if (dashboard.activity.length) {
67
+ lines.push("", "Activity");
68
+ for (const entry of dashboard.activity.slice(0, 5))
69
+ lines.push(` ${entry.id} ${entry.ticketId ? `[${entry.ticketId}] ` : ""}${entry.text} (${formatAgo(entry.at, nowMs)})`);
70
+ }
71
+ return lines.join("\n");
72
+ }
Binary file
Binary file
Binary file
Binary file
@@ -0,0 +1,33 @@
1
+ import type { PluginClientContext } from "@getpaseo/plugin/client";
2
+ import { ProgressPanel } from "./client/panel";
3
+ import { contributePills, progressPanel } from "./client/pills";
4
+
5
+ export default function contribute(client: PluginClientContext) {
6
+ const stopPills = contributePills(client);
7
+ const panel = progressPanel();
8
+ const stopPanel = client.addWorkspacePanel({
9
+ id: panel.id,
10
+ title: "Progress",
11
+ icon: "ListChecks",
12
+ context: "workspace",
13
+ locations: [panel.location],
14
+ Component: ProgressPanel,
15
+ });
16
+ const stopCommand = client.addCommandCenterItem({
17
+ id: "open-progress",
18
+ title: "Open Progress",
19
+ icon: "ListChecks",
20
+ keywords: ["dashboard", "tickets", "status", "questions"],
21
+ context: "workspace",
22
+ // Beside the agent chat where Paseo can show both; a tab of its own on phones.
23
+ onSelect({ openPanel }) {
24
+ openPanel(panel.id, { location: panel.location });
25
+ },
26
+ });
27
+
28
+ return () => {
29
+ void stopPanel();
30
+ void stopCommand();
31
+ stopPills();
32
+ };
33
+ }
@@ -0,0 +1,19 @@
1
+ import type { PluginServerContext } from "@getpaseo/plugin/server";
2
+ import { handleGetAttention, handleGetDashboard, handleMarkPanelOpened } from "./server/dashboard";
3
+ import { handleInstallLauncher, handleLauncherStatus } from "./server/launcher";
4
+ import { handleOpenDeliverable, handlePreviewDeliverable } from "./server/open";
5
+ import { handleInstallSkill, handleSkillStatus } from "./server/skill";
6
+ import { getAttention, getDashboard, getLauncherStatus, getSkillStatus, installLauncher, installSkill, markPanelOpened, openDeliverable, previewDeliverable } from "./shared/rpc";
7
+
8
+ export default function contribute(server: PluginServerContext) {
9
+ server.handle(getDashboard, handleGetDashboard);
10
+ server.handle(getAttention, handleGetAttention);
11
+ server.handle(markPanelOpened, handleMarkPanelOpened);
12
+ server.handle(getLauncherStatus, handleLauncherStatus);
13
+ server.handle(installLauncher, handleInstallLauncher);
14
+ server.handle(openDeliverable, handleOpenDeliverable);
15
+ server.handle(previewDeliverable, handlePreviewDeliverable);
16
+ server.handle(getSkillStatus, handleSkillStatus);
17
+ server.handle(installSkill, handleInstallSkill);
18
+ return () => {};
19
+ }