@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
package/shared/rpc.ts ADDED
@@ -0,0 +1,109 @@
1
+ import { defineRpc } from "@getpaseo/plugin";
2
+ import { z } from "zod";
3
+ import type { Dashboard } from "./dashboard.ts";
4
+
5
+ // `panelOpened`: the Progress panel has been opened for this worktree before.
6
+ // `version` changes whenever anything else in the result does.
7
+ export type DashboardResult = { configured: boolean; dashboard: Dashboard; panelOpened: boolean; version: string };
8
+
9
+ export const getDashboard = defineRpc({
10
+ name: "progress.get",
11
+ input: z.object({
12
+ workspaceId: z.string().min(1),
13
+ workspaceDirectory: z.string().min(1),
14
+ // The version the caller already has; if it's current, the reply is `unchanged`.
15
+ since: z.string().optional(),
16
+ }),
17
+ output: z.custom<DashboardResult | { unchanged: true; version: string }>(),
18
+ });
19
+
20
+ // What a message-box pill shows: counts that need the user, and whether a run
21
+ // is open in a worktree whose panel has never been opened.
22
+ export type AttentionResult = { configured: boolean; questions: number; stuck: number; runOpen: boolean; panelOpened: boolean };
23
+
24
+ export const getAttention = defineRpc({
25
+ name: "progress.attention",
26
+ input: z.object({
27
+ workspaceId: z.string().min(1),
28
+ workspaceDirectory: z.string().min(1),
29
+ }),
30
+ output: z.custom<AttentionResult>(),
31
+ });
32
+
33
+ // Records that the Progress panel was opened, so the "Progress" pill goes away for good.
34
+ export const markPanelOpened = defineRpc({
35
+ name: "progress.panel.opened",
36
+ input: z.object({
37
+ workspaceId: z.string().min(1),
38
+ workspaceDirectory: z.string().min(1),
39
+ }),
40
+ output: z.object({}),
41
+ });
42
+
43
+ // The `paseo-progress` launcher on the daemon host. "foreign" means another
44
+ // file already sits at that path; the plugin leaves it alone.
45
+ const launcherStatus = z.object({
46
+ path: z.string(),
47
+ state: z.enum(["missing", "current", "outdated", "foreign"]),
48
+ onPath: z.boolean(),
49
+ });
50
+ export type LauncherStatus = z.infer<typeof launcherStatus>;
51
+
52
+ export const getLauncherStatus = defineRpc({
53
+ name: "progress.launcher.status",
54
+ input: z.object({}),
55
+ output: launcherStatus,
56
+ });
57
+
58
+ export const installLauncher = defineRpc({
59
+ name: "progress.launcher.install",
60
+ input: z.object({}),
61
+ output: launcherStatus,
62
+ });
63
+
64
+ // The paseo-progress skill, linked into each agent's skills folder.
65
+ const skillState = z.enum(["missing", "current", "outdated", "foreign"]);
66
+ const skillStatus = z.object({
67
+ state: skillState,
68
+ targets: z.array(z.object({ path: z.string(), state: skillState })),
69
+ });
70
+ export type SkillStatus = z.infer<typeof skillStatus>;
71
+
72
+ export const getSkillStatus = defineRpc({
73
+ name: "progress.skill.status",
74
+ input: z.object({}),
75
+ output: skillStatus,
76
+ });
77
+
78
+ export const installSkill = defineRpc({
79
+ name: "progress.skill.install",
80
+ input: z.object({}),
81
+ output: skillStatus,
82
+ });
83
+
84
+ // Opens a recorded local deliverable on the daemon host with its default app.
85
+ export const openDeliverable = defineRpc({
86
+ name: "progress.deliverable.open",
87
+ input: z.object({
88
+ workspaceId: z.string().min(1),
89
+ workspaceDirectory: z.string().min(1),
90
+ // A deliverable ("D3") or a question attachment ("Q7.2").
91
+ ref: z.string().min(1),
92
+ }),
93
+ output: z.object({ opened: z.string() }),
94
+ });
95
+
96
+ // An image or text file's contents, for the preview dialog.
97
+ export const previewDeliverable = defineRpc({
98
+ name: "progress.deliverable.preview",
99
+ input: z.object({
100
+ workspaceId: z.string().min(1),
101
+ workspaceDirectory: z.string().min(1),
102
+ // A deliverable ("D3") or a question attachment ("Q7.2").
103
+ ref: z.string().min(1),
104
+ }),
105
+ output: z.discriminatedUnion("kind", [
106
+ z.object({ kind: z.literal("image"), dataUri: z.string(), bytes: z.number() }),
107
+ z.object({ kind: z.literal("text"), text: z.string(), bytes: z.number(), truncated: z.boolean() }),
108
+ ]),
109
+ });
package/shared/show.ts ADDED
@@ -0,0 +1,68 @@
1
+ import type { Dashboard } from "./dashboard.ts";
2
+ import { headlineText, shortTitle } from "./dashboard.ts";
3
+ import { formatAgo, formatClock, formatMinutes, formatWorkDone, minutesSince } from "./format.ts";
4
+
5
+ const STATUS: Record<string, string> = { not_started: "Not started", working: "Working", blocked: "Blocked", done: "Done", skipped: "Skipped" };
6
+
7
+ // The dashboard as plain text, so an agent can check what the user sees.
8
+ export function dashboardText(dashboard: Dashboard, nowMs: number): string {
9
+ if (!dashboard.run && !dashboard.tickets.length) return "No run yet. Start one with: paseo-progress start \"<title>\"";
10
+ const lines: string[] = [];
11
+ const when = (iso: string) => `${formatClock(iso)} (${formatAgo(iso, nowMs)})`;
12
+ lines.push(dashboard.run?.title ?? "Progress");
13
+ if (dashboard.run?.subtitle) lines.push(dashboard.run.subtitle);
14
+ lines.push(headlineText(dashboard));
15
+ if (dashboard.run?.finished) {
16
+ lines.push(`Finished ${when(dashboard.run.finished.at)}: ${dashboard.run.finished.outcome}`);
17
+ lines.push(`This run is closed. Start new work with: paseo-progress start "<title>"`);
18
+ }
19
+ if (dashboard.ticker) lines.push(`Now: ${dashboard.ticker.text}`);
20
+ if (dashboard.updatedAt) lines.push(`Last updated ${when(dashboard.updatedAt)}`);
21
+ if (dashboard.stale && dashboard.updatedAt) lines.push(`POSSIBLY STALE: no update for ${formatMinutes(minutesSince(dashboard.updatedAt, nowMs))}.`);
22
+ if (dashboard.progress.totalMin) {
23
+ lines.push(`${dashboard.progress.percent}% of estimated work done, ${dashboard.progress.tookMin !== undefined ? "took " : ""}${formatWorkDone(dashboard.progress.doneMin, dashboard.progress.totalMin, dashboard.progress.tookMin)}`);
24
+ }
25
+ if (dashboard.issues.length) {
26
+ lines.push("", `Skipped lines: ${dashboard.issues.map((issue) => `line ${issue.line} (${issue.reason})`).join(", ")}`);
27
+ }
28
+ if (dashboard.stuck.length) {
29
+ lines.push("", "Stuck");
30
+ for (const item of dashboard.stuck) {
31
+ const detail = item.kind === "overdue" ? `no update for longer than its ${formatMinutes(item.estimateMin)} estimate`
32
+ : item.kind === "blocked" ? `blocked${item.note ? `: ${item.note}` : ""}`
33
+ : `${item.id}: ${item.reason}`;
34
+ lines.push(` ${item.title} (${detail}), since ${when(item.since)}`);
35
+ }
36
+ }
37
+ lines.push("", `${dashboard.run?.itemLabel ?? "Ticket"}s`);
38
+ for (const ticket of dashboard.tickets) {
39
+ const stage = ticket.status === "working" && ticket.stage ? `, ${ticket.stage} stage` : "";
40
+ const status = ticket.waitingFor ? `Waiting for ${ticket.waitingFor.id}` : STATUS[ticket.status];
41
+ lines.push(` ${ticket.id} ${status}${stage} ${ticket.estimateMin} min ${ticket.title}`);
42
+ }
43
+ if (dashboard.questions.open.length) {
44
+ lines.push("", "Questions waiting");
45
+ for (const question of dashboard.questions.open) {
46
+ lines.push(` ${question.id} ${question.ticketId ? `[${question.ticketId}] ` : ""}(${question.title}): ${question.question} Default ${question.default}${question.waits ? ", waiting for the answer" : ""}`);
47
+ for (const option of question.options) lines.push(` ${option.letter}) ${option.label}${option.consequence ? `: ${option.consequence}` : ""}`);
48
+ }
49
+ }
50
+ if (dashboard.questions.answered.length) {
51
+ lines.push("", "Answered");
52
+ for (const question of dashboard.questions.answered) {
53
+ lines.push(` ${question.id} (${question.title}): ${question.answer!.choice}${question.answer!.changedCourse ? " (changed course)" : ""}`);
54
+ }
55
+ }
56
+ if (dashboard.deliverables.length) {
57
+ lines.push("", "Latest deliverables");
58
+ for (const deliverable of dashboard.deliverables.slice(0, 5)) {
59
+ const ticket = deliverable.ticketId ? `, ${deliverable.ticketLabel ?? shortTitle(deliverable.ticketId)}` : "";
60
+ lines.push(` ${deliverable.id} ${deliverable.title}: ${deliverable.url ?? deliverable.path}${ticket}`);
61
+ }
62
+ }
63
+ if (dashboard.activity.length) {
64
+ lines.push("", "Activity");
65
+ for (const entry of dashboard.activity.slice(0, 5)) lines.push(` ${entry.id} ${entry.ticketId ? `[${entry.ticketId}] ` : ""}${entry.text} (${formatAgo(entry.at, nowMs)})`);
66
+ }
67
+ return lines.join("\n");
68
+ }
@@ -0,0 +1,93 @@
1
+ import type { Activity, Deliverable, Question, Ticket } from "./dashboard.ts";
2
+ import type { TicketStatus } from "./events.ts";
3
+
4
+ // One span in a ticket's life: from a status or stage change to the next one.
5
+ export interface TimelineStep {
6
+ at: string;
7
+ // When the next step began; null while this one is still going (or it's the end).
8
+ until: string | null;
9
+ label: string;
10
+ status: TicketStatus;
11
+ notes: string[];
12
+ // Length of the step; null for a finished ticket's last step, which has no end.
13
+ minutes: number | null;
14
+ }
15
+
16
+ // Where a related item came from: tagged with `--ticket`, or logged while the
17
+ // ticket was the one working (older entries, recorded before tags existed).
18
+ export type Link = "tagged" | "by-time";
19
+
20
+ export interface TicketStory {
21
+ timeline: TimelineStep[];
22
+ workedMin: number;
23
+ deliverables: Deliverable[];
24
+ questions: Array<Question & { link: Link }>;
25
+ activity: Array<Activity & { link: Link }>;
26
+ }
27
+
28
+ const OPEN_ENDED: TicketStatus[] = ["working", "blocked", "not_started"];
29
+
30
+ function labelFor(status: TicketStatus, stage: string | undefined, first: boolean): string {
31
+ if (first && status === "not_started") return "Added";
32
+ switch (status) {
33
+ case "working": return stage ? `${stage} stage` : "Working";
34
+ case "blocked": return "Blocked";
35
+ case "done": return "Done";
36
+ case "skipped": return "Skipped";
37
+ default: return "Back to not started";
38
+ }
39
+ }
40
+
41
+ export function ticketTimeline(ticket: Ticket, nowMs: number): TimelineStep[] {
42
+ const steps: TimelineStep[] = [];
43
+ let status: TicketStatus = "not_started";
44
+ let stage: string | undefined;
45
+ ticket.history.forEach((change, index) => {
46
+ const statusChanged = change.status !== undefined && change.status !== status;
47
+ const stageChanged = change.stage !== undefined && (change.stage || undefined) !== stage;
48
+ if (change.status !== undefined) status = change.status;
49
+ if (change.stage !== undefined) stage = change.stage || undefined;
50
+ // A new step when the status changes, or the stage changes while working.
51
+ if (index === 0 || statusChanged || (stageChanged && status === "working")) {
52
+ steps.push({ at: change.at, until: null, label: labelFor(status, stage, index === 0), status, notes: [], minutes: null });
53
+ }
54
+ if (change.note) steps[steps.length - 1].notes.push(change.note);
55
+ });
56
+ steps.forEach((step, index) => {
57
+ const next = steps[index + 1];
58
+ step.until = next?.at ?? null;
59
+ const end = next ? Date.parse(next.at) : OPEN_ENDED.includes(step.status) ? nowMs : null;
60
+ step.minutes = end === null ? null : Math.max(0, (end - Date.parse(step.at)) / 60_000);
61
+ });
62
+ return steps;
63
+ }
64
+
65
+ // Spans when this ticket was the one working, for matching untagged entries by time.
66
+ // Each includes its start but not its end, so an entry logged at a handoff
67
+ // belongs to the ticket that just started.
68
+ function workingWindows(steps: TimelineStep[], nowMs: number): Array<[number, number]> {
69
+ return steps
70
+ .filter((step) => step.status === "working")
71
+ .map((step) => [Date.parse(step.at), step.until ? Date.parse(step.until) : nowMs + 1]);
72
+ }
73
+
74
+ export function ticketStory(ticket: Ticket, related: { deliverables: Deliverable[]; questions: Question[]; activity: Activity[] }, nowMs: number): TicketStory {
75
+ const timeline = ticketTimeline(ticket, nowMs);
76
+ const windows = workingWindows(timeline, nowMs);
77
+ const during = (iso: string) => windows.some(([from, to]) => Date.parse(iso) >= from && Date.parse(iso) < to);
78
+ const link = <T extends { ticketId?: string }>(item: T, at: string): Link | null =>
79
+ item.ticketId ? (item.ticketId === ticket.id ? "tagged" : null) : during(at) ? "by-time" : null;
80
+ return {
81
+ timeline,
82
+ workedMin: timeline.filter((step) => step.status === "working").reduce((sum, step) => sum + (step.minutes ?? 0), 0),
83
+ deliverables: related.deliverables.filter((deliverable) => deliverable.ticketId === ticket.id),
84
+ questions: related.questions.flatMap((question) => {
85
+ const found = link(question, question.askedAt);
86
+ return found ? [{ ...question, link: found }] : [];
87
+ }),
88
+ activity: related.activity.flatMap((entry) => {
89
+ const found = link(entry, entry.at);
90
+ return found ? [{ ...entry, link: found }] : [];
91
+ }),
92
+ };
93
+ }
@@ -0,0 +1,79 @@
1
+ ---
2
+ name: paseo-progress
3
+ description: Record progress on the Paseo Progress dashboard with the paseo-progress command. Use when implementing a spec or a set of tickets, running any job with more than 5 steps or over 30 minutes, or when the user asks for a progress dashboard.
4
+ ---
5
+
6
+ # Paseo progress
7
+
8
+ The user watches a live dashboard in Paseo, drawn from the progress file in this worktree. You keep it true by running `paseo-progress` the moment each change happens: the dashboard is a side effect of the work, never a summary written afterwards. Run it from inside the worktree you are working in: it records to that worktree's dashboard, and `show` names the worktree it read. `paseo-progress --help` lists every command; `paseo-progress <command> --help` gives its options.
9
+
10
+ The user answers questions by reference ("Q7 A") and reads everything else at a glance, so every line you record is written for a product manager: plain words, no jargon.
11
+
12
+ ## 1. Set up the run
13
+
14
+ Run `paseo-progress show`. When it shows an open run for this job, continue it. A run marked Finished is closed, even if it looks like your job; start a new one. Otherwise:
15
+
16
+ 1. `paseo-progress start "<job title>"`, with `--subtitle` naming the spec or ticket folder.
17
+ 2. Add every ticket in order with `paseo-progress ticket add "Ticket 01: <title>" --estimate <minutes>`, with `--waits-for T02` on any that can't start before another finishes. Keep the "Ticket NN:" prefix: the dashboard shortens titles to it. For a job without tickets, add its steps instead and pass `--item-label Step` to `start`.
18
+ 3. `paseo-progress activity add "<one line: what this run will do>"`.
19
+
20
+ Done when `show` lists every ticket with an estimate.
21
+
22
+ ## 2. Work each ticket
23
+
24
+ Record each change as it happens, on the ticket's id from `show` (T01, T02, ...):
25
+
26
+ | When | Command |
27
+ | --- | --- |
28
+ | You start a ticket | `ticket update T03 --status working --stage Build` |
29
+ | You move to the next stage | `ticket update T03 --stage Review`, then `Fixes`, then `Verify` |
30
+ | Something reviewable lands (a screenshot folder, a report, a preview URL) | `deliverable add "<what it shows>" <path or URL> --ticket T03` |
31
+ | A milestone the user would want in the story | `activity add "<one line>" --ticket T03` |
32
+ | Between bigger events | `ticker set "<what you are doing right now>"` |
33
+ | The ticket is finished | `ticket update T03 --status done`, then start the next one |
34
+ | The plan drops it | `ticket update T03 --status skipped --note "<why>"` |
35
+
36
+ Pass `--ticket` on every activity, deliverable and question that belongs to a ticket. The user presses a ticket to see its story: each stage and how long it took, with the activity, deliverables and questions tagged to it. Leave it off only for run-wide notes. Stage changes and notes are timed from your `ticket update` calls, so make them when the change happens.
37
+
38
+ A working ticket turns stuck on its own once it goes longer than its estimate without an update: a status, stage or note change, or activity tagged to it. When a ticket will run long, keep updating its stage, or raise its estimate with `--estimate`.
39
+
40
+ ## 3. Ask questions without stopping
41
+
42
+ When a decision belongs to the user, ask it and keep working:
43
+
44
+ ```sh
45
+ paseo-progress question ask "<short title>" "<the question, in one or two sentences>" \
46
+ --option "A=<choice> | <what happens if they pick it>" \
47
+ --option "B=<choice> | <what happens if they pick it>" \
48
+ --default <letter> --ticket T03 --raised-by "Ticket 03 design review" \
49
+ [--background "<what they need to decide without opening the worktree>"] \
50
+ [--file <path or http(s) URL>=<what it shows>]
51
+ ```
52
+
53
+ - The default is the safest choice. Carry on with it straight away.
54
+ - Add `--waits` when acting on any choice needs the user's yes: purchases, production, live vendor calls, commits. Then work on something else until they answer.
55
+ - When the user replies ("Q7 A", or in their own words), run `question answer Q7 A --words "<their words>"`. The command reports whether the answer differs from the default; when it does, change course before anything else.
56
+ - When a question is about how something looks, attach the screenshot, mockup or preview link with `--file`. The user opens it from the question: images preview in Paseo, links open in its browser. Add more later with `question update Q7 --file <path>=<what it shows>`.
57
+ - When the user asks about a question, answer in chat and also put the explanation on the question with `question update Q7 --background "<text>"`, so the dashboard carries it too.
58
+ - `question remove Q7` withdraws a question that no longer applies.
59
+
60
+ ## 4. Flag what is stuck
61
+
62
+ - A ticket that has to wait for another ticket in this run is not stuck; that's just the order of work. Record it with `ticket add ... --waits-for T03` (or `ticket update T05 --waits-for T03`) and leave its status alone. The dashboard shows it calmly as waiting until T03 is done.
63
+ - A ticket held up by something outside the run (a service, access, a decision nobody has made): `ticket update T03 --status blocked --note "<what it waits on>"`. That shows as stuck. Set it back to `working` when it moves again.
64
+ - A blocker outside any ticket (a failing service, a missing key): `stuck set "<reason>" [--ticket T03]`, then `stuck clear S1` once it is gone.
65
+
66
+ ## 5. Finish
67
+
68
+ When the job is over:
69
+
70
+ 1. Mark the last ticket done or skipped.
71
+ 2. Settle every open question: record the user's answer, or withdraw it with `question remove` when it no longer applies. Clear flagged blockers with `stuck clear`.
72
+ 3. `paseo-progress finish "<one line: the outcome>"`. It refuses, and says what is still open, until the steps above are done.
73
+ 4. Run `paseo-progress show`.
74
+
75
+ The finished run stays on the dashboard, marked Finished with its outcome, until the next `start`. It takes no more updates.
76
+
77
+ If the job pauses with questions only the user can answer, don't finish: leave the run open so the questions stay in front of them.
78
+
79
+ Done when `show` says Finished with the right outcome.
package/tsconfig.json ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2020",
4
+ "module": "ESNext",
5
+ "moduleResolution": "Bundler",
6
+ "lib": [
7
+ "ES2023"
8
+ ],
9
+ "jsx": "react-jsx",
10
+ "strict": true,
11
+ "skipLibCheck": true,
12
+ "noEmit": true,
13
+ "allowImportingTsExtensions": true,
14
+ "esModuleInterop": true,
15
+ "allowSyntheticDefaultImports": true
16
+ },
17
+ "include": [
18
+ "**/*.ts",
19
+ "**/*.tsx"
20
+ ],
21
+ "exclude": [
22
+ "node_modules"
23
+ ]
24
+ }