@stevecastaneda/paseo-progress 0.2.1 → 0.3.1
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/README.md +18 -7
- package/client/attachments.tsx +1 -1
- package/client/dialog-stack.tsx +8 -1
- package/client/panel.tsx +5 -29
- package/client/pill-popover.tsx +5 -3
- package/client/questions.tsx +18 -31
- package/client/tabs.tsx +65 -0
- package/client/ticket-dialog.tsx +142 -112
- package/client/time-bar.tsx +19 -0
- package/client/write-up.tsx +95 -0
- package/dist/server/cli.js +59 -30
- package/dist/server/progress-file.js +11 -4
- package/dist/shared/attachments.js +12 -6
- package/dist/shared/dashboard.js +14 -9
- package/dist/shared/events.js +14 -6
- package/dist/shared/format.js +6 -0
- package/dist/shared/show.js +3 -1
- package/package.json +2 -2
- package/paseo-plugin.json +1 -2
- package/server/cli.ts +62 -32
- package/server/progress-file.ts +11 -4
- package/shared/attachments.ts +21 -11
- package/shared/dashboard.ts +23 -12
- package/shared/events.ts +14 -6
- package/shared/format.ts +7 -0
- package/shared/show.ts +2 -1
- package/shared/write-up.ts +92 -0
- package/skills/paseo-progress/SKILL.md +10 -7
- package/npm-shrinkwrap.json +0 -4041
package/server/cli.ts
CHANGED
|
@@ -4,9 +4,9 @@ import { realpathSync, statSync } from "node:fs";
|
|
|
4
4
|
import { relative, resolve } from "node:path";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
import { parseArgs } from "node:util";
|
|
7
|
-
import { isSettled, type Dashboard, type NextIds } from "../shared/dashboard.ts";
|
|
8
|
-
import { DELIVERABLE_KINDS, TICKET_STATUSES } from "../shared/events.ts";
|
|
9
|
-
import { appendProgress, InvalidEvent, readProgress, type EventDraft } from "./progress-file.ts";
|
|
7
|
+
import { isSettled, type Dashboard, type NextIds, type Ticket } from "../shared/dashboard.ts";
|
|
8
|
+
import { DELIVERABLE_KINDS, TICKET_STATUSES, type FileLink, type ProgressEvent } from "../shared/events.ts";
|
|
9
|
+
import { appendProgress, InvalidEvent, readLatestRun, readProgress, type EventDraft } from "./progress-file.ts";
|
|
10
10
|
import { findRoot, outside } from "./paths.ts";
|
|
11
11
|
import { installLauncher } from "./launcher.ts";
|
|
12
12
|
import { findStore, saveRunCopy } from "./store.ts";
|
|
@@ -18,6 +18,8 @@ export interface CliOptions {
|
|
|
18
18
|
cwd: string;
|
|
19
19
|
now?: () => Date;
|
|
20
20
|
out?: (line: string) => void;
|
|
21
|
+
// The Paseo agent running the command, recorded on each event it writes.
|
|
22
|
+
agentId?: string;
|
|
21
23
|
}
|
|
22
24
|
|
|
23
25
|
class UsageError extends Error {}
|
|
@@ -136,6 +138,30 @@ function unfinishedWork(dashboard: Dashboard): string[] {
|
|
|
136
138
|
].filter(Boolean);
|
|
137
139
|
}
|
|
138
140
|
|
|
141
|
+
// Tickets another session added since this agent last wrote to the run, still
|
|
142
|
+
// to do. Nothing until the agent has written to the run: it reads `show` first.
|
|
143
|
+
function addedByOthers(events: ProgressEvent[], dashboard: Dashboard, agentId: string | undefined): Ticket[] {
|
|
144
|
+
if (!agentId) return [];
|
|
145
|
+
const since = events.findLastIndex((event) => event.by === agentId);
|
|
146
|
+
if (since === -1) return [];
|
|
147
|
+
const added = new Set(events.slice(since + 1).flatMap((event) => (event.type === "ticket.add" && event.by !== agentId ? [event.id] : [])));
|
|
148
|
+
return dashboard.tickets.filter((ticket) => added.has(ticket.id) && !isSettled(ticket));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function addedByOthersText(tickets: Ticket[], dashboard: Dashboard): string {
|
|
152
|
+
if (!tickets.length) return "";
|
|
153
|
+
const label = (dashboard.run?.itemLabel ?? "Ticket").toLowerCase();
|
|
154
|
+
const one = tickets.length === 1;
|
|
155
|
+
// Starts with a blank line, to follow the command's own output.
|
|
156
|
+
return [
|
|
157
|
+
"",
|
|
158
|
+
"",
|
|
159
|
+
`Another session added ${one ? `a ${label}` : `${tickets.length} ${label}s`} since your last update:`,
|
|
160
|
+
...tickets.map((ticket) => ` ${ticket.id} ${ticket.title}`),
|
|
161
|
+
`${one ? "It is" : "They are"} part of this run: work ${one ? "it" : "them"} in order after your current ${label}.`,
|
|
162
|
+
].join("\n");
|
|
163
|
+
}
|
|
164
|
+
|
|
139
165
|
const commands: Record<string, Command> = {
|
|
140
166
|
start: {
|
|
141
167
|
usage: `start "<title>" [--subtitle <text>] [--item-label <word>]`,
|
|
@@ -161,26 +187,27 @@ const commands: Record<string, Command> = {
|
|
|
161
187
|
},
|
|
162
188
|
},
|
|
163
189
|
"ticket add": {
|
|
164
|
-
usage: `ticket add "<title>" --estimate <minutes> [--status <status>] [--waits-for <id>]`,
|
|
165
|
-
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.",
|
|
166
|
-
options: { estimate: { type: "string" }, status: { type: "string" }, "waits-for": { type: "string" } },
|
|
167
|
-
run({ positionals, values, state }) {
|
|
190
|
+
usage: `ticket add "<title>" --estimate <minutes> [--status <status>] [--waits-for <id>] [--source <path or http(s) URL>[=<label>]]`,
|
|
191
|
+
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. --source is where the ticket is written up, like its spec file or issue link.",
|
|
192
|
+
options: { estimate: { type: "string" }, status: { type: "string" }, "waits-for": { type: "string" }, source: { type: "string" } },
|
|
193
|
+
run({ positionals, values, state, cwd, root }) {
|
|
168
194
|
const waitsFor = values["waits-for"] === undefined ? undefined : existingTicket(state, stringOption(values["waits-for"])).id;
|
|
169
195
|
const title = required(positionals[0], "ticket title");
|
|
170
196
|
const estimateMin = minutesOption(values.estimate, "estimate");
|
|
171
197
|
if (estimateMin === undefined) throw new UsageError("Missing --estimate <minutes>.");
|
|
198
|
+
const source = values.source === undefined ? undefined : parseFile(String(values.source), cwd, root);
|
|
172
199
|
const id = state.nextIds.ticket;
|
|
173
200
|
return {
|
|
174
|
-
event: { type: "ticket.add", id, title, estimateMin, status: statusOption(values.status), waitsFor },
|
|
175
|
-
message: `Added ${id}: ${title} (${estimateMin} min)${waitsFor ? `, waits for ${waitsFor}` : ""}`,
|
|
201
|
+
event: { type: "ticket.add", id, title, estimateMin, status: statusOption(values.status), waitsFor, source },
|
|
202
|
+
message: `Added ${id}: ${title} (${estimateMin} min)${waitsFor ? `, waits for ${waitsFor}` : ""}${source ? `, source ${source.path ?? source.url}` : ""}`,
|
|
176
203
|
};
|
|
177
204
|
},
|
|
178
205
|
},
|
|
179
206
|
"ticket update": {
|
|
180
|
-
usage: `ticket update <id> [--status <status>] [--stage <name>] [--title <text>] [--estimate <minutes>] [--note <text>] [--waits-for <id>]`,
|
|
181
|
-
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.`,
|
|
182
|
-
options: { status: { type: "string" }, stage: { type: "string" }, title: { type: "string" }, estimate: { type: "string" }, note: { type: "string" }, "waits-for": { type: "string" } },
|
|
183
|
-
run({ positionals, values, state }) {
|
|
207
|
+
usage: `ticket update <id> [--status <status>] [--stage <name>] [--title <text>] [--estimate <minutes>] [--note <text>] [--waits-for <id>] [--source <path or http(s) URL>[=<label>]]`,
|
|
208
|
+
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. --source is where the ticket is written up ("" clears it).`,
|
|
209
|
+
options: { status: { type: "string" }, stage: { type: "string" }, title: { type: "string" }, estimate: { type: "string" }, note: { type: "string" }, "waits-for": { type: "string" }, source: { type: "string" } },
|
|
210
|
+
run({ positionals, values, state, cwd, root }) {
|
|
184
211
|
const ticket = existingTicket(state, positionals[0]);
|
|
185
212
|
const rawWaitsFor = values["waits-for"] === undefined ? undefined : String(values["waits-for"]).trim();
|
|
186
213
|
const waitsFor = rawWaitsFor === undefined || rawWaitsFor === "" ? rawWaitsFor : existingTicket(state, rawWaitsFor).id;
|
|
@@ -199,10 +226,12 @@ const commands: Record<string, Command> = {
|
|
|
199
226
|
stage: stringOption(values.stage),
|
|
200
227
|
note: stringOption(values.note),
|
|
201
228
|
waitsFor,
|
|
229
|
+
source: values.source === undefined ? undefined : String(values.source).trim() === "" ? "" as const : parseFile(String(values.source), cwd, root),
|
|
202
230
|
};
|
|
203
231
|
const changes = Object.entries(event).filter(([key, value]) => key !== "type" && key !== "id" && value !== undefined);
|
|
204
|
-
if (!changes.length) throw new UsageError("Nothing to change. Pass --status, --stage, --title, --estimate, --note,
|
|
205
|
-
|
|
232
|
+
if (!changes.length) throw new UsageError("Nothing to change. Pass --status, --stage, --title, --estimate, --note, --waits-for, or --source.");
|
|
233
|
+
const shown = (value: unknown) => (typeof value === "object" ? (value as FileLink).path ?? (value as FileLink).url : value);
|
|
234
|
+
return { event, message: `Updated ${ticket.id}: ${changes.map(([key, value]) => `${key} ${shown(value)}`).join(", ")}` };
|
|
206
235
|
},
|
|
207
236
|
},
|
|
208
237
|
"ticket remove": {
|
|
@@ -214,12 +243,11 @@ const commands: Record<string, Command> = {
|
|
|
214
243
|
},
|
|
215
244
|
},
|
|
216
245
|
"question ask": {
|
|
217
|
-
usage: `question ask "<short title>" "<question>" [--option "A=<label> | <consequence>" ...] --
|
|
218
|
-
summary: "Ask the user a question. Prints its reference (Q1, Q2, ...).
|
|
246
|
+
usage: `question ask "<short title>" "<question>" [--option "A=<label> | <consequence>" ...] --recommend <letter or word> [--background <text>] [--file <path or http(s) URL>[=<label>] ...] [--raised-by <text>] [--ticket <id>]`,
|
|
247
|
+
summary: "Ask the user a question. Prints its reference (Q1, Q2, ...). --recommend is the option you would pick. Build no option before the answer: work only on what the question doesn't affect, and if nothing else is left, stop and wait.",
|
|
219
248
|
options: {
|
|
220
249
|
option: { type: "string", multiple: true },
|
|
221
|
-
|
|
222
|
-
waits: { type: "boolean" },
|
|
250
|
+
recommend: { type: "string" },
|
|
223
251
|
background: { type: "string" },
|
|
224
252
|
file: { type: "string", multiple: true },
|
|
225
253
|
"raised-by": { type: "string" },
|
|
@@ -233,12 +261,12 @@ const commands: Record<string, Command> = {
|
|
|
233
261
|
const letters = options.map((option) => option.letter);
|
|
234
262
|
const duplicate = letters.find((letter, index) => letters.indexOf(letter) !== index);
|
|
235
263
|
if (duplicate) throw new UsageError(`Option ${duplicate} is given twice.`);
|
|
236
|
-
let
|
|
264
|
+
let recommend = required(stringOption(values.recommend), "--recommend (the option you would pick)");
|
|
237
265
|
if (options.length) {
|
|
238
|
-
if (!/^[A-Za-z]$/.test(
|
|
239
|
-
throw new UsageError(`--
|
|
266
|
+
if (!/^[A-Za-z]$/.test(recommend) || !letters.includes(recommend.toUpperCase())) {
|
|
267
|
+
throw new UsageError(`--recommend must be one of the option letters: ${letters.join(", ")}.`);
|
|
240
268
|
}
|
|
241
|
-
|
|
269
|
+
recommend = recommend.toUpperCase();
|
|
242
270
|
}
|
|
243
271
|
const id = state.nextIds.question;
|
|
244
272
|
return {
|
|
@@ -248,14 +276,13 @@ const commands: Record<string, Command> = {
|
|
|
248
276
|
title,
|
|
249
277
|
question,
|
|
250
278
|
options: options.length ? options : undefined,
|
|
251
|
-
|
|
252
|
-
waits: values.waits === true ? true : undefined,
|
|
279
|
+
recommend,
|
|
253
280
|
background: stringOption(values.background),
|
|
254
281
|
files: listOption(values.file).length ? listOption(values.file).map((raw) => parseFile(raw, cwd, root)) : undefined,
|
|
255
282
|
raisedBy: stringOption(values["raised-by"]),
|
|
256
283
|
ticket: ticket?.id,
|
|
257
284
|
},
|
|
258
|
-
message: `Asked ${id} (${title}).
|
|
285
|
+
message: `Asked ${id} (${title}). You recommend ${recommend}. Build no option before the answer: work only on what it doesn't affect, and if nothing else is left, stop and wait.`,
|
|
259
286
|
};
|
|
260
287
|
},
|
|
261
288
|
},
|
|
@@ -271,10 +298,10 @@ const commands: Record<string, Command> = {
|
|
|
271
298
|
choice = choice.toUpperCase();
|
|
272
299
|
if (letters.length && !letters.includes(choice)) throw new UsageError(`${question.id} has options ${letters.join(", ")}, not ${choice}.`);
|
|
273
300
|
}
|
|
274
|
-
const
|
|
301
|
+
const asRecommended = choice.toLowerCase() === question.recommended.toLowerCase();
|
|
275
302
|
return {
|
|
276
|
-
event: { type: "question.answer", id: question.id, choice, words: stringOption(values.words)
|
|
277
|
-
message: `Answered ${question.id}: ${choice}${
|
|
303
|
+
event: { type: "question.answer", id: question.id, choice, words: stringOption(values.words) },
|
|
304
|
+
message: `Answered ${question.id}: ${choice}${asRecommended ? " (as you recommended)" : ` (not your recommendation, ${question.recommended})`}. Build it now.`,
|
|
278
305
|
};
|
|
279
306
|
},
|
|
280
307
|
},
|
|
@@ -466,7 +493,9 @@ export async function runCli(argv: string[], options: CliOptions): Promise<numbe
|
|
|
466
493
|
if (first === "show") {
|
|
467
494
|
const root = findRoot(options.cwd);
|
|
468
495
|
const store = await findStore(root);
|
|
469
|
-
|
|
496
|
+
const { dashboard } = await readProgress(store.directory, now());
|
|
497
|
+
const added = addedByOthersText(addedByOthers(await readLatestRun(store.directory), dashboard, options.agentId), dashboard);
|
|
498
|
+
out(`Worktree: ${root}\n\n${dashboardText(dashboard, now().getTime())}${added}`);
|
|
470
499
|
return 0;
|
|
471
500
|
}
|
|
472
501
|
const target = resolve(options.cwd, (argv[1] ?? DEFAULT_LAUNCHER).replace(/^~(?=$|\/)/, homedir()));
|
|
@@ -499,7 +528,8 @@ export async function runCli(argv: string[], options: CliOptions): Promise<numbe
|
|
|
499
528
|
throw new UsageError(`The run "${state.dashboard.run!.title}" is finished. Start new work with: ${USAGE_NAME} start "<title>"`);
|
|
500
529
|
}
|
|
501
530
|
const { event, message } = command.run({ positionals: parsed.positionals, values: parsed.values, state, cwd: options.cwd, root });
|
|
502
|
-
|
|
531
|
+
const added = command === commands.start ? "" : addedByOthersText(addedByOthers(state.events, state.dashboard, options.agentId), state.dashboard);
|
|
532
|
+
return { event: { ...event, by: options.agentId }, result: `${message}${added}` };
|
|
503
533
|
}, (text) => saveRunCopy(root, text));
|
|
504
534
|
out(message);
|
|
505
535
|
return 0;
|
|
@@ -522,7 +552,7 @@ function isMain(): boolean {
|
|
|
522
552
|
}
|
|
523
553
|
|
|
524
554
|
if (isMain()) {
|
|
525
|
-
runCli(process.argv.slice(2), { cwd: process.cwd() }).then(
|
|
555
|
+
runCli(process.argv.slice(2), { cwd: process.cwd(), agentId: process.env.PASEO_AGENT_ID || undefined }).then(
|
|
526
556
|
(code) => { process.exitCode = code; },
|
|
527
557
|
(error) => {
|
|
528
558
|
console.error(error instanceof Error ? error.message : error);
|
package/server/progress-file.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// the panel's RPC handlers go through here, so they agree on how they're read.
|
|
5
5
|
import { access, appendFile, mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
6
6
|
import { join } from "node:path";
|
|
7
|
-
import { EMPTY_DASHBOARD, parseProgress, reduceProgress, type Dashboard, type NextIds } from "../shared/dashboard.ts";
|
|
7
|
+
import { EMPTY_DASHBOARD, latestRun, parseProgress, reduceProgress, type Dashboard, type NextIds } from "../shared/dashboard.ts";
|
|
8
8
|
import { EVENT_VERSION, eventSchema, PANEL_OPENED_NAME, PROGRESS_FILE_NAME, type ProgressEvent } from "../shared/events.ts";
|
|
9
9
|
import { withLock } from "./lock.ts";
|
|
10
10
|
|
|
@@ -47,21 +47,28 @@ export async function readProgress(directory: string, now = new Date()): Promise
|
|
|
47
47
|
return { configured: true, dashboard: cached.dashboard, panelOpened };
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
// The events of the latest run, oldest first.
|
|
51
|
+
export async function readLatestRun(directory: string): Promise<ProgressEvent[]> {
|
|
52
|
+
return latestRun((await readParsed(directory))?.events ?? []).map(({ event }) => event);
|
|
53
|
+
}
|
|
54
|
+
|
|
50
55
|
// Appends the one event `write` builds from the file as it is now. Commands
|
|
51
56
|
// that run at once take turns, so each sees the others' events and no two hand
|
|
52
57
|
// out the same id. `write` may throw to append nothing.
|
|
53
58
|
export async function appendProgress<T>(
|
|
54
59
|
directory: string,
|
|
55
60
|
now: () => Date,
|
|
56
|
-
|
|
61
|
+
// `events` is the latest run as it is now, oldest first.
|
|
62
|
+
write: (state: { dashboard: Dashboard; nextIds: NextIds; events: ProgressEvent[] }) => { event: EventDraft; result: T },
|
|
57
63
|
// Runs before the lock is released, with the whole file as it now is.
|
|
58
64
|
after?: (text: string) => Promise<void>,
|
|
59
65
|
): Promise<T> {
|
|
60
66
|
await mkdir(directory, { recursive: true });
|
|
61
67
|
return withLock(join(directory, LOCK_NAME), async () => {
|
|
62
68
|
const text = (await readText(directory)) ?? "";
|
|
63
|
-
const
|
|
64
|
-
const {
|
|
69
|
+
const parsed = parseProgress(text);
|
|
70
|
+
const { dashboard, nextIds } = reduceProgress(parsed, now());
|
|
71
|
+
const { event, result } = write({ dashboard, nextIds, events: latestRun(parsed.events).map((line) => line.event) });
|
|
65
72
|
const checked = eventSchema.safeParse({ v: EVENT_VERSION, ts: now().toISOString(), ...withoutUndefined(event) });
|
|
66
73
|
if (!checked.success) {
|
|
67
74
|
throw new InvalidEvent(checked.error.issues.map((issue) => `${issue.path.join(".") || "value"}: ${issue.message}`).join("; "));
|
package/shared/attachments.ts
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
|
-
// Anything the user can open from the dashboard: a deliverable ("D3")
|
|
2
|
-
//
|
|
3
|
-
// server finds them by the same reference, so both
|
|
4
|
-
|
|
1
|
+
// Anything the user can open from the dashboard: a deliverable ("D3"), a file
|
|
2
|
+
// on a question ("Q7.2", counting from 1), or a ticket's source ("T03.source").
|
|
3
|
+
// The panel lists them and the server finds them by the same reference, so both
|
|
4
|
+
// read it from here.
|
|
5
|
+
import type { Dashboard, Deliverable, Question, Ticket } from "./dashboard.ts";
|
|
6
|
+
import type { FileLink } from "./events.ts";
|
|
5
7
|
|
|
6
8
|
export interface Attachment {
|
|
7
9
|
ref: string;
|
|
8
10
|
title: string;
|
|
9
11
|
path?: string;
|
|
10
12
|
url?: string;
|
|
11
|
-
// What the agent said a deliverable is.
|
|
13
|
+
// What the agent said a deliverable is. Other attachments don't have one.
|
|
12
14
|
kind?: Deliverable["kind"];
|
|
13
15
|
}
|
|
14
16
|
|
|
@@ -16,13 +18,16 @@ export function deliverableAttachment(deliverable: Deliverable): Attachment {
|
|
|
16
18
|
return { ref: deliverable.id, title: deliverable.title, path: deliverable.path, url: deliverable.url, kind: deliverable.kind };
|
|
17
19
|
}
|
|
18
20
|
|
|
21
|
+
function fileAttachment(ref: string, file: FileLink): Attachment {
|
|
22
|
+
return { ref, title: file.label ?? (file.path ?? file.url ?? "").replace(/[\\/]+$/, "").split(/[\\/]/).pop() ?? "", path: file.path, url: file.url };
|
|
23
|
+
}
|
|
24
|
+
|
|
19
25
|
export function questionAttachments(question: Question): Attachment[] {
|
|
20
|
-
return question.files.map((file, index) => ({
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
}));
|
|
26
|
+
return question.files.map((file, index) => fileAttachment(`${question.id}.${index + 1}`, file));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function ticketSourceAttachment(ticket: Ticket): Attachment | null {
|
|
30
|
+
return ticket.source ? fileAttachment(`${ticket.id}.source`, ticket.source) : null;
|
|
26
31
|
}
|
|
27
32
|
|
|
28
33
|
// The attachment a reference names, or null when it names nothing.
|
|
@@ -32,6 +37,11 @@ export function findAttachment(dashboard: Dashboard, ref: string): Attachment |
|
|
|
32
37
|
const found = [...dashboard.questions.open, ...dashboard.questions.answered].find((candidate) => candidate.id === question[1]);
|
|
33
38
|
return (found && questionAttachments(found).find((attachment) => attachment.ref === ref)) ?? null;
|
|
34
39
|
}
|
|
40
|
+
const ticket = ref.match(/^(.+)\.source$/);
|
|
41
|
+
if (ticket) {
|
|
42
|
+
const found = dashboard.tickets.find((candidate) => candidate.id === ticket[1]);
|
|
43
|
+
return found ? ticketSourceAttachment(found) : null;
|
|
44
|
+
}
|
|
35
45
|
const deliverable = dashboard.deliverables.find((candidate) => candidate.id === ref);
|
|
36
46
|
return deliverable ? deliverableAttachment(deliverable) : null;
|
|
37
47
|
}
|
package/shared/dashboard.ts
CHANGED
|
@@ -24,6 +24,10 @@ export interface Ticket {
|
|
|
24
24
|
// Waiting is the order of work, not a problem, so it never counts as stuck.
|
|
25
25
|
waitingFor?: { id: string; title: string };
|
|
26
26
|
waitsFor?: string;
|
|
27
|
+
// Where the ticket is written up: a spec file or an issue link.
|
|
28
|
+
source?: FileLink;
|
|
29
|
+
// The Paseo agent that last added or updated it, when written from Paseo.
|
|
30
|
+
agentId?: string;
|
|
27
31
|
// Every status, stage and note change, starting with when it was added.
|
|
28
32
|
history: TicketChange[];
|
|
29
33
|
}
|
|
@@ -39,14 +43,15 @@ export interface Question {
|
|
|
39
43
|
title: string;
|
|
40
44
|
question: string;
|
|
41
45
|
options: QuestionOption[];
|
|
42
|
-
|
|
43
|
-
|
|
46
|
+
// The option the agent leans toward. Nothing is built until the user answers.
|
|
47
|
+
recommended: string;
|
|
44
48
|
background?: string;
|
|
45
49
|
files: FileLink[];
|
|
46
50
|
raisedBy?: string;
|
|
47
51
|
ticketId?: string;
|
|
48
52
|
askedAt: string;
|
|
49
|
-
|
|
53
|
+
// `changedCourse` only on older answers, from when agents built a default before asking.
|
|
54
|
+
answer?: { choice: string; words?: string; changedCourse?: boolean; at: string };
|
|
50
55
|
}
|
|
51
56
|
|
|
52
57
|
export interface Deliverable {
|
|
@@ -109,7 +114,8 @@ export interface Dashboard {
|
|
|
109
114
|
deliverables: Deliverable[];
|
|
110
115
|
// Newest first.
|
|
111
116
|
activity: Activity[];
|
|
112
|
-
|
|
117
|
+
// `by` is the Paseo agent that set it, when set from Paseo.
|
|
118
|
+
ticker: { text: string; since: string; by?: string } | null;
|
|
113
119
|
// Share of estimated minutes in done tickets. Skipped tickets are left out.
|
|
114
120
|
// `tookMin`: once every counted ticket is done, the real time from the run's
|
|
115
121
|
// start to the last one finishing.
|
|
@@ -189,16 +195,18 @@ export function isSettled(ticket: Ticket): boolean {
|
|
|
189
195
|
return ticket.status === "done" || ticket.status === "skipped";
|
|
190
196
|
}
|
|
191
197
|
|
|
198
|
+
// The events from the last `run.start` on; earlier runs are ignored.
|
|
199
|
+
export function latestRun(events: ParsedLine[]): ParsedLine[] {
|
|
200
|
+
const start = events.findLastIndex(({ event }) => event.type === "run.start");
|
|
201
|
+
return events.slice(Math.max(start, 0));
|
|
202
|
+
}
|
|
203
|
+
|
|
192
204
|
// Folds the events of the latest run into what the panel shows at `now`, how
|
|
193
205
|
// long that stays true while the events don't change, and the ids new items get.
|
|
194
206
|
// Everything before the last `run.start` belongs to earlier runs and is ignored.
|
|
195
207
|
export function reduceProgress(parsed: { events: ParsedLine[]; issues: FileIssue[] }, now: Date): { dashboard: Dashboard; validUntil: number; nextIds: NextIds } {
|
|
196
208
|
const time = clock(now);
|
|
197
|
-
|
|
198
|
-
parsed.events.forEach(({ event }, index) => {
|
|
199
|
-
if (event.type === "run.start") start = index;
|
|
200
|
-
});
|
|
201
|
-
const events = parsed.events.slice(start);
|
|
209
|
+
const events = latestRun(parsed.events);
|
|
202
210
|
const issues = [...parsed.issues];
|
|
203
211
|
let run: Dashboard["run"] = null;
|
|
204
212
|
const tickets = new Map<string, Ticket>();
|
|
@@ -229,6 +237,8 @@ export function reduceProgress(parsed: { events: ParsedLine[]; issues: FileIssue
|
|
|
229
237
|
workingSince: event.status === "working" ? event.ts : undefined,
|
|
230
238
|
history: [{ at: event.ts, status: event.status ?? "not_started" }],
|
|
231
239
|
waitsFor: event.waitsFor,
|
|
240
|
+
source: event.source,
|
|
241
|
+
agentId: event.by,
|
|
232
242
|
});
|
|
233
243
|
break;
|
|
234
244
|
case "ticket.update": {
|
|
@@ -255,6 +265,8 @@ export function reduceProgress(parsed: { events: ParsedLine[]; issues: FileIssue
|
|
|
255
265
|
}
|
|
256
266
|
if (event.note !== undefined) ticket.note = event.note || undefined;
|
|
257
267
|
if (event.waitsFor !== undefined) ticket.waitsFor = event.waitsFor || undefined;
|
|
268
|
+
if (event.source !== undefined) ticket.source = event.source || undefined;
|
|
269
|
+
if (event.by) ticket.agentId = event.by;
|
|
258
270
|
break;
|
|
259
271
|
}
|
|
260
272
|
case "ticket.remove":
|
|
@@ -266,8 +278,7 @@ export function reduceProgress(parsed: { events: ParsedLine[]; issues: FileIssue
|
|
|
266
278
|
title: event.title,
|
|
267
279
|
question: event.question,
|
|
268
280
|
options: event.options ?? [],
|
|
269
|
-
|
|
270
|
-
waits: event.waits ?? false,
|
|
281
|
+
recommended: event.recommend ?? event.default ?? "",
|
|
271
282
|
background: event.background,
|
|
272
283
|
files: event.files ?? [],
|
|
273
284
|
raisedBy: event.raisedBy,
|
|
@@ -322,7 +333,7 @@ export function reduceProgress(parsed: { events: ParsedLine[]; issues: FileIssue
|
|
|
322
333
|
if (!activity.delete(event.id)) issues.push({ line, reason: `unknown activity ${event.id}` });
|
|
323
334
|
break;
|
|
324
335
|
case "ticker.set":
|
|
325
|
-
ticker = { text: event.text, since: event.ts };
|
|
336
|
+
ticker = { text: event.text, since: event.ts, ...(event.by ? { by: event.by } : {}) };
|
|
326
337
|
break;
|
|
327
338
|
case "ticker.clear":
|
|
328
339
|
ticker = null;
|
package/shared/events.ts
CHANGED
|
@@ -16,7 +16,8 @@ export type TicketStatus = z.infer<typeof ticketStatusSchema>;
|
|
|
16
16
|
|
|
17
17
|
const text = z.string().trim().min(1);
|
|
18
18
|
const minutes = z.number().int().positive().max(100_000);
|
|
19
|
-
|
|
19
|
+
// `by` is the Paseo agent that wrote the event, absent when run outside Paseo.
|
|
20
|
+
const base = { v: z.literal(EVENT_VERSION), ts: z.iso.datetime(), by: text.optional() };
|
|
20
21
|
|
|
21
22
|
export const questionOptionSchema = z.object({
|
|
22
23
|
letter: z.string().regex(/^[A-Z]$/),
|
|
@@ -54,6 +55,8 @@ export const eventSchema = z.discriminatedUnion("type", [
|
|
|
54
55
|
status: ticketStatusSchema.optional(),
|
|
55
56
|
// Another ticket in the run this one can't start until it's done.
|
|
56
57
|
waitsFor: text.optional(),
|
|
58
|
+
// Where the ticket is written up: a spec file or an issue link.
|
|
59
|
+
source: fileLinkSchema.optional(),
|
|
57
60
|
}),
|
|
58
61
|
z.object({
|
|
59
62
|
...base,
|
|
@@ -67,12 +70,16 @@ export const eventSchema = z.discriminatedUnion("type", [
|
|
|
67
70
|
note: z.string().trim().optional(),
|
|
68
71
|
// Empty clears it.
|
|
69
72
|
waitsFor: z.string().trim().optional(),
|
|
73
|
+
// Empty clears it.
|
|
74
|
+
source: z.union([fileLinkSchema, z.literal("")]).optional(),
|
|
70
75
|
}),
|
|
71
76
|
z.object({ ...base, type: z.literal("ticket.remove"), id: text }),
|
|
72
77
|
// A blocker that is not a ticket status, like a failing external service.
|
|
73
78
|
z.object({ ...base, type: z.literal("stuck.set"), id: text, reason: text, ticket: text.optional() }),
|
|
74
79
|
z.object({ ...base, type: z.literal("stuck.clear"), id: text }),
|
|
75
|
-
// The agent asks,
|
|
80
|
+
// The agent asks, recommends an option, and builds none of them before the
|
|
81
|
+
// answer: it works only on what the question doesn't affect. Older questions
|
|
82
|
+
// carry `default` (the option the agent carried on with) and `waits` instead.
|
|
76
83
|
z.object({
|
|
77
84
|
...base,
|
|
78
85
|
type: z.literal("question.ask"),
|
|
@@ -80,9 +87,9 @@ export const eventSchema = z.discriminatedUnion("type", [
|
|
|
80
87
|
title: text,
|
|
81
88
|
question: text,
|
|
82
89
|
options: z.array(questionOptionSchema).max(26).optional(),
|
|
83
|
-
//
|
|
84
|
-
|
|
85
|
-
|
|
90
|
+
// The agent's pick: an option letter, or a word such as "Looks right" when there are no options.
|
|
91
|
+
recommend: text.optional(),
|
|
92
|
+
default: text.optional(),
|
|
86
93
|
waits: z.boolean().optional(),
|
|
87
94
|
background: text.optional(),
|
|
88
95
|
files: z.array(fileLinkSchema).optional(),
|
|
@@ -97,7 +104,8 @@ export const eventSchema = z.discriminatedUnion("type", [
|
|
|
97
104
|
choice: text,
|
|
98
105
|
// The user's own words, quoted under the answer.
|
|
99
106
|
words: text.optional(),
|
|
100
|
-
|
|
107
|
+
// Older answers: true when the agent had to undo work built on its default.
|
|
108
|
+
changedCourse: z.boolean().optional(),
|
|
101
109
|
}),
|
|
102
110
|
// Adds detail to an open question; its reference and default stay.
|
|
103
111
|
z.object({
|
package/shared/format.ts
CHANGED
|
@@ -36,6 +36,13 @@ export function formatClock(iso: string): string {
|
|
|
36
36
|
return `${MONTHS[date.getMonth()]} ${date.getDate()}, ${hours % 12 || 12}:${minutes} ${hours < 12 ? "AM" : "PM"}`;
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
// Just the time, "2:08 AM", when it falls on the same day as `nowMs`; the full
|
|
40
|
+
// clock time otherwise.
|
|
41
|
+
export function formatTimeOfDay(iso: string, nowMs: number): string {
|
|
42
|
+
const full = formatClock(iso);
|
|
43
|
+
return new Date(iso).toDateString() === new Date(nowMs).toDateString() ? full.slice(full.indexOf(", ") + 2) : full;
|
|
44
|
+
}
|
|
45
|
+
|
|
39
46
|
export function formatAgo(iso: string, nowMs: number): string {
|
|
40
47
|
const minutes = (nowMs - Date.parse(iso)) / 60_000;
|
|
41
48
|
return minutes < 1 ? "just now" : `${formatMinutes(minutes)} ago`;
|
package/shared/show.ts
CHANGED
|
@@ -39,11 +39,12 @@ export function dashboardText(dashboard: Dashboard, nowMs: number): string {
|
|
|
39
39
|
const stage = ticket.status === "working" && ticket.stage ? `, ${ticket.stage} stage` : "";
|
|
40
40
|
const status = ticket.waitingFor ? `Waiting for ${ticket.waitingFor.id}` : STATUS[ticket.status];
|
|
41
41
|
lines.push(` ${ticket.id} ${status}${stage} ${formatEstimate(ticket.estimateMin)} ${ticket.title}`);
|
|
42
|
+
if (ticket.source) lines.push(` Source: ${ticket.source.path ?? ticket.source.url}${ticket.source.label ? ` (${ticket.source.label})` : ""}`);
|
|
42
43
|
}
|
|
43
44
|
if (dashboard.questions.open.length) {
|
|
44
45
|
lines.push("", "Questions waiting");
|
|
45
46
|
for (const question of dashboard.questions.open) {
|
|
46
|
-
lines.push(` ${question.id} ${question.ticketId ? `[${question.ticketId}] ` : ""}(${question.title}): ${question.question}
|
|
47
|
+
lines.push(` ${question.id} ${question.ticketId ? `[${question.ticketId}] ` : ""}(${question.title}): ${question.question} You recommended ${question.recommended}`);
|
|
47
48
|
for (const option of question.options) lines.push(` ${option.letter}) ${option.label}${option.consequence ? `: ${option.consequence}` : ""}`);
|
|
48
49
|
}
|
|
49
50
|
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// A ticket's write-up (its Markdown source file) as simple blocks the dialog
|
|
2
|
+
// can draw without a Markdown renderer: headings, paragraphs, list items and
|
|
3
|
+
// code. Inline marks are kept as spans: `code` stays code, **bold** and
|
|
4
|
+
// _emphasis_ lose their markers, and [links](url) keep only their text.
|
|
5
|
+
|
|
6
|
+
export type Span = { text: string; code?: boolean };
|
|
7
|
+
|
|
8
|
+
export type Block =
|
|
9
|
+
| { kind: "heading"; level: number; spans: Span[] }
|
|
10
|
+
| { kind: "paragraph"; spans: Span[] }
|
|
11
|
+
// `marker` is "•" for bullets, "1." and so on for numbered items, "☐"/"☑" for tasks.
|
|
12
|
+
| { kind: "item"; marker: string; depth: number; spans: Span[] }
|
|
13
|
+
| { kind: "code"; text: string };
|
|
14
|
+
|
|
15
|
+
export function spans(text: string): Span[] {
|
|
16
|
+
const result: Span[] = [];
|
|
17
|
+
text.split(/(`[^`]+`)/).forEach((part) => {
|
|
18
|
+
if (!part) return;
|
|
19
|
+
if (/^`[^`]+`$/.test(part)) {
|
|
20
|
+
result.push({ text: part.slice(1, -1), code: true });
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
const plain = part
|
|
24
|
+
.replace(/!?\[([^\]]*)\]\([^)]*\)/g, "$1")
|
|
25
|
+
.replace(/(\*\*|__)(.+?)\1/g, "$2")
|
|
26
|
+
.replace(/(^|[\s(])[*_](\S(?:.*?\S)?)[*_](?=[\s).,;:!?]|$)/g, "$1$2");
|
|
27
|
+
result.push({ text: plain });
|
|
28
|
+
});
|
|
29
|
+
return result;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function parseWriteUp(markdown: string): Block[] {
|
|
33
|
+
const blocks: Block[] = [];
|
|
34
|
+
let paragraph: string[] = [];
|
|
35
|
+
let fence: string[] | null = null;
|
|
36
|
+
const flush = () => {
|
|
37
|
+
if (paragraph.length) blocks.push({ kind: "paragraph", spans: spans(paragraph.join(" ")) });
|
|
38
|
+
paragraph = [];
|
|
39
|
+
};
|
|
40
|
+
for (const raw of markdown.replace(/\r\n?/g, "\n").split("\n")) {
|
|
41
|
+
if (fence) {
|
|
42
|
+
if (/^\s*(```|~~~)/.test(raw)) {
|
|
43
|
+
blocks.push({ kind: "code", text: fence.join("\n") });
|
|
44
|
+
fence = null;
|
|
45
|
+
} else fence.push(raw);
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
if (/^\s*(```|~~~)/.test(raw)) {
|
|
49
|
+
flush();
|
|
50
|
+
fence = [];
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
const line = raw.trimEnd();
|
|
54
|
+
const heading = /^(#{1,6})\s+(.*?)\s*#*$/.exec(line);
|
|
55
|
+
const item = /^(\s*)([-*+]|\d+[.)])\s+(?:\[([ xX])\]\s+)?(.*)$/.exec(line);
|
|
56
|
+
if (!line.trim() || /^\s*([-*_])(\s*\1){2,}\s*$/.test(line)) flush();
|
|
57
|
+
else if (heading) {
|
|
58
|
+
flush();
|
|
59
|
+
blocks.push({ kind: "heading", level: heading[1].length, spans: spans(heading[2]) });
|
|
60
|
+
} else if (item) {
|
|
61
|
+
flush();
|
|
62
|
+
const marker = item[3] !== undefined ? (item[3] === " " ? "☐" : "☑") : /\d/.test(item[2]) ? item[2].replace(")", ".") : "•";
|
|
63
|
+
blocks.push({ kind: "item", marker, depth: Math.floor(item[1].replace(/\t/g, " ").length / 2), spans: spans(item[4]) });
|
|
64
|
+
} else if (/^\s{2,}\S/.test(line) && blocks.at(-1)?.kind === "item" && !paragraph.length) {
|
|
65
|
+
// A wrapped list item continues on an indented line.
|
|
66
|
+
const last = blocks.at(-1) as Extract<Block, { kind: "item" }>;
|
|
67
|
+
last.spans = [...last.spans, ...spans(` ${line.trim()}`)];
|
|
68
|
+
} else paragraph.push(line.trim().replace(/^>\s?/, ""));
|
|
69
|
+
}
|
|
70
|
+
flush();
|
|
71
|
+
if (fence) blocks.push({ kind: "code", text: fence.join("\n") });
|
|
72
|
+
return blocks;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// The write-up without a leading top heading, which only repeats the ticket's title.
|
|
76
|
+
export function withoutTitle(blocks: Block[]): Block[] {
|
|
77
|
+
return blocks[0]?.kind === "heading" && blocks[0].level === 1 ? blocks.slice(1) : blocks;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// The first blocks of the write-up, up to about `maxChars` of text, for the excerpt.
|
|
81
|
+
export function writeUpExcerpt(blocks: Block[], maxChars = 420): { blocks: Block[]; more: boolean } {
|
|
82
|
+
const body = withoutTitle(blocks);
|
|
83
|
+
const shown: Block[] = [];
|
|
84
|
+
let chars = 0;
|
|
85
|
+
for (const block of body) {
|
|
86
|
+
const length = block.kind === "code" ? block.text.length : block.spans.reduce((sum, span) => sum + span.text.length, 0);
|
|
87
|
+
if (shown.length && chars + length > maxChars) break;
|
|
88
|
+
shown.push(block);
|
|
89
|
+
chars += length;
|
|
90
|
+
}
|
|
91
|
+
return { blocks: shown, more: shown.length < body.length };
|
|
92
|
+
}
|
|
@@ -14,7 +14,7 @@ The user answers questions by reference ("Q7 A") and reads everything else at a
|
|
|
14
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
15
|
|
|
16
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
|
|
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. When a ticket is written up somewhere, a spec file or an issue, pass `--source <path or link>` so the user can open it from the ticket. For a job without tickets, add its steps instead and pass `--item-label Step` to `start`; give a step `--source` only when it has a write-up.
|
|
18
18
|
3. `paseo-progress activity add "<one line: what this run will do>"`.
|
|
19
19
|
|
|
20
20
|
Done when `show` lists every ticket with an estimate.
|
|
@@ -35,24 +35,27 @@ Record each change as it happens, on the ticket's id from `show` (T01, T02, ...)
|
|
|
35
35
|
|
|
36
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
37
|
|
|
38
|
+
Other sessions may add tickets (or steps) to the same run. When a command's output says another session added one, it is part of the plan: work it in order after your current one, the same as the ones you added.
|
|
39
|
+
|
|
38
40
|
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
41
|
|
|
40
|
-
## 3. Ask questions
|
|
42
|
+
## 3. Ask questions, and work around them
|
|
41
43
|
|
|
42
|
-
When a decision belongs to the user, ask it
|
|
44
|
+
When a decision belongs to the user, ask it, then park everything the answer affects:
|
|
43
45
|
|
|
44
46
|
```sh
|
|
45
47
|
paseo-progress question ask "<short title>" "<the question, in one or two sentences>" \
|
|
46
48
|
--option "A=<choice> | <what happens if they pick it>" \
|
|
47
49
|
--option "B=<choice> | <what happens if they pick it>" \
|
|
48
|
-
--
|
|
50
|
+
--recommend <letter> --ticket T03 --raised-by "Ticket 03 design review" \
|
|
49
51
|
[--background "<what they need to decide without opening the worktree>"] \
|
|
50
52
|
[--file <path or http(s) URL>=<what it shows>]
|
|
51
53
|
```
|
|
52
54
|
|
|
53
|
-
-
|
|
54
|
-
-
|
|
55
|
-
-
|
|
55
|
+
- `--recommend` is the option you would pick, so the user knows which way to lean.
|
|
56
|
+
- Build no option before the user answers, not even the one you recommend. The point of asking is that the user decides before the work is done.
|
|
57
|
+
- Keep working on whatever the question doesn't affect: other tickets, or other parts of this one. If nothing else is left, stop, tell the user in chat which questions you're waiting on, and leave the run open.
|
|
58
|
+
- When the user replies ("Q7 A", or in their own words), run `question answer Q7 A --words "<their words>"`, then build what they chose.
|
|
56
59
|
- 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
60
|
- 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
61
|
- `question remove Q7` withdraws a question that no longer applies.
|