@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,528 @@
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 } from "../shared/dashboard.js";
8
+ import { DELIVERABLE_KINDS, PROGRESS_FILE, TICKET_STATUSES } from "../shared/events.js";
9
+ import { appendProgress, InvalidEvent, readProgress } from "./progress-file.js";
10
+ import { outside } from "./paths.js";
11
+ import { installLauncher } from "./launcher.js";
12
+ import { dashboardText } from "../shared/show.js";
13
+ import { imageMimeType } from "../shared/attachments.js";
14
+ import { homedir } from "node:os";
15
+ class UsageError extends Error {
16
+ }
17
+ const USAGE_NAME = "paseo-progress";
18
+ function required(value, name) {
19
+ if (!value?.trim())
20
+ throw new UsageError(`Missing ${name}.`);
21
+ return value.trim();
22
+ }
23
+ function minutesOption(value, name) {
24
+ if (value === undefined)
25
+ return undefined;
26
+ const number = Number(value);
27
+ if (!Number.isInteger(number) || number <= 0)
28
+ throw new UsageError(`--${name} must be a whole number of minutes, like 90.`);
29
+ return number;
30
+ }
31
+ function statusOption(value) {
32
+ if (value === undefined)
33
+ return undefined;
34
+ const status = String(value).replace(/-/g, "_");
35
+ if (!TICKET_STATUSES.includes(status)) {
36
+ throw new UsageError(`--status must be one of: ${TICKET_STATUSES.join(", ")}.`);
37
+ }
38
+ return status;
39
+ }
40
+ function stringOption(value) {
41
+ return typeof value === "string" ? value : undefined;
42
+ }
43
+ function listOption(value) {
44
+ return Array.isArray(value) ? value.filter((entry) => typeof entry === "string") : [];
45
+ }
46
+ // "A=Even out the spacing | Cards look balanced" → letter, label, consequence.
47
+ function parseOption(raw) {
48
+ const match = /^\s*([A-Za-z])\s*=\s*(.+)$/.exec(raw);
49
+ if (!match)
50
+ throw new UsageError(`--option must look like "A=<label> | <consequence>", got "${raw}".`);
51
+ const [label, ...rest] = match[2].split("|").map((part) => part.trim());
52
+ if (!label)
53
+ throw new UsageError(`--option ${match[1].toUpperCase()} needs a label.`);
54
+ return { letter: match[1].toUpperCase(), label, consequence: rest.join(" | ") || undefined };
55
+ }
56
+ const URL_PATTERN = /^[a-z][a-z\d+.-]*:\/\//i;
57
+ // "path/to/file.png=Before and after" → root-relative path and optional label.
58
+ // "https://example.com/?q=1=Preview" → URL and label: an "=" right after a
59
+ // query key belongs to the URL.
60
+ function parseFile(raw, cwd, root) {
61
+ const isUrl = URL_PATTERN.test(raw);
62
+ let index = isUrl ? raw.lastIndexOf("=") : raw.indexOf("=");
63
+ while (isUrl && index !== -1 && /[?&][^=&#]*$/.test(raw.slice(0, index)))
64
+ index = raw.lastIndexOf("=", index - 1);
65
+ const target = (index === -1 ? raw : raw.slice(0, index)).trim();
66
+ const label = index === -1 ? undefined : raw.slice(index + 1).trim() || undefined;
67
+ if (!target)
68
+ throw new UsageError(`--file needs a path or URL, got "${raw}".`);
69
+ if (!isUrl)
70
+ return { path: rootRelative(target, cwd, root), label };
71
+ if (!/^https?:\/\//i.test(target))
72
+ throw new UsageError("Only http(s) URLs are supported; give local files as a path.");
73
+ return { url: target, label };
74
+ }
75
+ function existingActivity(state, id) {
76
+ const wanted = required(id, "activity id").toUpperCase();
77
+ const entry = state.dashboard.activity.find((candidate) => candidate.id === wanted);
78
+ if (!entry) {
79
+ const known = state.dashboard.activity.map((candidate) => candidate.id).join(", ") || "none";
80
+ throw new UsageError(`No activity ${wanted} in this run. Activity: ${known}.`);
81
+ }
82
+ return entry;
83
+ }
84
+ function existingQuestion(state, id) {
85
+ const wanted = required(id, "question id").toUpperCase();
86
+ const { open, answered } = state.dashboard.questions;
87
+ const question = [...open, ...answered].find((candidate) => candidate.id === wanted);
88
+ if (!question) {
89
+ const known = [...open, ...answered].map((candidate) => candidate.id).join(", ") || "none";
90
+ throw new UsageError(`No question ${wanted} in this run. Questions: ${known}.`);
91
+ }
92
+ return question;
93
+ }
94
+ function existingTicket(state, id) {
95
+ const wanted = required(id, "ticket id").toUpperCase();
96
+ const ticket = state.dashboard.tickets.find((candidate) => candidate.id.toUpperCase() === wanted);
97
+ if (!ticket) {
98
+ const known = state.dashboard.tickets.map((candidate) => candidate.id).join(", ") || "none";
99
+ throw new UsageError(`No ticket ${wanted} in this run. Tickets: ${known}.`);
100
+ }
101
+ return ticket;
102
+ }
103
+ // What still has to be settled before a run can finish.
104
+ function unfinishedWork(dashboard) {
105
+ const label = (dashboard.run?.itemLabel ?? "Ticket").toLowerCase();
106
+ const tickets = dashboard.tickets.filter((ticket) => !isSettled(ticket));
107
+ const flagged = dashboard.stuck.filter((item) => item.kind === "manual");
108
+ return [
109
+ dashboard.questions.open.length
110
+ ? `open questions ${dashboard.questions.open.map((question) => question.id).join(", ")} (record the answer with "question answer", or withdraw with "question remove")`
111
+ : "",
112
+ tickets.length ? `${label}s not done or skipped: ${tickets.map((ticket) => ticket.id).join(", ")}` : "",
113
+ flagged.length ? `flagged blockers ${flagged.map((item) => item.id).join(", ")} (clear with "stuck clear")` : "",
114
+ ].filter(Boolean);
115
+ }
116
+ const commands = {
117
+ start: {
118
+ usage: `start "<title>" [--subtitle <text>] [--item-label <word>]`,
119
+ summary: "Start a new run. Earlier runs stay in the file but leave the dashboard.",
120
+ options: { subtitle: { type: "string" }, "item-label": { type: "string" } },
121
+ run({ positionals, values }) {
122
+ const title = required(positionals[0], "run title");
123
+ return {
124
+ event: { type: "run.start", title, subtitle: stringOption(values.subtitle), itemLabel: stringOption(values["item-label"]) },
125
+ message: `Started run: ${title}`,
126
+ };
127
+ },
128
+ },
129
+ finish: {
130
+ usage: `finish "<outcome>"`,
131
+ 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.",
132
+ run({ positionals, state }) {
133
+ const outcome = required(positionals[0], "outcome");
134
+ if (!state.dashboard.run)
135
+ throw new UsageError("No run to finish.");
136
+ const left = unfinishedWork(state.dashboard);
137
+ if (left.length)
138
+ throw new UsageError(`Can't finish yet. Still open: ${left.join("; ")}.`);
139
+ return { event: { type: "run.finish", outcome }, message: `Finished run: ${state.dashboard.run.title}` };
140
+ },
141
+ },
142
+ "ticket add": {
143
+ usage: `ticket add "<title>" --estimate <minutes> [--status <status>] [--waits-for <id>]`,
144
+ 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.",
145
+ options: { estimate: { type: "string" }, status: { type: "string" }, "waits-for": { type: "string" } },
146
+ run({ positionals, values, state }) {
147
+ const waitsFor = values["waits-for"] === undefined ? undefined : existingTicket(state, stringOption(values["waits-for"])).id;
148
+ const title = required(positionals[0], "ticket title");
149
+ const estimateMin = minutesOption(values.estimate, "estimate");
150
+ if (estimateMin === undefined)
151
+ throw new UsageError("Missing --estimate <minutes>.");
152
+ const id = state.nextIds.ticket;
153
+ return {
154
+ event: { type: "ticket.add", id, title, estimateMin, status: statusOption(values.status), waitsFor },
155
+ message: `Added ${id}: ${title} (${estimateMin} min)${waitsFor ? `, waits for ${waitsFor}` : ""}`,
156
+ };
157
+ },
158
+ },
159
+ "ticket update": {
160
+ usage: `ticket update <id> [--status <status>] [--stage <name>] [--title <text>] [--estimate <minutes>] [--note <text>] [--waits-for <id>]`,
161
+ 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.`,
162
+ options: { status: { type: "string" }, stage: { type: "string" }, title: { type: "string" }, estimate: { type: "string" }, note: { type: "string" }, "waits-for": { type: "string" } },
163
+ run({ positionals, values, state }) {
164
+ const ticket = existingTicket(state, positionals[0]);
165
+ const rawWaitsFor = values["waits-for"] === undefined ? undefined : String(values["waits-for"]).trim();
166
+ const waitsFor = rawWaitsFor === undefined || rawWaitsFor === "" ? rawWaitsFor : existingTicket(state, rawWaitsFor).id;
167
+ if (waitsFor && waitsFor === ticket.id)
168
+ throw new UsageError(`${ticket.id} can't wait for itself.`);
169
+ // Refuse a loop (T01 waits for T02 waits for T01): neither could ever start.
170
+ for (let next = waitsFor ? state.dashboard.tickets.find((entry) => entry.id === waitsFor) : undefined, seen = 0; next?.waitsFor && seen < 1000; seen++) {
171
+ if (next.waitsFor === ticket.id)
172
+ throw new UsageError(`${ticket.id} can't wait for ${waitsFor}: ${next.id} already waits for ${ticket.id}, so neither could start.`);
173
+ next = state.dashboard.tickets.find((entry) => entry.id === next.waitsFor);
174
+ }
175
+ const event = {
176
+ type: "ticket.update",
177
+ id: ticket.id,
178
+ title: stringOption(values.title),
179
+ estimateMin: minutesOption(values.estimate, "estimate"),
180
+ status: statusOption(values.status),
181
+ stage: stringOption(values.stage),
182
+ note: stringOption(values.note),
183
+ waitsFor,
184
+ };
185
+ const changes = Object.entries(event).filter(([key, value]) => key !== "type" && key !== "id" && value !== undefined);
186
+ if (!changes.length)
187
+ throw new UsageError("Nothing to change. Pass --status, --stage, --title, --estimate, --note, or --waits-for.");
188
+ return { event, message: `Updated ${ticket.id}: ${changes.map(([key, value]) => `${key} ${value}`).join(", ")}` };
189
+ },
190
+ },
191
+ "ticket remove": {
192
+ usage: `ticket remove <id>`,
193
+ summary: "Remove a ticket from the dashboard.",
194
+ run({ positionals, state }) {
195
+ const ticket = existingTicket(state, positionals[0]);
196
+ return { event: { type: "ticket.remove", id: ticket.id }, message: `Removed ${ticket.id}: ${ticket.title}` };
197
+ },
198
+ },
199
+ "question ask": {
200
+ 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>]`,
201
+ 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.",
202
+ options: {
203
+ option: { type: "string", multiple: true },
204
+ default: { type: "string" },
205
+ waits: { type: "boolean" },
206
+ background: { type: "string" },
207
+ file: { type: "string", multiple: true },
208
+ "raised-by": { type: "string" },
209
+ ticket: { type: "string" },
210
+ },
211
+ run({ positionals, values, state, cwd, root }) {
212
+ const title = required(positionals[0], "short title");
213
+ const ticket = values.ticket === undefined ? undefined : existingTicket(state, stringOption(values.ticket));
214
+ const question = required(positionals[1], "question");
215
+ const options = listOption(values.option).map(parseOption);
216
+ const letters = options.map((option) => option.letter);
217
+ const duplicate = letters.find((letter, index) => letters.indexOf(letter) !== index);
218
+ if (duplicate)
219
+ throw new UsageError(`Option ${duplicate} is given twice.`);
220
+ let fallback = required(stringOption(values.default), "--default");
221
+ if (options.length) {
222
+ if (!/^[A-Za-z]$/.test(fallback) || !letters.includes(fallback.toUpperCase())) {
223
+ throw new UsageError(`--default must be one of the option letters: ${letters.join(", ")}.`);
224
+ }
225
+ fallback = fallback.toUpperCase();
226
+ }
227
+ const id = state.nextIds.question;
228
+ return {
229
+ event: {
230
+ type: "question.ask",
231
+ id,
232
+ title,
233
+ question,
234
+ options: options.length ? options : undefined,
235
+ default: fallback,
236
+ waits: values.waits === true ? true : undefined,
237
+ background: stringOption(values.background),
238
+ files: listOption(values.file).length ? listOption(values.file).map((raw) => parseFile(raw, cwd, root)) : undefined,
239
+ raisedBy: stringOption(values["raised-by"]),
240
+ ticket: ticket?.id,
241
+ },
242
+ message: `Asked ${id} (${title}). Default: ${fallback}${values.waits === true ? " (waiting for the answer)" : ""}`,
243
+ };
244
+ },
245
+ },
246
+ "question answer": {
247
+ usage: `question answer <id> <choice> [--words "<what the user said>"]`,
248
+ summary: "Record the user's answer. Choice is an option letter or the user's own short answer.",
249
+ options: { words: { type: "string" } },
250
+ run({ positionals, values, state }) {
251
+ const question = existingQuestion(state, positionals[0]);
252
+ let choice = required(positionals[1], "choice");
253
+ const letters = question.options.map((option) => option.letter);
254
+ if (/^[A-Za-z]$/.test(choice)) {
255
+ choice = choice.toUpperCase();
256
+ if (letters.length && !letters.includes(choice))
257
+ throw new UsageError(`${question.id} has options ${letters.join(", ")}, not ${choice}.`);
258
+ }
259
+ const changedCourse = choice.toLowerCase() !== question.default.toLowerCase();
260
+ return {
261
+ event: { type: "question.answer", id: question.id, choice, words: stringOption(values.words), changedCourse },
262
+ message: `Answered ${question.id}: ${choice}${changedCourse ? ` (differs from default ${question.default}: change course)` : " (same as the default)"}`,
263
+ };
264
+ },
265
+ },
266
+ "question update": {
267
+ usage: `question update <id> [--background <text>] [--question <text>] [--file <path or http(s) URL>[=<label>] ...]`,
268
+ summary: "Add detail to a question: replace its background or wording, or add files. Its reference and default stay.",
269
+ options: { background: { type: "string" }, question: { type: "string" }, file: { type: "string", multiple: true } },
270
+ run({ positionals, values, state, cwd, root }) {
271
+ const question = existingQuestion(state, positionals[0]);
272
+ const files = listOption(values.file).map((raw) => parseFile(raw, cwd, root));
273
+ const event = {
274
+ type: "question.update",
275
+ id: question.id,
276
+ question: stringOption(values.question),
277
+ background: stringOption(values.background),
278
+ files: files.length ? files : undefined,
279
+ };
280
+ if (!event.question && !event.background && !event.files)
281
+ throw new UsageError("Nothing to change. Pass --background, --question, or --file.");
282
+ return { event, message: `Updated ${question.id} (${question.title})` };
283
+ },
284
+ },
285
+ "question remove": {
286
+ usage: `question remove <id>`,
287
+ summary: "Withdraw a question. Its reference is not reused.",
288
+ run({ positionals, state }) {
289
+ const question = existingQuestion(state, positionals[0]);
290
+ return { event: { type: "question.remove", id: question.id }, message: `Removed ${question.id} (${question.title})` };
291
+ },
292
+ },
293
+ "deliverable add": {
294
+ usage: `deliverable add "<title>" <path or http(s) URL> [--ticket <id>] [--kind ${DELIVERABLE_KINDS.join("|")}]`,
295
+ summary: "Add something the user can review. Paths are relative to where you run the command. Prints its id (D1, D2, ...).",
296
+ options: { ticket: { type: "string" }, kind: { type: "string" } },
297
+ run({ positionals, values, state, cwd, root }) {
298
+ const title = required(positionals[0], "deliverable title");
299
+ const target = required(positionals[1], "path or URL");
300
+ const ticket = values.ticket === undefined ? undefined : existingTicket(state, stringOption(values.ticket));
301
+ const kind = stringOption(values.kind);
302
+ if (kind !== undefined && !DELIVERABLE_KINDS.includes(kind)) {
303
+ throw new UsageError(`--kind must be one of: ${DELIVERABLE_KINDS.join(", ")}.`);
304
+ }
305
+ const isUrl = URL_PATTERN.test(target);
306
+ if (isUrl && !/^https?:\/\//i.test(target))
307
+ throw new UsageError("Only http(s) URLs are supported; give local files as a path.");
308
+ const id = state.nextIds.deliverable;
309
+ return {
310
+ event: {
311
+ type: "deliverable.add",
312
+ id,
313
+ title,
314
+ ...(isUrl ? { url: target } : { path: rootRelative(target, cwd, root) }),
315
+ ticket: ticket?.id,
316
+ kind: kind ?? guessKind(target, isUrl, cwd),
317
+ },
318
+ message: `Added ${id}: ${title}`,
319
+ };
320
+ },
321
+ },
322
+ "deliverable remove": {
323
+ usage: `deliverable remove <id>`,
324
+ summary: "Remove a deliverable from the dashboard.",
325
+ run({ positionals, state }) {
326
+ const wanted = required(positionals[0], "deliverable id").toUpperCase();
327
+ const deliverable = state.dashboard.deliverables.find((candidate) => candidate.id === wanted);
328
+ if (!deliverable) {
329
+ const known = state.dashboard.deliverables.map((candidate) => candidate.id).join(", ") || "none";
330
+ throw new UsageError(`No deliverable ${wanted} in this run. Deliverables: ${known}.`);
331
+ }
332
+ return { event: { type: "deliverable.remove", id: deliverable.id }, message: `Removed ${deliverable.id}: ${deliverable.title}` };
333
+ },
334
+ },
335
+ "activity add": {
336
+ usage: `activity add "<text>" [--ticket <id>]`,
337
+ summary: "Log a short note in the Activity feed, on the ticket it is about. Prints its id (A1, A2, ...).",
338
+ options: { ticket: { type: "string" } },
339
+ run({ positionals, values, state }) {
340
+ const text = required(positionals[0], "activity text");
341
+ const ticket = values.ticket === undefined ? undefined : existingTicket(state, stringOption(values.ticket));
342
+ const id = state.nextIds.activity;
343
+ return { event: { type: "activity.add", id, text, ticket: ticket?.id }, message: `Logged ${id}${ticket ? ` on ${ticket.id}` : ""}: ${text}` };
344
+ },
345
+ },
346
+ "activity update": {
347
+ usage: `activity update <id> "<text>"`,
348
+ summary: "Reword an activity note.",
349
+ run({ positionals, state }) {
350
+ const entry = existingActivity(state, positionals[0]);
351
+ const text = required(positionals[1], "activity text");
352
+ return { event: { type: "activity.update", id: entry.id, text }, message: `Updated ${entry.id}: ${text}` };
353
+ },
354
+ },
355
+ "activity remove": {
356
+ usage: `activity remove <id>`,
357
+ summary: "Remove an activity note.",
358
+ run({ positionals, state }) {
359
+ const entry = existingActivity(state, positionals[0]);
360
+ return { event: { type: "activity.remove", id: entry.id }, message: `Removed ${entry.id}: ${entry.text}` };
361
+ },
362
+ },
363
+ "ticker set": {
364
+ usage: `ticker set "<text>"`,
365
+ summary: "Show one line at the top saying what you are doing right now.",
366
+ run({ positionals }) {
367
+ const text = required(positionals[0], "ticker text");
368
+ return { event: { type: "ticker.set", text }, message: `Ticker: ${text}` };
369
+ },
370
+ },
371
+ "ticker clear": {
372
+ usage: `ticker clear`,
373
+ summary: "Hide the ticker line.",
374
+ run() {
375
+ return { event: { type: "ticker.clear" }, message: "Ticker cleared" };
376
+ },
377
+ },
378
+ "stuck set": {
379
+ usage: `stuck set "<reason>" [--ticket <id>]`,
380
+ summary: "Flag a blocker that is not a ticket status. Prints its id (S1, S2, ...).",
381
+ options: { ticket: { type: "string" } },
382
+ run({ positionals, values, state }) {
383
+ const reason = required(positionals[0], "reason");
384
+ const ticket = values.ticket === undefined ? undefined : existingTicket(state, stringOption(values.ticket));
385
+ const id = state.nextIds.stuck;
386
+ return { event: { type: "stuck.set", id, reason, ticket: ticket?.id }, message: `Flagged ${id}: ${reason}` };
387
+ },
388
+ },
389
+ "stuck clear": {
390
+ usage: `stuck clear <id>`,
391
+ summary: "Clear a flagged blocker.",
392
+ run({ positionals, state }) {
393
+ const wanted = required(positionals[0], "stuck id").toUpperCase();
394
+ const item = state.dashboard.stuck.find((candidate) => candidate.kind === "manual" && candidate.id === wanted);
395
+ if (!item || item.kind !== "manual") {
396
+ const known = state.dashboard.stuck.flatMap((candidate) => (candidate.kind === "manual" ? [candidate.id] : [])).join(", ") || "none";
397
+ throw new UsageError(`No flagged blocker ${wanted}. Flagged: ${known}.`);
398
+ }
399
+ return { event: { type: "stuck.clear", id: item.id }, message: `Cleared ${item.id}: ${item.reason}` };
400
+ },
401
+ },
402
+ };
403
+ // Stored relative to the worktree root so the panel can resolve it against
404
+ // the workspace directory; paths outside the worktree stay absolute.
405
+ function rootRelative(target, cwd, root) {
406
+ const absolute = resolve(cwd, target);
407
+ const inside = relative(root, absolute);
408
+ const suffix = /[\/]$/.test(target) ? "/" : "";
409
+ if (!inside)
410
+ return "./";
411
+ return outside(inside) ? absolute + suffix : inside + suffix;
412
+ }
413
+ function guessKind(target, isUrl, cwd) {
414
+ if (isUrl)
415
+ return "link";
416
+ if (/[\/]$/.test(target) || statSync(resolve(cwd, target), { throwIfNoEntry: false })?.isDirectory())
417
+ return "folder";
418
+ if (imageMimeType(target))
419
+ return "screenshot";
420
+ return "file";
421
+ }
422
+ const SHOW_SUMMARY = "Print the dashboard as text: what the user sees in the Progress panel.";
423
+ 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.";
424
+ const DEFAULT_LAUNCHER = "~/.local/bin/paseo-progress";
425
+ function help() {
426
+ return [
427
+ `Usage: ${USAGE_NAME} <command>`,
428
+ "",
429
+ `Records progress in ${PROGRESS_FILE} at the worktree root, shown by the Progress panel in Paseo.`,
430
+ `Add --help after any command for its options.`,
431
+ "",
432
+ ...Object.values(commands).flatMap((command) => [` ${command.usage}`, ` ${command.summary}`]),
433
+ ` show`,
434
+ ` ${SHOW_SUMMARY}`,
435
+ ` install-launcher [path]`,
436
+ ` ${INSTALL_SUMMARY}`,
437
+ ].join("\n");
438
+ }
439
+ // The worktree root is the nearest directory with a `.git` entry (a folder in
440
+ // a checkout, a file in a linked worktree). Outside git, use the directory.
441
+ export function findRoot(cwd) {
442
+ let directory = resolve(cwd);
443
+ while (true) {
444
+ if (existsSync(join(directory, ".git")))
445
+ return directory;
446
+ const parent = dirname(directory);
447
+ if (parent === directory)
448
+ return resolve(cwd);
449
+ directory = parent;
450
+ }
451
+ }
452
+ export async function runCli(argv, options) {
453
+ const out = options.out ?? ((line) => console.log(line));
454
+ const now = options.now ?? (() => new Date());
455
+ const [first, second] = argv;
456
+ if (!first || first === "--help" || first === "-h" || first === "help") {
457
+ out(help());
458
+ return first ? 0 : 1;
459
+ }
460
+ if (first === "show" || first === "install-launcher") {
461
+ if (argv.slice(1).includes("--help") || argv.slice(1).includes("-h")) {
462
+ out(first === "show" ? `Usage: ${USAGE_NAME} show\n\n${SHOW_SUMMARY}` : `Usage: ${USAGE_NAME} install-launcher [path]\n\n${INSTALL_SUMMARY}`);
463
+ return 0;
464
+ }
465
+ if (first === "show") {
466
+ const root = findRoot(options.cwd);
467
+ out(`Worktree: ${root}\n\n${dashboardText((await readProgress(root, now())).dashboard, now().getTime())}`);
468
+ return 0;
469
+ }
470
+ const target = resolve(options.cwd, (argv[1] ?? DEFAULT_LAUNCHER).replace(/^~(?=$|\/)/, homedir()));
471
+ out(await installLauncher(target) ? `Installed the launcher at ${target}` : `The launcher at ${target} is already current`);
472
+ return 0;
473
+ }
474
+ const twoWord = commands[`${first} ${second}`];
475
+ const command = twoWord ?? commands[first];
476
+ if (!command) {
477
+ out(`Unknown command: ${[first, second].filter(Boolean).join(" ")}\n\n${help()}`);
478
+ return 1;
479
+ }
480
+ const rest = argv.slice(twoWord ? 2 : 1);
481
+ if (rest.includes("--help") || rest.includes("-h")) {
482
+ out(`Usage: ${USAGE_NAME} ${command.usage}\n\n${command.summary}`);
483
+ return 0;
484
+ }
485
+ try {
486
+ let parsed;
487
+ try {
488
+ parsed = parseArgs({ args: rest, options: command.options ?? {}, allowPositionals: true, strict: true });
489
+ }
490
+ catch (error) {
491
+ throw new UsageError(error.message);
492
+ }
493
+ const root = findRoot(options.cwd);
494
+ const message = await appendProgress(root, now, (state) => {
495
+ const finished = state.dashboard.run?.finished;
496
+ if (finished && command !== commands.start) {
497
+ throw new UsageError(`The run "${state.dashboard.run.title}" is finished. Start new work with: ${USAGE_NAME} start "<title>"`);
498
+ }
499
+ const { event, message } = command.run({ positionals: parsed.positionals, values: parsed.values, state, cwd: options.cwd, root });
500
+ return { event, result: message };
501
+ });
502
+ out(message);
503
+ return 0;
504
+ }
505
+ catch (error) {
506
+ if (error instanceof UsageError || error instanceof InvalidEvent) {
507
+ out(`${error.message}\nUsage: ${USAGE_NAME} ${command.usage}`);
508
+ return 1;
509
+ }
510
+ throw error;
511
+ }
512
+ }
513
+ function isMain() {
514
+ if (!process.argv[1])
515
+ return false;
516
+ try {
517
+ return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
518
+ }
519
+ catch {
520
+ return false;
521
+ }
522
+ }
523
+ if (isMain()) {
524
+ runCli(process.argv.slice(2), { cwd: process.cwd() }).then((code) => { process.exitCode = code; }, (error) => {
525
+ console.error(error instanceof Error ? error.message : error);
526
+ process.exitCode = 1;
527
+ });
528
+ }
@@ -0,0 +1,90 @@
1
+ import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { delimiter, dirname, join } from "node:path";
4
+ export const PLUGIN_ID = "progress";
5
+ // The launcher never names a plugin folder. Each run asks Paseo which copy of
6
+ // the plugin it is running, so updates and `npm run dev` switches keep working.
7
+ export function launcherScript() {
8
+ return `#!/usr/bin/env node
9
+ // Written by the ${PLUGIN_ID} Paseo plugin. Runs the progress command from the
10
+ // copy of the plugin Paseo is running now.
11
+ const [major, minor] = process.versions.node.split(".").map(Number);
12
+ if (major < 22 || (major === 22 && minor < 18)) {
13
+ console.error("paseo-progress needs Node.js 22.18 or later; this is " + process.version + ".");
14
+ process.exit(1);
15
+ }
16
+ // Not require: a package.json with "type": "module" above this folder makes Node load it as ESM.
17
+ const { spawnSync } = process.getBuiltinModule("node:child_process");
18
+ const { existsSync } = process.getBuiltinModule("node:fs");
19
+ const { join } = process.getBuiltinModule("node:path");
20
+
21
+ function pluginDirectory() {
22
+ if (process.env.PASEO_PROGRESS_PLUGIN_DIR) return process.env.PASEO_PROGRESS_PLUGIN_DIR;
23
+ const clis = [process.env.PASEO_BIN, "paseo", "/Applications/Paseo.app/Contents/Resources/bin/paseo"].filter(Boolean);
24
+ for (const cli of clis) {
25
+ const result = spawnSync(cli, ["plugin", "ls", "${PLUGIN_ID}", "--json"], { encoding: "utf8" });
26
+ if (result.error || result.status !== 0) continue;
27
+ try {
28
+ const plugin = JSON.parse(result.stdout).find((entry) => entry.id === "${PLUGIN_ID}");
29
+ return plugin && plugin.path;
30
+ } catch {
31
+ return undefined;
32
+ }
33
+ }
34
+ return undefined;
35
+ }
36
+
37
+ // An npm install puts the plugin under node_modules, where Node won't run
38
+ // TypeScript, so the npm package carries the command compiled to dist/.
39
+ const directory = pluginDirectory();
40
+ const entry = directory && [join(directory, "dist", "server", "cli.js"), join(directory, "server", "cli.ts")].find((path) => existsSync(path));
41
+ if (!entry) {
42
+ console.error("The ${PLUGIN_ID} plugin is not installed in Paseo, or Paseo is not reachable. Check with: paseo plugin ls");
43
+ process.exit(1);
44
+ }
45
+ const run = spawnSync(process.execPath, [entry, ...process.argv.slice(2)], { stdio: "inherit" });
46
+ process.exit(run.status === null ? 1 : run.status);
47
+ `;
48
+ }
49
+ const MARKER = `Written by the ${PLUGIN_ID} Paseo plugin`;
50
+ export function defaultLauncherPath(home = homedir()) {
51
+ return join(home, ".local", "bin", "paseo-progress");
52
+ }
53
+ export async function launcherState(path) {
54
+ const current = await readFile(path, "utf8").catch(() => null);
55
+ if (current === null)
56
+ return "missing";
57
+ if (current === launcherScript())
58
+ return "current";
59
+ return current.includes(MARKER) ? "outdated" : "foreign";
60
+ }
61
+ // Writes the launcher when it is missing or out of date. Never replaces a file
62
+ // something else put there. Returns whether it wrote.
63
+ export async function installLauncher(path) {
64
+ const state = await launcherState(path);
65
+ if (state === "current")
66
+ return false;
67
+ if (state === "foreign")
68
+ throw new Error(`${path} already exists and was not written by this plugin. Move it, or pass another path.`);
69
+ await mkdir(dirname(path), { recursive: true });
70
+ await writeFile(path, launcherScript());
71
+ await chmod(path, 0o755);
72
+ return true;
73
+ }
74
+ // For the panel: whether agents on the daemon host can run `paseo-progress`.
75
+ export async function launcherStatus(path = defaultLauncherPath(), pathEnv = process.env.PATH ?? "") {
76
+ return {
77
+ path,
78
+ state: await launcherState(path),
79
+ onPath: pathEnv.split(delimiter).some((entry) => entry.replace(/\/+$/, "") === dirname(path)),
80
+ };
81
+ }
82
+ export async function handleLauncherStatus() {
83
+ return launcherStatus();
84
+ }
85
+ // Runs only when the user presses Install in the panel.
86
+ export async function handleInstallLauncher() {
87
+ const path = defaultLauncherPath();
88
+ await installLauncher(path);
89
+ return launcherStatus(path);
90
+ }
@@ -0,0 +1,34 @@
1
+ import { mkdir, rm, stat } from "node:fs/promises";
2
+ const RETRY_MS = 25;
3
+ const WAIT_MS = 10_000;
4
+ // A lock older than this was left by a command that crashed.
5
+ const STALE_MS = 30_000;
6
+ // Serializes read-then-append so two commands never hand out the same id or
7
+ // interleave a line. mkdir is atomic, so only one process creates the folder.
8
+ export async function withLock(lockPath, work) {
9
+ const started = Date.now();
10
+ while (true) {
11
+ try {
12
+ await mkdir(lockPath);
13
+ break;
14
+ }
15
+ catch (error) {
16
+ if (error.code !== "EEXIST")
17
+ throw error;
18
+ const since = await stat(lockPath).then((info) => Date.now() - info.mtimeMs, () => 0);
19
+ if (since > STALE_MS) {
20
+ await rm(lockPath, { recursive: true, force: true });
21
+ continue;
22
+ }
23
+ if (Date.now() - started > WAIT_MS)
24
+ throw new Error(`Timed out waiting for ${lockPath}. If no other progress command is running, delete it.`);
25
+ await new Promise((resolve) => setTimeout(resolve, RETRY_MS + Math.random() * RETRY_MS));
26
+ }
27
+ }
28
+ try {
29
+ return await work();
30
+ }
31
+ finally {
32
+ await rm(lockPath, { recursive: true, force: true });
33
+ }
34
+ }
@@ -0,0 +1,6 @@
1
+ import { isAbsolute, sep } from "node:path";
2
+ // Whether a path from relative(root, target) leaves the root. "..notes" is a
3
+ // name inside it, not a step out.
4
+ export function outside(inside) {
5
+ return inside === ".." || inside.startsWith(`..${sep}`) || isAbsolute(inside);
6
+ }