@stevecastaneda/paseo-progress 0.2.0 → 0.3.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/README.md +10 -6
- package/client/attachments.tsx +1 -1
- package/client/dialog-stack.tsx +12 -1
- package/client/narrow.tsx +13 -0
- package/client/panel.tsx +28 -37
- package/client/pill-popover.tsx +5 -3
- package/client/questions.tsx +18 -24
- package/client/tabs.tsx +65 -0
- package/client/ticket-dialog.tsx +142 -112
- package/client/time-bar.tsx +19 -0
- package/client/when.tsx +4 -1
- 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/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- 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/dist/server/cli.js
CHANGED
|
@@ -6,7 +6,7 @@ import { fileURLToPath } from "node:url";
|
|
|
6
6
|
import { parseArgs } from "node:util";
|
|
7
7
|
import { isSettled } from "../shared/dashboard.js";
|
|
8
8
|
import { DELIVERABLE_KINDS, TICKET_STATUSES } from "../shared/events.js";
|
|
9
|
-
import { appendProgress, InvalidEvent, readProgress } from "./progress-file.js";
|
|
9
|
+
import { appendProgress, InvalidEvent, readLatestRun, readProgress } from "./progress-file.js";
|
|
10
10
|
import { findRoot, outside } from "./paths.js";
|
|
11
11
|
import { installLauncher } from "./launcher.js";
|
|
12
12
|
import { findStore, saveRunCopy } from "./store.js";
|
|
@@ -114,6 +114,31 @@ function unfinishedWork(dashboard) {
|
|
|
114
114
|
flagged.length ? `flagged blockers ${flagged.map((item) => item.id).join(", ")} (clear with "stuck clear")` : "",
|
|
115
115
|
].filter(Boolean);
|
|
116
116
|
}
|
|
117
|
+
// Tickets another session added since this agent last wrote to the run, still
|
|
118
|
+
// to do. Nothing until the agent has written to the run: it reads `show` first.
|
|
119
|
+
function addedByOthers(events, dashboard, agentId) {
|
|
120
|
+
if (!agentId)
|
|
121
|
+
return [];
|
|
122
|
+
const since = events.findLastIndex((event) => event.by === agentId);
|
|
123
|
+
if (since === -1)
|
|
124
|
+
return [];
|
|
125
|
+
const added = new Set(events.slice(since + 1).flatMap((event) => (event.type === "ticket.add" && event.by !== agentId ? [event.id] : [])));
|
|
126
|
+
return dashboard.tickets.filter((ticket) => added.has(ticket.id) && !isSettled(ticket));
|
|
127
|
+
}
|
|
128
|
+
function addedByOthersText(tickets, dashboard) {
|
|
129
|
+
if (!tickets.length)
|
|
130
|
+
return "";
|
|
131
|
+
const label = (dashboard.run?.itemLabel ?? "Ticket").toLowerCase();
|
|
132
|
+
const one = tickets.length === 1;
|
|
133
|
+
// Starts with a blank line, to follow the command's own output.
|
|
134
|
+
return [
|
|
135
|
+
"",
|
|
136
|
+
"",
|
|
137
|
+
`Another session added ${one ? `a ${label}` : `${tickets.length} ${label}s`} since your last update:`,
|
|
138
|
+
...tickets.map((ticket) => ` ${ticket.id} ${ticket.title}`),
|
|
139
|
+
`${one ? "It is" : "They are"} part of this run: work ${one ? "it" : "them"} in order after your current ${label}.`,
|
|
140
|
+
].join("\n");
|
|
141
|
+
}
|
|
117
142
|
const commands = {
|
|
118
143
|
start: {
|
|
119
144
|
usage: `start "<title>" [--subtitle <text>] [--item-label <word>]`,
|
|
@@ -141,27 +166,28 @@ const commands = {
|
|
|
141
166
|
},
|
|
142
167
|
},
|
|
143
168
|
"ticket add": {
|
|
144
|
-
usage: `ticket add "<title>" --estimate <minutes> [--status <status>] [--waits-for <id>]`,
|
|
145
|
-
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.",
|
|
146
|
-
options: { estimate: { type: "string" }, status: { type: "string" }, "waits-for": { type: "string" } },
|
|
147
|
-
run({ positionals, values, state }) {
|
|
169
|
+
usage: `ticket add "<title>" --estimate <minutes> [--status <status>] [--waits-for <id>] [--source <path or http(s) URL>[=<label>]]`,
|
|
170
|
+
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.",
|
|
171
|
+
options: { estimate: { type: "string" }, status: { type: "string" }, "waits-for": { type: "string" }, source: { type: "string" } },
|
|
172
|
+
run({ positionals, values, state, cwd, root }) {
|
|
148
173
|
const waitsFor = values["waits-for"] === undefined ? undefined : existingTicket(state, stringOption(values["waits-for"])).id;
|
|
149
174
|
const title = required(positionals[0], "ticket title");
|
|
150
175
|
const estimateMin = minutesOption(values.estimate, "estimate");
|
|
151
176
|
if (estimateMin === undefined)
|
|
152
177
|
throw new UsageError("Missing --estimate <minutes>.");
|
|
178
|
+
const source = values.source === undefined ? undefined : parseFile(String(values.source), cwd, root);
|
|
153
179
|
const id = state.nextIds.ticket;
|
|
154
180
|
return {
|
|
155
|
-
event: { type: "ticket.add", id, title, estimateMin, status: statusOption(values.status), waitsFor },
|
|
156
|
-
message: `Added ${id}: ${title} (${estimateMin} min)${waitsFor ? `, waits for ${waitsFor}` : ""}`,
|
|
181
|
+
event: { type: "ticket.add", id, title, estimateMin, status: statusOption(values.status), waitsFor, source },
|
|
182
|
+
message: `Added ${id}: ${title} (${estimateMin} min)${waitsFor ? `, waits for ${waitsFor}` : ""}${source ? `, source ${source.path ?? source.url}` : ""}`,
|
|
157
183
|
};
|
|
158
184
|
},
|
|
159
185
|
},
|
|
160
186
|
"ticket update": {
|
|
161
|
-
usage: `ticket update <id> [--status <status>] [--stage <name>] [--title <text>] [--estimate <minutes>] [--note <text>] [--waits-for <id>]`,
|
|
162
|
-
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.`,
|
|
163
|
-
options: { status: { type: "string" }, stage: { type: "string" }, title: { type: "string" }, estimate: { type: "string" }, note: { type: "string" }, "waits-for": { type: "string" } },
|
|
164
|
-
run({ positionals, values, state }) {
|
|
187
|
+
usage: `ticket update <id> [--status <status>] [--stage <name>] [--title <text>] [--estimate <minutes>] [--note <text>] [--waits-for <id>] [--source <path or http(s) URL>[=<label>]]`,
|
|
188
|
+
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).`,
|
|
189
|
+
options: { status: { type: "string" }, stage: { type: "string" }, title: { type: "string" }, estimate: { type: "string" }, note: { type: "string" }, "waits-for": { type: "string" }, source: { type: "string" } },
|
|
190
|
+
run({ positionals, values, state, cwd, root }) {
|
|
165
191
|
const ticket = existingTicket(state, positionals[0]);
|
|
166
192
|
const rawWaitsFor = values["waits-for"] === undefined ? undefined : String(values["waits-for"]).trim();
|
|
167
193
|
const waitsFor = rawWaitsFor === undefined || rawWaitsFor === "" ? rawWaitsFor : existingTicket(state, rawWaitsFor).id;
|
|
@@ -182,11 +208,13 @@ const commands = {
|
|
|
182
208
|
stage: stringOption(values.stage),
|
|
183
209
|
note: stringOption(values.note),
|
|
184
210
|
waitsFor,
|
|
211
|
+
source: values.source === undefined ? undefined : String(values.source).trim() === "" ? "" : parseFile(String(values.source), cwd, root),
|
|
185
212
|
};
|
|
186
213
|
const changes = Object.entries(event).filter(([key, value]) => key !== "type" && key !== "id" && value !== undefined);
|
|
187
214
|
if (!changes.length)
|
|
188
|
-
throw new UsageError("Nothing to change. Pass --status, --stage, --title, --estimate, --note,
|
|
189
|
-
|
|
215
|
+
throw new UsageError("Nothing to change. Pass --status, --stage, --title, --estimate, --note, --waits-for, or --source.");
|
|
216
|
+
const shown = (value) => (typeof value === "object" ? value.path ?? value.url : value);
|
|
217
|
+
return { event, message: `Updated ${ticket.id}: ${changes.map(([key, value]) => `${key} ${shown(value)}`).join(", ")}` };
|
|
190
218
|
},
|
|
191
219
|
},
|
|
192
220
|
"ticket remove": {
|
|
@@ -198,12 +226,11 @@ const commands = {
|
|
|
198
226
|
},
|
|
199
227
|
},
|
|
200
228
|
"question ask": {
|
|
201
|
-
usage: `question ask "<short title>" "<question>" [--option "A=<label> | <consequence>" ...] --
|
|
202
|
-
summary: "Ask the user a question. Prints its reference (Q1, Q2, ...).
|
|
229
|
+
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>]`,
|
|
230
|
+
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.",
|
|
203
231
|
options: {
|
|
204
232
|
option: { type: "string", multiple: true },
|
|
205
|
-
|
|
206
|
-
waits: { type: "boolean" },
|
|
233
|
+
recommend: { type: "string" },
|
|
207
234
|
background: { type: "string" },
|
|
208
235
|
file: { type: "string", multiple: true },
|
|
209
236
|
"raised-by": { type: "string" },
|
|
@@ -218,12 +245,12 @@ const commands = {
|
|
|
218
245
|
const duplicate = letters.find((letter, index) => letters.indexOf(letter) !== index);
|
|
219
246
|
if (duplicate)
|
|
220
247
|
throw new UsageError(`Option ${duplicate} is given twice.`);
|
|
221
|
-
let
|
|
248
|
+
let recommend = required(stringOption(values.recommend), "--recommend (the option you would pick)");
|
|
222
249
|
if (options.length) {
|
|
223
|
-
if (!/^[A-Za-z]$/.test(
|
|
224
|
-
throw new UsageError(`--
|
|
250
|
+
if (!/^[A-Za-z]$/.test(recommend) || !letters.includes(recommend.toUpperCase())) {
|
|
251
|
+
throw new UsageError(`--recommend must be one of the option letters: ${letters.join(", ")}.`);
|
|
225
252
|
}
|
|
226
|
-
|
|
253
|
+
recommend = recommend.toUpperCase();
|
|
227
254
|
}
|
|
228
255
|
const id = state.nextIds.question;
|
|
229
256
|
return {
|
|
@@ -233,14 +260,13 @@ const commands = {
|
|
|
233
260
|
title,
|
|
234
261
|
question,
|
|
235
262
|
options: options.length ? options : undefined,
|
|
236
|
-
|
|
237
|
-
waits: values.waits === true ? true : undefined,
|
|
263
|
+
recommend,
|
|
238
264
|
background: stringOption(values.background),
|
|
239
265
|
files: listOption(values.file).length ? listOption(values.file).map((raw) => parseFile(raw, cwd, root)) : undefined,
|
|
240
266
|
raisedBy: stringOption(values["raised-by"]),
|
|
241
267
|
ticket: ticket?.id,
|
|
242
268
|
},
|
|
243
|
-
message: `Asked ${id} (${title}).
|
|
269
|
+
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.`,
|
|
244
270
|
};
|
|
245
271
|
},
|
|
246
272
|
},
|
|
@@ -257,10 +283,10 @@ const commands = {
|
|
|
257
283
|
if (letters.length && !letters.includes(choice))
|
|
258
284
|
throw new UsageError(`${question.id} has options ${letters.join(", ")}, not ${choice}.`);
|
|
259
285
|
}
|
|
260
|
-
const
|
|
286
|
+
const asRecommended = choice.toLowerCase() === question.recommended.toLowerCase();
|
|
261
287
|
return {
|
|
262
|
-
event: { type: "question.answer", id: question.id, choice, words: stringOption(values.words)
|
|
263
|
-
message: `Answered ${question.id}: ${choice}${
|
|
288
|
+
event: { type: "question.answer", id: question.id, choice, words: stringOption(values.words) },
|
|
289
|
+
message: `Answered ${question.id}: ${choice}${asRecommended ? " (as you recommended)" : ` (not your recommendation, ${question.recommended})`}. Build it now.`,
|
|
264
290
|
};
|
|
265
291
|
},
|
|
266
292
|
},
|
|
@@ -453,7 +479,9 @@ export async function runCli(argv, options) {
|
|
|
453
479
|
if (first === "show") {
|
|
454
480
|
const root = findRoot(options.cwd);
|
|
455
481
|
const store = await findStore(root);
|
|
456
|
-
|
|
482
|
+
const { dashboard } = await readProgress(store.directory, now());
|
|
483
|
+
const added = addedByOthersText(addedByOthers(await readLatestRun(store.directory), dashboard, options.agentId), dashboard);
|
|
484
|
+
out(`Worktree: ${root}\n\n${dashboardText(dashboard, now().getTime())}${added}`);
|
|
457
485
|
return 0;
|
|
458
486
|
}
|
|
459
487
|
const target = resolve(options.cwd, (argv[1] ?? DEFAULT_LAUNCHER).replace(/^~(?=$|\/)/, homedir()));
|
|
@@ -487,7 +515,8 @@ export async function runCli(argv, options) {
|
|
|
487
515
|
throw new UsageError(`The run "${state.dashboard.run.title}" is finished. Start new work with: ${USAGE_NAME} start "<title>"`);
|
|
488
516
|
}
|
|
489
517
|
const { event, message } = command.run({ positionals: parsed.positionals, values: parsed.values, state, cwd: options.cwd, root });
|
|
490
|
-
|
|
518
|
+
const added = command === commands.start ? "" : addedByOthersText(addedByOthers(state.events, state.dashboard, options.agentId), state.dashboard);
|
|
519
|
+
return { event: { ...event, by: options.agentId }, result: `${message}${added}` };
|
|
491
520
|
}, (text) => saveRunCopy(root, text));
|
|
492
521
|
out(message);
|
|
493
522
|
return 0;
|
|
@@ -511,7 +540,7 @@ function isMain() {
|
|
|
511
540
|
}
|
|
512
541
|
}
|
|
513
542
|
if (isMain()) {
|
|
514
|
-
runCli(process.argv.slice(2), { cwd: process.cwd() }).then((code) => { process.exitCode = code; }, (error) => {
|
|
543
|
+
runCli(process.argv.slice(2), { cwd: process.cwd(), agentId: process.env.PASEO_AGENT_ID || undefined }).then((code) => { process.exitCode = code; }, (error) => {
|
|
515
544
|
console.error(error instanceof Error ? error.message : error);
|
|
516
545
|
process.exitCode = 1;
|
|
517
546
|
});
|
|
@@ -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 } from "../shared/dashboard.js";
|
|
7
|
+
import { EMPTY_DASHBOARD, latestRun, parseProgress, reduceProgress } from "../shared/dashboard.js";
|
|
8
8
|
import { EVENT_VERSION, eventSchema, PANEL_OPENED_NAME, PROGRESS_FILE_NAME } from "../shared/events.js";
|
|
9
9
|
import { withLock } from "./lock.js";
|
|
10
10
|
const LOCK_NAME = `${PROGRESS_FILE_NAME}.lock`;
|
|
@@ -34,17 +34,24 @@ export async function readProgress(directory, now = new Date()) {
|
|
|
34
34
|
}
|
|
35
35
|
return { configured: true, dashboard: cached.dashboard, panelOpened };
|
|
36
36
|
}
|
|
37
|
+
// The events of the latest run, oldest first.
|
|
38
|
+
export async function readLatestRun(directory) {
|
|
39
|
+
return latestRun((await readParsed(directory))?.events ?? []).map(({ event }) => event);
|
|
40
|
+
}
|
|
37
41
|
// Appends the one event `write` builds from the file as it is now. Commands
|
|
38
42
|
// that run at once take turns, so each sees the others' events and no two hand
|
|
39
43
|
// out the same id. `write` may throw to append nothing.
|
|
40
|
-
export async function appendProgress(directory, now,
|
|
44
|
+
export async function appendProgress(directory, now,
|
|
45
|
+
// `events` is the latest run as it is now, oldest first.
|
|
46
|
+
write,
|
|
41
47
|
// Runs before the lock is released, with the whole file as it now is.
|
|
42
48
|
after) {
|
|
43
49
|
await mkdir(directory, { recursive: true });
|
|
44
50
|
return withLock(join(directory, LOCK_NAME), async () => {
|
|
45
51
|
const text = (await readText(directory)) ?? "";
|
|
46
|
-
const
|
|
47
|
-
const {
|
|
52
|
+
const parsed = parseProgress(text);
|
|
53
|
+
const { dashboard, nextIds } = reduceProgress(parsed, now());
|
|
54
|
+
const { event, result } = write({ dashboard, nextIds, events: latestRun(parsed.events).map((line) => line.event) });
|
|
48
55
|
const checked = eventSchema.safeParse({ v: EVENT_VERSION, ts: now().toISOString(), ...withoutUndefined(event) });
|
|
49
56
|
if (!checked.success) {
|
|
50
57
|
throw new InvalidEvent(checked.error.issues.map((issue) => `${issue.path.join(".") || "value"}: ${issue.message}`).join("; "));
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
export function deliverableAttachment(deliverable) {
|
|
2
2
|
return { ref: deliverable.id, title: deliverable.title, path: deliverable.path, url: deliverable.url, kind: deliverable.kind };
|
|
3
3
|
}
|
|
4
|
+
function fileAttachment(ref, file) {
|
|
5
|
+
return { ref, title: file.label ?? (file.path ?? file.url ?? "").replace(/[\\/]+$/, "").split(/[\\/]/).pop() ?? "", path: file.path, url: file.url };
|
|
6
|
+
}
|
|
4
7
|
export function questionAttachments(question) {
|
|
5
|
-
return question.files.map((file, index) => ({
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
url: file.url,
|
|
10
|
-
}));
|
|
8
|
+
return question.files.map((file, index) => fileAttachment(`${question.id}.${index + 1}`, file));
|
|
9
|
+
}
|
|
10
|
+
export function ticketSourceAttachment(ticket) {
|
|
11
|
+
return ticket.source ? fileAttachment(`${ticket.id}.source`, ticket.source) : null;
|
|
11
12
|
}
|
|
12
13
|
// The attachment a reference names, or null when it names nothing.
|
|
13
14
|
export function findAttachment(dashboard, ref) {
|
|
@@ -16,6 +17,11 @@ export function findAttachment(dashboard, ref) {
|
|
|
16
17
|
const found = [...dashboard.questions.open, ...dashboard.questions.answered].find((candidate) => candidate.id === question[1]);
|
|
17
18
|
return (found && questionAttachments(found).find((attachment) => attachment.ref === ref)) ?? null;
|
|
18
19
|
}
|
|
20
|
+
const ticket = ref.match(/^(.+)\.source$/);
|
|
21
|
+
if (ticket) {
|
|
22
|
+
const found = dashboard.tickets.find((candidate) => candidate.id === ticket[1]);
|
|
23
|
+
return found ? ticketSourceAttachment(found) : null;
|
|
24
|
+
}
|
|
19
25
|
const deliverable = dashboard.deliverables.find((candidate) => candidate.id === ref);
|
|
20
26
|
return deliverable ? deliverableAttachment(deliverable) : null;
|
|
21
27
|
}
|
package/dist/shared/dashboard.js
CHANGED
|
@@ -55,17 +55,17 @@ function clock(now) {
|
|
|
55
55
|
export function isSettled(ticket) {
|
|
56
56
|
return ticket.status === "done" || ticket.status === "skipped";
|
|
57
57
|
}
|
|
58
|
+
// The events from the last `run.start` on; earlier runs are ignored.
|
|
59
|
+
export function latestRun(events) {
|
|
60
|
+
const start = events.findLastIndex(({ event }) => event.type === "run.start");
|
|
61
|
+
return events.slice(Math.max(start, 0));
|
|
62
|
+
}
|
|
58
63
|
// Folds the events of the latest run into what the panel shows at `now`, how
|
|
59
64
|
// long that stays true while the events don't change, and the ids new items get.
|
|
60
65
|
// Everything before the last `run.start` belongs to earlier runs and is ignored.
|
|
61
66
|
export function reduceProgress(parsed, now) {
|
|
62
67
|
const time = clock(now);
|
|
63
|
-
|
|
64
|
-
parsed.events.forEach(({ event }, index) => {
|
|
65
|
-
if (event.type === "run.start")
|
|
66
|
-
start = index;
|
|
67
|
-
});
|
|
68
|
-
const events = parsed.events.slice(start);
|
|
68
|
+
const events = latestRun(parsed.events);
|
|
69
69
|
const issues = [...parsed.issues];
|
|
70
70
|
let run = null;
|
|
71
71
|
const tickets = new Map();
|
|
@@ -96,6 +96,8 @@ export function reduceProgress(parsed, now) {
|
|
|
96
96
|
workingSince: event.status === "working" ? event.ts : undefined,
|
|
97
97
|
history: [{ at: event.ts, status: event.status ?? "not_started" }],
|
|
98
98
|
waitsFor: event.waitsFor,
|
|
99
|
+
source: event.source,
|
|
100
|
+
agentId: event.by,
|
|
99
101
|
});
|
|
100
102
|
break;
|
|
101
103
|
case "ticket.update": {
|
|
@@ -131,6 +133,10 @@ export function reduceProgress(parsed, now) {
|
|
|
131
133
|
ticket.note = event.note || undefined;
|
|
132
134
|
if (event.waitsFor !== undefined)
|
|
133
135
|
ticket.waitsFor = event.waitsFor || undefined;
|
|
136
|
+
if (event.source !== undefined)
|
|
137
|
+
ticket.source = event.source || undefined;
|
|
138
|
+
if (event.by)
|
|
139
|
+
ticket.agentId = event.by;
|
|
134
140
|
break;
|
|
135
141
|
}
|
|
136
142
|
case "ticket.remove":
|
|
@@ -143,8 +149,7 @@ export function reduceProgress(parsed, now) {
|
|
|
143
149
|
title: event.title,
|
|
144
150
|
question: event.question,
|
|
145
151
|
options: event.options ?? [],
|
|
146
|
-
|
|
147
|
-
waits: event.waits ?? false,
|
|
152
|
+
recommended: event.recommend ?? event.default ?? "",
|
|
148
153
|
background: event.background,
|
|
149
154
|
files: event.files ?? [],
|
|
150
155
|
raisedBy: event.raisedBy,
|
|
@@ -209,7 +214,7 @@ export function reduceProgress(parsed, now) {
|
|
|
209
214
|
issues.push({ line, reason: `unknown activity ${event.id}` });
|
|
210
215
|
break;
|
|
211
216
|
case "ticker.set":
|
|
212
|
-
ticker = { text: event.text, since: event.ts };
|
|
217
|
+
ticker = { text: event.text, since: event.ts, ...(event.by ? { by: event.by } : {}) };
|
|
213
218
|
break;
|
|
214
219
|
case "ticker.clear":
|
|
215
220
|
ticker = null;
|
package/dist/shared/events.js
CHANGED
|
@@ -12,7 +12,8 @@ export const TICKET_STATUSES = ["not_started", "working", "blocked", "done", "sk
|
|
|
12
12
|
export const ticketStatusSchema = z.enum(TICKET_STATUSES);
|
|
13
13
|
const text = z.string().trim().min(1);
|
|
14
14
|
const minutes = z.number().int().positive().max(100_000);
|
|
15
|
-
|
|
15
|
+
// `by` is the Paseo agent that wrote the event, absent when run outside Paseo.
|
|
16
|
+
const base = { v: z.literal(EVENT_VERSION), ts: z.iso.datetime(), by: text.optional() };
|
|
16
17
|
export const questionOptionSchema = z.object({
|
|
17
18
|
letter: z.string().regex(/^[A-Z]$/),
|
|
18
19
|
label: text,
|
|
@@ -43,6 +44,8 @@ export const eventSchema = z.discriminatedUnion("type", [
|
|
|
43
44
|
status: ticketStatusSchema.optional(),
|
|
44
45
|
// Another ticket in the run this one can't start until it's done.
|
|
45
46
|
waitsFor: text.optional(),
|
|
47
|
+
// Where the ticket is written up: a spec file or an issue link.
|
|
48
|
+
source: fileLinkSchema.optional(),
|
|
46
49
|
}),
|
|
47
50
|
z.object({
|
|
48
51
|
...base,
|
|
@@ -56,12 +59,16 @@ export const eventSchema = z.discriminatedUnion("type", [
|
|
|
56
59
|
note: z.string().trim().optional(),
|
|
57
60
|
// Empty clears it.
|
|
58
61
|
waitsFor: z.string().trim().optional(),
|
|
62
|
+
// Empty clears it.
|
|
63
|
+
source: z.union([fileLinkSchema, z.literal("")]).optional(),
|
|
59
64
|
}),
|
|
60
65
|
z.object({ ...base, type: z.literal("ticket.remove"), id: text }),
|
|
61
66
|
// A blocker that is not a ticket status, like a failing external service.
|
|
62
67
|
z.object({ ...base, type: z.literal("stuck.set"), id: text, reason: text, ticket: text.optional() }),
|
|
63
68
|
z.object({ ...base, type: z.literal("stuck.clear"), id: text }),
|
|
64
|
-
// The agent asks,
|
|
69
|
+
// The agent asks, recommends an option, and builds none of them before the
|
|
70
|
+
// answer: it works only on what the question doesn't affect. Older questions
|
|
71
|
+
// carry `default` (the option the agent carried on with) and `waits` instead.
|
|
65
72
|
z.object({
|
|
66
73
|
...base,
|
|
67
74
|
type: z.literal("question.ask"),
|
|
@@ -69,9 +76,9 @@ export const eventSchema = z.discriminatedUnion("type", [
|
|
|
69
76
|
title: text,
|
|
70
77
|
question: text,
|
|
71
78
|
options: z.array(questionOptionSchema).max(26).optional(),
|
|
72
|
-
//
|
|
73
|
-
|
|
74
|
-
|
|
79
|
+
// The agent's pick: an option letter, or a word such as "Looks right" when there are no options.
|
|
80
|
+
recommend: text.optional(),
|
|
81
|
+
default: text.optional(),
|
|
75
82
|
waits: z.boolean().optional(),
|
|
76
83
|
background: text.optional(),
|
|
77
84
|
files: z.array(fileLinkSchema).optional(),
|
|
@@ -86,7 +93,8 @@ export const eventSchema = z.discriminatedUnion("type", [
|
|
|
86
93
|
choice: text,
|
|
87
94
|
// The user's own words, quoted under the answer.
|
|
88
95
|
words: text.optional(),
|
|
89
|
-
|
|
96
|
+
// Older answers: true when the agent had to undo work built on its default.
|
|
97
|
+
changedCourse: z.boolean().optional(),
|
|
90
98
|
}),
|
|
91
99
|
// Adds detail to an open question; its reference and default stay.
|
|
92
100
|
z.object({
|
package/dist/shared/format.js
CHANGED
|
@@ -35,6 +35,12 @@ export function formatClock(iso) {
|
|
|
35
35
|
const minutes = String(date.getMinutes()).padStart(2, "0");
|
|
36
36
|
return `${MONTHS[date.getMonth()]} ${date.getDate()}, ${hours % 12 || 12}:${minutes} ${hours < 12 ? "AM" : "PM"}`;
|
|
37
37
|
}
|
|
38
|
+
// Just the time, "2:08 AM", when it falls on the same day as `nowMs`; the full
|
|
39
|
+
// clock time otherwise.
|
|
40
|
+
export function formatTimeOfDay(iso, nowMs) {
|
|
41
|
+
const full = formatClock(iso);
|
|
42
|
+
return new Date(iso).toDateString() === new Date(nowMs).toDateString() ? full.slice(full.indexOf(", ") + 2) : full;
|
|
43
|
+
}
|
|
38
44
|
export function formatAgo(iso, nowMs) {
|
|
39
45
|
const minutes = (nowMs - Date.parse(iso)) / 60_000;
|
|
40
46
|
return minutes < 1 ? "just now" : `${formatMinutes(minutes)} ago`;
|
package/dist/shared/show.js
CHANGED
|
@@ -41,11 +41,13 @@ export function dashboardText(dashboard, nowMs) {
|
|
|
41
41
|
const stage = ticket.status === "working" && ticket.stage ? `, ${ticket.stage} stage` : "";
|
|
42
42
|
const status = ticket.waitingFor ? `Waiting for ${ticket.waitingFor.id}` : STATUS[ticket.status];
|
|
43
43
|
lines.push(` ${ticket.id} ${status}${stage} ${formatEstimate(ticket.estimateMin)} ${ticket.title}`);
|
|
44
|
+
if (ticket.source)
|
|
45
|
+
lines.push(` Source: ${ticket.source.path ?? ticket.source.url}${ticket.source.label ? ` (${ticket.source.label})` : ""}`);
|
|
44
46
|
}
|
|
45
47
|
if (dashboard.questions.open.length) {
|
|
46
48
|
lines.push("", "Questions waiting");
|
|
47
49
|
for (const question of dashboard.questions.open) {
|
|
48
|
-
lines.push(` ${question.id} ${question.ticketId ? `[${question.ticketId}] ` : ""}(${question.title}): ${question.question}
|
|
50
|
+
lines.push(` ${question.id} ${question.ticketId ? `[${question.ticketId}] ` : ""}(${question.title}): ${question.question} You recommended ${question.recommended}`);
|
|
49
51
|
for (const option of question.options)
|
|
50
52
|
lines.push(` ${option.letter}) ${option.label}${option.consequence ? `: ${option.consequence}` : ""}`);
|
|
51
53
|
}
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stevecastaneda/paseo-progress",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@stevecastaneda/paseo-progress",
|
|
9
|
-
"version": "0.
|
|
9
|
+
"version": "0.3.0",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@getpaseo/client": "^0.9.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stevecastaneda/paseo-progress",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "A live progress dashboard per worktree for Paseo, drawn from a file agents write with the paseo-progress command.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Steve Castaneda",
|