@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.
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, or --waits-for.");
205
- return { event, message: `Updated ${ticket.id}: ${changes.map(([key, value]) => `${key} ${value}`).join(", ")}` };
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>" ...] --default <letter or word> [--waits] [--background <text>] [--file <path or http(s) URL>[=<label>] ...] [--raised-by <text>] [--ticket <id>]`,
218
- 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.",
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
- default: { type: "string" },
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 fallback = required(stringOption(values.default), "--default");
264
+ let recommend = required(stringOption(values.recommend), "--recommend (the option you would pick)");
237
265
  if (options.length) {
238
- if (!/^[A-Za-z]$/.test(fallback) || !letters.includes(fallback.toUpperCase())) {
239
- throw new UsageError(`--default must be one of the option letters: ${letters.join(", ")}.`);
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
- fallback = fallback.toUpperCase();
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
- default: fallback,
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}). Default: ${fallback}${values.waits === true ? " (waiting for the answer)" : ""}`,
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 changedCourse = choice.toLowerCase() !== question.default.toLowerCase();
301
+ const asRecommended = choice.toLowerCase() === question.recommended.toLowerCase();
275
302
  return {
276
- event: { type: "question.answer", id: question.id, choice, words: stringOption(values.words), changedCourse },
277
- message: `Answered ${question.id}: ${choice}${changedCourse ? ` (differs from default ${question.default}: change course)` : " (same as the default)"}`,
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
- out(`Worktree: ${root}\n\n${dashboardText((await readProgress(store.directory, now())).dashboard, now().getTime())}`);
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
- return { event, result: message };
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);
@@ -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
- write: (state: { dashboard: Dashboard; nextIds: NextIds }) => { event: EventDraft; result: T },
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 { dashboard, nextIds } = reduceProgress(parseProgress(text), now());
64
- const { event, result } = write({ dashboard, nextIds });
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("; "));
@@ -1,14 +1,16 @@
1
- // Anything the user can open from the dashboard: a deliverable ("D3") or a
2
- // file on a question ("Q7.2", counting from 1). The panel lists them and the
3
- // server finds them by the same reference, so both read it from here.
4
- import type { Dashboard, Deliverable, Question } from "./dashboard.ts";
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. Question files don't have one.
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
- ref: `${question.id}.${index + 1}`,
22
- title: file.label ?? (file.path ?? file.url ?? "").replace(/[\\/]+$/, "").split(/[\\/]/).pop() ?? "",
23
- path: file.path,
24
- url: file.url,
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
  }
@@ -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
- default: string;
43
- waits: boolean;
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
- answer?: { choice: string; words?: string; changedCourse: boolean; at: string };
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
- ticker: { text: string; since: string } | null;
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
- let start = 0;
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
- default: event.default,
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
- const base = { v: z.literal(EVENT_VERSION), ts: z.iso.datetime() };
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, picks `default`, and keeps working on it until answered.
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
- // An option letter, or a word such as "Your check" when there are no options.
84
- default: text,
85
- // True when the agent will not act on the default and waits for the answer.
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
- changedCourse: z.boolean(),
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} Default ${question.default}${question.waits ? ", waiting for the answer" : ""}`);
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 without stopping
42
+ ## 3. Ask questions, and work around them
41
43
 
42
- When a decision belongs to the user, ask it and keep working:
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
- --default <letter> --ticket T03 --raised-by "Ticket 03 design review" \
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
- - The default is the safest choice. Carry on with it straight away.
54
- - Add `--waits` when acting on any choice needs the user's yes: purchases, production, live vendor calls, commits. Then work on something else until they answer.
55
- - When the user replies ("Q7 A", or in their own words), run `question answer Q7 A --words "<their words>"`. The command reports whether the answer differs from the default; when it does, change course before anything else.
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.