@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
package/package.json
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@stevecastaneda/paseo-progress",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "A live progress dashboard per worktree for Paseo, drawn from a file agents write with the paseo-progress command.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Steve Castaneda",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/stevecastaneda/paseo-plugins.git",
|
|
10
|
+
"directory": "progress"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/stevecastaneda/paseo-plugins/tree/main/progress#readme",
|
|
13
|
+
"keywords": [
|
|
14
|
+
"paseo",
|
|
15
|
+
"paseo-plugin",
|
|
16
|
+
"progress",
|
|
17
|
+
"dashboard",
|
|
18
|
+
"coding-agents"
|
|
19
|
+
],
|
|
20
|
+
"type": "module",
|
|
21
|
+
"engines": {
|
|
22
|
+
"node": "^22.18.0 || >=23.6.0"
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"paseo-plugin.json",
|
|
26
|
+
"npm-shrinkwrap.json",
|
|
27
|
+
"dist/",
|
|
28
|
+
"index.client.tsx",
|
|
29
|
+
"index.server.ts",
|
|
30
|
+
"client/",
|
|
31
|
+
"server/",
|
|
32
|
+
"shared/",
|
|
33
|
+
"skills/",
|
|
34
|
+
"images/",
|
|
35
|
+
"tsconfig.json",
|
|
36
|
+
"!**/*.test.ts"
|
|
37
|
+
],
|
|
38
|
+
"publishConfig": {
|
|
39
|
+
"access": "public"
|
|
40
|
+
},
|
|
41
|
+
"scripts": {
|
|
42
|
+
"typecheck": "tsc --noEmit",
|
|
43
|
+
"test": "node --test \"*/*.test.ts\"",
|
|
44
|
+
"dev": "node ../scripts/dev.mjs",
|
|
45
|
+
"build:cli": "rm -rf dist && tsc -p tsconfig.cli.json",
|
|
46
|
+
"prepack": "npm run build:cli",
|
|
47
|
+
"postpack": "rm -rf dist"
|
|
48
|
+
},
|
|
49
|
+
"devDependencies": {
|
|
50
|
+
"@getpaseo/plugin": "^0.9.2",
|
|
51
|
+
"@tanstack/react-query": "^5.90.11",
|
|
52
|
+
"@types/node": "^20.9.0",
|
|
53
|
+
"@types/react": "~19.2.0",
|
|
54
|
+
"react": "19.1.0",
|
|
55
|
+
"react-native": "0.81.5",
|
|
56
|
+
"typescript": "^5.9.3"
|
|
57
|
+
},
|
|
58
|
+
"dependencies": {
|
|
59
|
+
"@getpaseo/client": "^0.9.2",
|
|
60
|
+
"zod": "^4.4.3"
|
|
61
|
+
}
|
|
62
|
+
}
|
package/server/cli.ts
ADDED
|
@@ -0,0 +1,541 @@
|
|
|
1
|
+
// The command agents run to record progress. Each command appends one event
|
|
2
|
+
// to the worktree's progress file and prints what it recorded.
|
|
3
|
+
import { existsSync, realpathSync, statSync } from "node:fs";
|
|
4
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { parseArgs } from "node:util";
|
|
7
|
+
import { isSettled, type Dashboard, type NextIds } from "../shared/dashboard.ts";
|
|
8
|
+
import { DELIVERABLE_KINDS, PROGRESS_FILE, TICKET_STATUSES } from "../shared/events.ts";
|
|
9
|
+
import { appendProgress, InvalidEvent, readProgress, type EventDraft } from "./progress-file.ts";
|
|
10
|
+
import { outside } from "./paths.ts";
|
|
11
|
+
import { installLauncher } from "./launcher.ts";
|
|
12
|
+
import { dashboardText } from "../shared/show.ts";
|
|
13
|
+
import { imageMimeType } from "../shared/attachments.ts";
|
|
14
|
+
import { homedir } from "node:os";
|
|
15
|
+
|
|
16
|
+
export interface CliOptions {
|
|
17
|
+
cwd: string;
|
|
18
|
+
now?: () => Date;
|
|
19
|
+
out?: (line: string) => void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
class UsageError extends Error {}
|
|
23
|
+
|
|
24
|
+
interface State {
|
|
25
|
+
dashboard: Dashboard;
|
|
26
|
+
nextIds: NextIds;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface Command {
|
|
30
|
+
usage: string;
|
|
31
|
+
summary: string;
|
|
32
|
+
options?: Record<string, { type: "string" | "boolean"; multiple?: boolean }>;
|
|
33
|
+
run(args: { positionals: string[]; values: Record<string, string | boolean | Array<string | boolean> | undefined>; state: State; cwd: string; root: string }): { event: EventDraft; message: string };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const USAGE_NAME = "paseo-progress";
|
|
37
|
+
|
|
38
|
+
function required(value: string | undefined, name: string): string {
|
|
39
|
+
if (!value?.trim()) throw new UsageError(`Missing ${name}.`);
|
|
40
|
+
return value.trim();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function minutesOption(value: unknown, name: string): number | undefined {
|
|
44
|
+
if (value === undefined) return undefined;
|
|
45
|
+
const number = Number(value);
|
|
46
|
+
if (!Number.isInteger(number) || number <= 0) throw new UsageError(`--${name} must be a whole number of minutes, like 90.`);
|
|
47
|
+
return number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function statusOption(value: unknown) {
|
|
51
|
+
if (value === undefined) return undefined;
|
|
52
|
+
const status = String(value).replace(/-/g, "_");
|
|
53
|
+
if (!(TICKET_STATUSES as readonly string[]).includes(status)) {
|
|
54
|
+
throw new UsageError(`--status must be one of: ${TICKET_STATUSES.join(", ")}.`);
|
|
55
|
+
}
|
|
56
|
+
return status as (typeof TICKET_STATUSES)[number];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function stringOption(value: unknown): string | undefined {
|
|
60
|
+
return typeof value === "string" ? value : undefined;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function listOption(value: unknown): string[] {
|
|
64
|
+
return Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === "string") : [];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// "A=Even out the spacing | Cards look balanced" → letter, label, consequence.
|
|
68
|
+
function parseOption(raw: string) {
|
|
69
|
+
const match = /^\s*([A-Za-z])\s*=\s*(.+)$/.exec(raw);
|
|
70
|
+
if (!match) throw new UsageError(`--option must look like "A=<label> | <consequence>", got "${raw}".`);
|
|
71
|
+
const [label, ...rest] = match[2].split("|").map((part) => part.trim());
|
|
72
|
+
if (!label) throw new UsageError(`--option ${match[1].toUpperCase()} needs a label.`);
|
|
73
|
+
return { letter: match[1].toUpperCase(), label, consequence: rest.join(" | ") || undefined };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const URL_PATTERN = /^[a-z][a-z\d+.-]*:\/\//i;
|
|
77
|
+
|
|
78
|
+
// "path/to/file.png=Before and after" → root-relative path and optional label.
|
|
79
|
+
// "https://example.com/?q=1=Preview" → URL and label: an "=" right after a
|
|
80
|
+
// query key belongs to the URL.
|
|
81
|
+
function parseFile(raw: string, cwd: string, root: string) {
|
|
82
|
+
const isUrl = URL_PATTERN.test(raw);
|
|
83
|
+
let index = isUrl ? raw.lastIndexOf("=") : raw.indexOf("=");
|
|
84
|
+
while (isUrl && index !== -1 && /[?&][^=&#]*$/.test(raw.slice(0, index))) index = raw.lastIndexOf("=", index - 1);
|
|
85
|
+
const target = (index === -1 ? raw : raw.slice(0, index)).trim();
|
|
86
|
+
const label = index === -1 ? undefined : raw.slice(index + 1).trim() || undefined;
|
|
87
|
+
if (!target) throw new UsageError(`--file needs a path or URL, got "${raw}".`);
|
|
88
|
+
if (!isUrl) return { path: rootRelative(target, cwd, root), label };
|
|
89
|
+
if (!/^https?:\/\//i.test(target)) throw new UsageError("Only http(s) URLs are supported; give local files as a path.");
|
|
90
|
+
return { url: target, label };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function existingActivity(state: State, id: string | undefined) {
|
|
94
|
+
const wanted = required(id, "activity id").toUpperCase();
|
|
95
|
+
const entry = state.dashboard.activity.find((candidate) => candidate.id === wanted);
|
|
96
|
+
if (!entry) {
|
|
97
|
+
const known = state.dashboard.activity.map((candidate) => candidate.id).join(", ") || "none";
|
|
98
|
+
throw new UsageError(`No activity ${wanted} in this run. Activity: ${known}.`);
|
|
99
|
+
}
|
|
100
|
+
return entry;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function existingQuestion(state: State, id: string | undefined) {
|
|
104
|
+
const wanted = required(id, "question id").toUpperCase();
|
|
105
|
+
const { open, answered } = state.dashboard.questions;
|
|
106
|
+
const question = [...open, ...answered].find((candidate) => candidate.id === wanted);
|
|
107
|
+
if (!question) {
|
|
108
|
+
const known = [...open, ...answered].map((candidate) => candidate.id).join(", ") || "none";
|
|
109
|
+
throw new UsageError(`No question ${wanted} in this run. Questions: ${known}.`);
|
|
110
|
+
}
|
|
111
|
+
return question;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function existingTicket(state: State, id: string | undefined) {
|
|
115
|
+
const wanted = required(id, "ticket id").toUpperCase();
|
|
116
|
+
const ticket = state.dashboard.tickets.find((candidate) => candidate.id.toUpperCase() === wanted);
|
|
117
|
+
if (!ticket) {
|
|
118
|
+
const known = state.dashboard.tickets.map((candidate) => candidate.id).join(", ") || "none";
|
|
119
|
+
throw new UsageError(`No ticket ${wanted} in this run. Tickets: ${known}.`);
|
|
120
|
+
}
|
|
121
|
+
return ticket;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// What still has to be settled before a run can finish.
|
|
125
|
+
function unfinishedWork(dashboard: Dashboard): string[] {
|
|
126
|
+
const label = (dashboard.run?.itemLabel ?? "Ticket").toLowerCase();
|
|
127
|
+
const tickets = dashboard.tickets.filter((ticket) => !isSettled(ticket));
|
|
128
|
+
const flagged = dashboard.stuck.filter((item) => item.kind === "manual");
|
|
129
|
+
return [
|
|
130
|
+
dashboard.questions.open.length
|
|
131
|
+
? `open questions ${dashboard.questions.open.map((question) => question.id).join(", ")} (record the answer with "question answer", or withdraw with "question remove")`
|
|
132
|
+
: "",
|
|
133
|
+
tickets.length ? `${label}s not done or skipped: ${tickets.map((ticket) => ticket.id).join(", ")}` : "",
|
|
134
|
+
flagged.length ? `flagged blockers ${flagged.map((item) => item.id).join(", ")} (clear with "stuck clear")` : "",
|
|
135
|
+
].filter(Boolean);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const commands: Record<string, Command> = {
|
|
139
|
+
start: {
|
|
140
|
+
usage: `start "<title>" [--subtitle <text>] [--item-label <word>]`,
|
|
141
|
+
summary: "Start a new run. Earlier runs stay in the file but leave the dashboard.",
|
|
142
|
+
options: { subtitle: { type: "string" }, "item-label": { type: "string" } },
|
|
143
|
+
run({ positionals, values }) {
|
|
144
|
+
const title = required(positionals[0], "run title");
|
|
145
|
+
return {
|
|
146
|
+
event: { type: "run.start", title, subtitle: stringOption(values.subtitle), itemLabel: stringOption(values["item-label"]) },
|
|
147
|
+
message: `Started run: ${title}`,
|
|
148
|
+
};
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
finish: {
|
|
152
|
+
usage: `finish "<outcome>"`,
|
|
153
|
+
summary: "Close the run with a one-line outcome. Every ticket must be done or skipped, no question open, nothing flagged stuck. New work after this needs start.",
|
|
154
|
+
run({ positionals, state }) {
|
|
155
|
+
const outcome = required(positionals[0], "outcome");
|
|
156
|
+
if (!state.dashboard.run) throw new UsageError("No run to finish.");
|
|
157
|
+
const left = unfinishedWork(state.dashboard);
|
|
158
|
+
if (left.length) throw new UsageError(`Can't finish yet. Still open: ${left.join("; ")}.`);
|
|
159
|
+
return { event: { type: "run.finish", outcome }, message: `Finished run: ${state.dashboard.run.title}` };
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
"ticket add": {
|
|
163
|
+
usage: `ticket add "<title>" --estimate <minutes> [--status <status>] [--waits-for <id>]`,
|
|
164
|
+
summary: "Add a ticket. Prints its id (T01, T02, ...). --waits-for names a ticket it can't start before; that is the order of work, not a blocker.",
|
|
165
|
+
options: { estimate: { type: "string" }, status: { type: "string" }, "waits-for": { type: "string" } },
|
|
166
|
+
run({ positionals, values, state }) {
|
|
167
|
+
const waitsFor = values["waits-for"] === undefined ? undefined : existingTicket(state, stringOption(values["waits-for"])).id;
|
|
168
|
+
const title = required(positionals[0], "ticket title");
|
|
169
|
+
const estimateMin = minutesOption(values.estimate, "estimate");
|
|
170
|
+
if (estimateMin === undefined) throw new UsageError("Missing --estimate <minutes>.");
|
|
171
|
+
const id = state.nextIds.ticket;
|
|
172
|
+
return {
|
|
173
|
+
event: { type: "ticket.add", id, title, estimateMin, status: statusOption(values.status), waitsFor },
|
|
174
|
+
message: `Added ${id}: ${title} (${estimateMin} min)${waitsFor ? `, waits for ${waitsFor}` : ""}`,
|
|
175
|
+
};
|
|
176
|
+
},
|
|
177
|
+
},
|
|
178
|
+
"ticket update": {
|
|
179
|
+
usage: `ticket update <id> [--status <status>] [--stage <name>] [--title <text>] [--estimate <minutes>] [--note <text>] [--waits-for <id>]`,
|
|
180
|
+
summary: `Change a ticket. Statuses: ${TICKET_STATUSES.join(", ")}. --stage names the step in progress, like Build or Fixes ("" clears it). --waits-for names a ticket it can't start before ("" clears it); waiting is not stuck, so don't mark it blocked for that.`,
|
|
181
|
+
options: { status: { type: "string" }, stage: { type: "string" }, title: { type: "string" }, estimate: { type: "string" }, note: { type: "string" }, "waits-for": { type: "string" } },
|
|
182
|
+
run({ positionals, values, state }) {
|
|
183
|
+
const ticket = existingTicket(state, positionals[0]);
|
|
184
|
+
const rawWaitsFor = values["waits-for"] === undefined ? undefined : String(values["waits-for"]).trim();
|
|
185
|
+
const waitsFor = rawWaitsFor === undefined || rawWaitsFor === "" ? rawWaitsFor : existingTicket(state, rawWaitsFor).id;
|
|
186
|
+
if (waitsFor && waitsFor === ticket.id) throw new UsageError(`${ticket.id} can't wait for itself.`);
|
|
187
|
+
// Refuse a loop (T01 waits for T02 waits for T01): neither could ever start.
|
|
188
|
+
for (let next = waitsFor ? state.dashboard.tickets.find((entry) => entry.id === waitsFor) : undefined, seen = 0; next?.waitsFor && seen < 1000; seen++) {
|
|
189
|
+
if (next.waitsFor === ticket.id) throw new UsageError(`${ticket.id} can't wait for ${waitsFor}: ${next.id} already waits for ${ticket.id}, so neither could start.`);
|
|
190
|
+
next = state.dashboard.tickets.find((entry) => entry.id === next!.waitsFor);
|
|
191
|
+
}
|
|
192
|
+
const event = {
|
|
193
|
+
type: "ticket.update" as const,
|
|
194
|
+
id: ticket.id,
|
|
195
|
+
title: stringOption(values.title),
|
|
196
|
+
estimateMin: minutesOption(values.estimate, "estimate"),
|
|
197
|
+
status: statusOption(values.status),
|
|
198
|
+
stage: stringOption(values.stage),
|
|
199
|
+
note: stringOption(values.note),
|
|
200
|
+
waitsFor,
|
|
201
|
+
};
|
|
202
|
+
const changes = Object.entries(event).filter(([key, value]) => key !== "type" && key !== "id" && value !== undefined);
|
|
203
|
+
if (!changes.length) throw new UsageError("Nothing to change. Pass --status, --stage, --title, --estimate, --note, or --waits-for.");
|
|
204
|
+
return { event, message: `Updated ${ticket.id}: ${changes.map(([key, value]) => `${key} ${value}`).join(", ")}` };
|
|
205
|
+
},
|
|
206
|
+
},
|
|
207
|
+
"ticket remove": {
|
|
208
|
+
usage: `ticket remove <id>`,
|
|
209
|
+
summary: "Remove a ticket from the dashboard.",
|
|
210
|
+
run({ positionals, state }) {
|
|
211
|
+
const ticket = existingTicket(state, positionals[0]);
|
|
212
|
+
return { event: { type: "ticket.remove", id: ticket.id }, message: `Removed ${ticket.id}: ${ticket.title}` };
|
|
213
|
+
},
|
|
214
|
+
},
|
|
215
|
+
"question ask": {
|
|
216
|
+
usage: `question ask "<short title>" "<question>" [--option "A=<label> | <consequence>" ...] --default <letter or word> [--waits] [--background <text>] [--file <path or http(s) URL>[=<label>] ...] [--raised-by <text>] [--ticket <id>]`,
|
|
217
|
+
summary: "Ask the user a question. Prints its reference (Q1, Q2, ...). Keep working on the default until it is answered, unless --waits: then the default is only a suggestion and you wait.",
|
|
218
|
+
options: {
|
|
219
|
+
option: { type: "string", multiple: true },
|
|
220
|
+
default: { type: "string" },
|
|
221
|
+
waits: { type: "boolean" },
|
|
222
|
+
background: { type: "string" },
|
|
223
|
+
file: { type: "string", multiple: true },
|
|
224
|
+
"raised-by": { type: "string" },
|
|
225
|
+
ticket: { type: "string" },
|
|
226
|
+
},
|
|
227
|
+
run({ positionals, values, state, cwd, root }) {
|
|
228
|
+
const title = required(positionals[0], "short title");
|
|
229
|
+
const ticket = values.ticket === undefined ? undefined : existingTicket(state, stringOption(values.ticket));
|
|
230
|
+
const question = required(positionals[1], "question");
|
|
231
|
+
const options = listOption(values.option).map(parseOption);
|
|
232
|
+
const letters = options.map((option) => option.letter);
|
|
233
|
+
const duplicate = letters.find((letter, index) => letters.indexOf(letter) !== index);
|
|
234
|
+
if (duplicate) throw new UsageError(`Option ${duplicate} is given twice.`);
|
|
235
|
+
let fallback = required(stringOption(values.default), "--default");
|
|
236
|
+
if (options.length) {
|
|
237
|
+
if (!/^[A-Za-z]$/.test(fallback) || !letters.includes(fallback.toUpperCase())) {
|
|
238
|
+
throw new UsageError(`--default must be one of the option letters: ${letters.join(", ")}.`);
|
|
239
|
+
}
|
|
240
|
+
fallback = fallback.toUpperCase();
|
|
241
|
+
}
|
|
242
|
+
const id = state.nextIds.question;
|
|
243
|
+
return {
|
|
244
|
+
event: {
|
|
245
|
+
type: "question.ask",
|
|
246
|
+
id,
|
|
247
|
+
title,
|
|
248
|
+
question,
|
|
249
|
+
options: options.length ? options : undefined,
|
|
250
|
+
default: fallback,
|
|
251
|
+
waits: values.waits === true ? true : undefined,
|
|
252
|
+
background: stringOption(values.background),
|
|
253
|
+
files: listOption(values.file).length ? listOption(values.file).map((raw) => parseFile(raw, cwd, root)) : undefined,
|
|
254
|
+
raisedBy: stringOption(values["raised-by"]),
|
|
255
|
+
ticket: ticket?.id,
|
|
256
|
+
},
|
|
257
|
+
message: `Asked ${id} (${title}). Default: ${fallback}${values.waits === true ? " (waiting for the answer)" : ""}`,
|
|
258
|
+
};
|
|
259
|
+
},
|
|
260
|
+
},
|
|
261
|
+
"question answer": {
|
|
262
|
+
usage: `question answer <id> <choice> [--words "<what the user said>"]`,
|
|
263
|
+
summary: "Record the user's answer. Choice is an option letter or the user's own short answer.",
|
|
264
|
+
options: { words: { type: "string" } },
|
|
265
|
+
run({ positionals, values, state }) {
|
|
266
|
+
const question = existingQuestion(state, positionals[0]);
|
|
267
|
+
let choice = required(positionals[1], "choice");
|
|
268
|
+
const letters = question.options.map((option) => option.letter);
|
|
269
|
+
if (/^[A-Za-z]$/.test(choice)) {
|
|
270
|
+
choice = choice.toUpperCase();
|
|
271
|
+
if (letters.length && !letters.includes(choice)) throw new UsageError(`${question.id} has options ${letters.join(", ")}, not ${choice}.`);
|
|
272
|
+
}
|
|
273
|
+
const changedCourse = choice.toLowerCase() !== question.default.toLowerCase();
|
|
274
|
+
return {
|
|
275
|
+
event: { type: "question.answer", id: question.id, choice, words: stringOption(values.words), changedCourse },
|
|
276
|
+
message: `Answered ${question.id}: ${choice}${changedCourse ? ` (differs from default ${question.default}: change course)` : " (same as the default)"}`,
|
|
277
|
+
};
|
|
278
|
+
},
|
|
279
|
+
},
|
|
280
|
+
"question update": {
|
|
281
|
+
usage: `question update <id> [--background <text>] [--question <text>] [--file <path or http(s) URL>[=<label>] ...]`,
|
|
282
|
+
summary: "Add detail to a question: replace its background or wording, or add files. Its reference and default stay.",
|
|
283
|
+
options: { background: { type: "string" }, question: { type: "string" }, file: { type: "string", multiple: true } },
|
|
284
|
+
run({ positionals, values, state, cwd, root }) {
|
|
285
|
+
const question = existingQuestion(state, positionals[0]);
|
|
286
|
+
const files = listOption(values.file).map((raw) => parseFile(raw, cwd, root));
|
|
287
|
+
const event = {
|
|
288
|
+
type: "question.update" as const,
|
|
289
|
+
id: question.id,
|
|
290
|
+
question: stringOption(values.question),
|
|
291
|
+
background: stringOption(values.background),
|
|
292
|
+
files: files.length ? files : undefined,
|
|
293
|
+
};
|
|
294
|
+
if (!event.question && !event.background && !event.files) throw new UsageError("Nothing to change. Pass --background, --question, or --file.");
|
|
295
|
+
return { event, message: `Updated ${question.id} (${question.title})` };
|
|
296
|
+
},
|
|
297
|
+
},
|
|
298
|
+
"question remove": {
|
|
299
|
+
usage: `question remove <id>`,
|
|
300
|
+
summary: "Withdraw a question. Its reference is not reused.",
|
|
301
|
+
run({ positionals, state }) {
|
|
302
|
+
const question = existingQuestion(state, positionals[0]);
|
|
303
|
+
return { event: { type: "question.remove", id: question.id }, message: `Removed ${question.id} (${question.title})` };
|
|
304
|
+
},
|
|
305
|
+
},
|
|
306
|
+
"deliverable add": {
|
|
307
|
+
usage: `deliverable add "<title>" <path or http(s) URL> [--ticket <id>] [--kind ${DELIVERABLE_KINDS.join("|")}]`,
|
|
308
|
+
summary: "Add something the user can review. Paths are relative to where you run the command. Prints its id (D1, D2, ...).",
|
|
309
|
+
options: { ticket: { type: "string" }, kind: { type: "string" } },
|
|
310
|
+
run({ positionals, values, state, cwd, root }) {
|
|
311
|
+
const title = required(positionals[0], "deliverable title");
|
|
312
|
+
const target = required(positionals[1], "path or URL");
|
|
313
|
+
const ticket = values.ticket === undefined ? undefined : existingTicket(state, stringOption(values.ticket));
|
|
314
|
+
const kind = stringOption(values.kind);
|
|
315
|
+
if (kind !== undefined && !(DELIVERABLE_KINDS as readonly string[]).includes(kind)) {
|
|
316
|
+
throw new UsageError(`--kind must be one of: ${DELIVERABLE_KINDS.join(", ")}.`);
|
|
317
|
+
}
|
|
318
|
+
const isUrl = URL_PATTERN.test(target);
|
|
319
|
+
if (isUrl && !/^https?:\/\//i.test(target)) throw new UsageError("Only http(s) URLs are supported; give local files as a path.");
|
|
320
|
+
const id = state.nextIds.deliverable;
|
|
321
|
+
return {
|
|
322
|
+
event: {
|
|
323
|
+
type: "deliverable.add",
|
|
324
|
+
id,
|
|
325
|
+
title,
|
|
326
|
+
...(isUrl ? { url: target } : { path: rootRelative(target, cwd, root) }),
|
|
327
|
+
ticket: ticket?.id,
|
|
328
|
+
kind: (kind as (typeof DELIVERABLE_KINDS)[number] | undefined) ?? guessKind(target, isUrl, cwd),
|
|
329
|
+
},
|
|
330
|
+
message: `Added ${id}: ${title}`,
|
|
331
|
+
};
|
|
332
|
+
},
|
|
333
|
+
},
|
|
334
|
+
"deliverable remove": {
|
|
335
|
+
usage: `deliverable remove <id>`,
|
|
336
|
+
summary: "Remove a deliverable from the dashboard.",
|
|
337
|
+
run({ positionals, state }) {
|
|
338
|
+
const wanted = required(positionals[0], "deliverable id").toUpperCase();
|
|
339
|
+
const deliverable = state.dashboard.deliverables.find((candidate) => candidate.id === wanted);
|
|
340
|
+
if (!deliverable) {
|
|
341
|
+
const known = state.dashboard.deliverables.map((candidate) => candidate.id).join(", ") || "none";
|
|
342
|
+
throw new UsageError(`No deliverable ${wanted} in this run. Deliverables: ${known}.`);
|
|
343
|
+
}
|
|
344
|
+
return { event: { type: "deliverable.remove", id: deliverable.id }, message: `Removed ${deliverable.id}: ${deliverable.title}` };
|
|
345
|
+
},
|
|
346
|
+
},
|
|
347
|
+
"activity add": {
|
|
348
|
+
usage: `activity add "<text>" [--ticket <id>]`,
|
|
349
|
+
summary: "Log a short note in the Activity feed, on the ticket it is about. Prints its id (A1, A2, ...).",
|
|
350
|
+
options: { ticket: { type: "string" } },
|
|
351
|
+
run({ positionals, values, state }) {
|
|
352
|
+
const text = required(positionals[0], "activity text");
|
|
353
|
+
const ticket = values.ticket === undefined ? undefined : existingTicket(state, stringOption(values.ticket));
|
|
354
|
+
const id = state.nextIds.activity;
|
|
355
|
+
return { event: { type: "activity.add", id, text, ticket: ticket?.id }, message: `Logged ${id}${ticket ? ` on ${ticket.id}` : ""}: ${text}` };
|
|
356
|
+
},
|
|
357
|
+
},
|
|
358
|
+
"activity update": {
|
|
359
|
+
usage: `activity update <id> "<text>"`,
|
|
360
|
+
summary: "Reword an activity note.",
|
|
361
|
+
run({ positionals, state }) {
|
|
362
|
+
const entry = existingActivity(state, positionals[0]);
|
|
363
|
+
const text = required(positionals[1], "activity text");
|
|
364
|
+
return { event: { type: "activity.update", id: entry.id, text }, message: `Updated ${entry.id}: ${text}` };
|
|
365
|
+
},
|
|
366
|
+
},
|
|
367
|
+
"activity remove": {
|
|
368
|
+
usage: `activity remove <id>`,
|
|
369
|
+
summary: "Remove an activity note.",
|
|
370
|
+
run({ positionals, state }) {
|
|
371
|
+
const entry = existingActivity(state, positionals[0]);
|
|
372
|
+
return { event: { type: "activity.remove", id: entry.id }, message: `Removed ${entry.id}: ${entry.text}` };
|
|
373
|
+
},
|
|
374
|
+
},
|
|
375
|
+
"ticker set": {
|
|
376
|
+
usage: `ticker set "<text>"`,
|
|
377
|
+
summary: "Show one line at the top saying what you are doing right now.",
|
|
378
|
+
run({ positionals }) {
|
|
379
|
+
const text = required(positionals[0], "ticker text");
|
|
380
|
+
return { event: { type: "ticker.set", text }, message: `Ticker: ${text}` };
|
|
381
|
+
},
|
|
382
|
+
},
|
|
383
|
+
"ticker clear": {
|
|
384
|
+
usage: `ticker clear`,
|
|
385
|
+
summary: "Hide the ticker line.",
|
|
386
|
+
run() {
|
|
387
|
+
return { event: { type: "ticker.clear" }, message: "Ticker cleared" };
|
|
388
|
+
},
|
|
389
|
+
},
|
|
390
|
+
"stuck set": {
|
|
391
|
+
usage: `stuck set "<reason>" [--ticket <id>]`,
|
|
392
|
+
summary: "Flag a blocker that is not a ticket status. Prints its id (S1, S2, ...).",
|
|
393
|
+
options: { ticket: { type: "string" } },
|
|
394
|
+
run({ positionals, values, state }) {
|
|
395
|
+
const reason = required(positionals[0], "reason");
|
|
396
|
+
const ticket = values.ticket === undefined ? undefined : existingTicket(state, stringOption(values.ticket));
|
|
397
|
+
const id = state.nextIds.stuck;
|
|
398
|
+
return { event: { type: "stuck.set", id, reason, ticket: ticket?.id }, message: `Flagged ${id}: ${reason}` };
|
|
399
|
+
},
|
|
400
|
+
},
|
|
401
|
+
"stuck clear": {
|
|
402
|
+
usage: `stuck clear <id>`,
|
|
403
|
+
summary: "Clear a flagged blocker.",
|
|
404
|
+
run({ positionals, state }) {
|
|
405
|
+
const wanted = required(positionals[0], "stuck id").toUpperCase();
|
|
406
|
+
const item = state.dashboard.stuck.find((candidate) => candidate.kind === "manual" && candidate.id === wanted);
|
|
407
|
+
if (!item || item.kind !== "manual") {
|
|
408
|
+
const known = state.dashboard.stuck.flatMap((candidate) => (candidate.kind === "manual" ? [candidate.id] : [])).join(", ") || "none";
|
|
409
|
+
throw new UsageError(`No flagged blocker ${wanted}. Flagged: ${known}.`);
|
|
410
|
+
}
|
|
411
|
+
return { event: { type: "stuck.clear", id: item.id }, message: `Cleared ${item.id}: ${item.reason}` };
|
|
412
|
+
},
|
|
413
|
+
},
|
|
414
|
+
};
|
|
415
|
+
|
|
416
|
+
// Stored relative to the worktree root so the panel can resolve it against
|
|
417
|
+
// the workspace directory; paths outside the worktree stay absolute.
|
|
418
|
+
function rootRelative(target: string, cwd: string, root: string): string {
|
|
419
|
+
const absolute = resolve(cwd, target);
|
|
420
|
+
const inside = relative(root, absolute);
|
|
421
|
+
const suffix = /[\/]$/.test(target) ? "/" : "";
|
|
422
|
+
if (!inside) return "./";
|
|
423
|
+
return outside(inside) ? absolute + suffix : inside + suffix;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function guessKind(target: string, isUrl: boolean, cwd: string): (typeof DELIVERABLE_KINDS)[number] {
|
|
427
|
+
if (isUrl) return "link";
|
|
428
|
+
if (/[\/]$/.test(target) || statSync(resolve(cwd, target), { throwIfNoEntry: false })?.isDirectory()) return "folder";
|
|
429
|
+
if (imageMimeType(target)) return "screenshot";
|
|
430
|
+
return "file";
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const SHOW_SUMMARY = "Print the dashboard as text: what the user sees in the Progress panel.";
|
|
434
|
+
const INSTALL_SUMMARY = "Write the paseo-progress launcher (default ~/.local/bin/paseo-progress). It runs this command from whichever copy of the plugin Paseo is running.";
|
|
435
|
+
const DEFAULT_LAUNCHER = "~/.local/bin/paseo-progress";
|
|
436
|
+
|
|
437
|
+
function help(): string {
|
|
438
|
+
return [
|
|
439
|
+
`Usage: ${USAGE_NAME} <command>`,
|
|
440
|
+
"",
|
|
441
|
+
`Records progress in ${PROGRESS_FILE} at the worktree root, shown by the Progress panel in Paseo.`,
|
|
442
|
+
`Add --help after any command for its options.`,
|
|
443
|
+
"",
|
|
444
|
+
...Object.values(commands).flatMap((command) => [` ${command.usage}`, ` ${command.summary}`]),
|
|
445
|
+
` show`,
|
|
446
|
+
` ${SHOW_SUMMARY}`,
|
|
447
|
+
` install-launcher [path]`,
|
|
448
|
+
` ${INSTALL_SUMMARY}`,
|
|
449
|
+
].join("\n");
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// The worktree root is the nearest directory with a `.git` entry (a folder in
|
|
453
|
+
// a checkout, a file in a linked worktree). Outside git, use the directory.
|
|
454
|
+
export function findRoot(cwd: string): string {
|
|
455
|
+
let directory = resolve(cwd);
|
|
456
|
+
while (true) {
|
|
457
|
+
if (existsSync(join(directory, ".git"))) return directory;
|
|
458
|
+
const parent = dirname(directory);
|
|
459
|
+
if (parent === directory) return resolve(cwd);
|
|
460
|
+
directory = parent;
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
export async function runCli(argv: string[], options: CliOptions): Promise<number> {
|
|
465
|
+
const out = options.out ?? ((line: string) => console.log(line));
|
|
466
|
+
const now = options.now ?? (() => new Date());
|
|
467
|
+
const [first, second] = argv;
|
|
468
|
+
if (!first || first === "--help" || first === "-h" || first === "help") {
|
|
469
|
+
out(help());
|
|
470
|
+
return first ? 0 : 1;
|
|
471
|
+
}
|
|
472
|
+
if (first === "show" || first === "install-launcher") {
|
|
473
|
+
if (argv.slice(1).includes("--help") || argv.slice(1).includes("-h")) {
|
|
474
|
+
out(first === "show" ? `Usage: ${USAGE_NAME} show\n\n${SHOW_SUMMARY}` : `Usage: ${USAGE_NAME} install-launcher [path]\n\n${INSTALL_SUMMARY}`);
|
|
475
|
+
return 0;
|
|
476
|
+
}
|
|
477
|
+
if (first === "show") {
|
|
478
|
+
const root = findRoot(options.cwd);
|
|
479
|
+
out(`Worktree: ${root}\n\n${dashboardText((await readProgress(root, now())).dashboard, now().getTime())}`);
|
|
480
|
+
return 0;
|
|
481
|
+
}
|
|
482
|
+
const target = resolve(options.cwd, (argv[1] ?? DEFAULT_LAUNCHER).replace(/^~(?=$|\/)/, homedir()));
|
|
483
|
+
out(await installLauncher(target) ? `Installed the launcher at ${target}` : `The launcher at ${target} is already current`);
|
|
484
|
+
return 0;
|
|
485
|
+
}
|
|
486
|
+
const twoWord = commands[`${first} ${second}`];
|
|
487
|
+
const command = twoWord ?? commands[first];
|
|
488
|
+
if (!command) {
|
|
489
|
+
out(`Unknown command: ${[first, second].filter(Boolean).join(" ")}\n\n${help()}`);
|
|
490
|
+
return 1;
|
|
491
|
+
}
|
|
492
|
+
const rest = argv.slice(twoWord ? 2 : 1);
|
|
493
|
+
if (rest.includes("--help") || rest.includes("-h")) {
|
|
494
|
+
out(`Usage: ${USAGE_NAME} ${command.usage}\n\n${command.summary}`);
|
|
495
|
+
return 0;
|
|
496
|
+
}
|
|
497
|
+
try {
|
|
498
|
+
let parsed;
|
|
499
|
+
try {
|
|
500
|
+
parsed = parseArgs({ args: rest, options: command.options ?? {}, allowPositionals: true, strict: true });
|
|
501
|
+
} catch (error) {
|
|
502
|
+
throw new UsageError((error as Error).message);
|
|
503
|
+
}
|
|
504
|
+
const root = findRoot(options.cwd);
|
|
505
|
+
const message = await appendProgress(root, now, (state) => {
|
|
506
|
+
const finished = state.dashboard.run?.finished;
|
|
507
|
+
if (finished && command !== commands.start) {
|
|
508
|
+
throw new UsageError(`The run "${state.dashboard.run!.title}" is finished. Start new work with: ${USAGE_NAME} start "<title>"`);
|
|
509
|
+
}
|
|
510
|
+
const { event, message } = command.run({ positionals: parsed.positionals, values: parsed.values, state, cwd: options.cwd, root });
|
|
511
|
+
return { event, result: message };
|
|
512
|
+
});
|
|
513
|
+
out(message);
|
|
514
|
+
return 0;
|
|
515
|
+
} catch (error) {
|
|
516
|
+
if (error instanceof UsageError || error instanceof InvalidEvent) {
|
|
517
|
+
out(`${error.message}\nUsage: ${USAGE_NAME} ${command.usage}`);
|
|
518
|
+
return 1;
|
|
519
|
+
}
|
|
520
|
+
throw error;
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function isMain(): boolean {
|
|
525
|
+
if (!process.argv[1]) return false;
|
|
526
|
+
try {
|
|
527
|
+
return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
|
|
528
|
+
} catch {
|
|
529
|
+
return false;
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
if (isMain()) {
|
|
534
|
+
runCli(process.argv.slice(2), { cwd: process.cwd() }).then(
|
|
535
|
+
(code) => { process.exitCode = code; },
|
|
536
|
+
(error) => {
|
|
537
|
+
console.error(error instanceof Error ? error.message : error);
|
|
538
|
+
process.exitCode = 1;
|
|
539
|
+
},
|
|
540
|
+
);
|
|
541
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { PluginHandlerContext } from "@getpaseo/plugin/server";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import type { Dashboard } from "../shared/dashboard.ts";
|
|
4
|
+
import type { AttentionResult, DashboardResult } from "../shared/rpc.ts";
|
|
5
|
+
import { markPanelOpened, readProgress } from "./progress-file.ts";
|
|
6
|
+
|
|
7
|
+
const versions = new WeakMap<Dashboard, { panelOpened: boolean; version: string }>();
|
|
8
|
+
|
|
9
|
+
export async function handleMarkPanelOpened(
|
|
10
|
+
input: { workspaceId: string; workspaceDirectory: string },
|
|
11
|
+
_context: PluginHandlerContext,
|
|
12
|
+
) {
|
|
13
|
+
await markPanelOpened(input.workspaceDirectory);
|
|
14
|
+
return {};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// The panel polls with the version it already has; when nothing it shows has
|
|
18
|
+
// changed, the reply is just "unchanged" instead of the whole dashboard again.
|
|
19
|
+
export async function handleGetDashboard(
|
|
20
|
+
input: { workspaceId: string; workspaceDirectory: string; since?: string },
|
|
21
|
+
_context: PluginHandlerContext,
|
|
22
|
+
): Promise<DashboardResult | { unchanged: true; version: string }> {
|
|
23
|
+
const result = await readProgress(input.workspaceDirectory);
|
|
24
|
+
let cached = versions.get(result.dashboard);
|
|
25
|
+
if (!cached || cached.panelOpened !== result.panelOpened) {
|
|
26
|
+
cached = { panelOpened: result.panelOpened, version: createHash("sha1").update(JSON.stringify(result)).digest("base64url") };
|
|
27
|
+
versions.set(result.dashboard, cached);
|
|
28
|
+
}
|
|
29
|
+
const { version } = cached;
|
|
30
|
+
return input.since === version ? { unchanged: true, version } : { ...result, version };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Just what a message-box pill needs, so polling every workspace stays small.
|
|
34
|
+
export async function handleGetAttention(
|
|
35
|
+
input: { workspaceId: string; workspaceDirectory: string },
|
|
36
|
+
_context: PluginHandlerContext,
|
|
37
|
+
): Promise<AttentionResult> {
|
|
38
|
+
const { configured, dashboard, panelOpened } = await readProgress(input.workspaceDirectory);
|
|
39
|
+
return {
|
|
40
|
+
configured,
|
|
41
|
+
questions: dashboard.questions.open.length,
|
|
42
|
+
stuck: dashboard.stuck.length,
|
|
43
|
+
runOpen: Boolean(dashboard.run && !dashboard.run.finished),
|
|
44
|
+
panelOpened,
|
|
45
|
+
};
|
|
46
|
+
}
|