@stevecastaneda/paseo-progress 0.2.1 → 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.
@@ -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, or --waits-for.");
189
- return { event, message: `Updated ${ticket.id}: ${changes.map(([key, value]) => `${key} ${value}`).join(", ")}` };
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>" ...] --default <letter or word> [--waits] [--background <text>] [--file <path or http(s) URL>[=<label>] ...] [--raised-by <text>] [--ticket <id>]`,
202
- 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.",
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
- default: { type: "string" },
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 fallback = required(stringOption(values.default), "--default");
248
+ let recommend = required(stringOption(values.recommend), "--recommend (the option you would pick)");
222
249
  if (options.length) {
223
- if (!/^[A-Za-z]$/.test(fallback) || !letters.includes(fallback.toUpperCase())) {
224
- throw new UsageError(`--default must be one of the option letters: ${letters.join(", ")}.`);
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
- fallback = fallback.toUpperCase();
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
- default: fallback,
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}). Default: ${fallback}${values.waits === true ? " (waiting for the answer)" : ""}`,
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 changedCourse = choice.toLowerCase() !== question.default.toLowerCase();
286
+ const asRecommended = choice.toLowerCase() === question.recommended.toLowerCase();
261
287
  return {
262
- event: { type: "question.answer", id: question.id, choice, words: stringOption(values.words), changedCourse },
263
- message: `Answered ${question.id}: ${choice}${changedCourse ? ` (differs from default ${question.default}: change course)` : " (same as the default)"}`,
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
- out(`Worktree: ${root}\n\n${dashboardText((await readProgress(store.directory, now())).dashboard, now().getTime())}`);
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
- return { event, result: message };
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, write,
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 { dashboard, nextIds } = reduceProgress(parseProgress(text), now());
47
- const { event, result } = write({ dashboard, nextIds });
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
- ref: `${question.id}.${index + 1}`,
7
- title: file.label ?? (file.path ?? file.url ?? "").replace(/[\\/]+$/, "").split(/[\\/]/).pop() ?? "",
8
- path: file.path,
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
  }
@@ -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
- let start = 0;
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
- default: event.default,
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;
@@ -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
- const base = { v: z.literal(EVENT_VERSION), ts: z.iso.datetime() };
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, picks `default`, and keeps working on it until answered.
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
- // An option letter, or a word such as "Your check" when there are no options.
73
- default: text,
74
- // True when the agent will not act on the default and waits for the answer.
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
- changedCourse: z.boolean(),
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({
@@ -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`;
@@ -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} Default ${question.default}${question.waits ? ", waiting for the answer" : ""}`);
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
  }
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@stevecastaneda/paseo-progress",
3
- "version": "0.2.1",
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.2.1",
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.2.1",
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",