@stevecastaneda/paseo-progress 1.3.1 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/OVERVIEW.md +1 -1
  2. package/README.md +28 -4
  3. package/client/attachments.tsx +9 -6
  4. package/client/deliverables.tsx +6 -4
  5. package/client/dialog-stack.tsx +3 -0
  6. package/client/history.tsx +23 -18
  7. package/client/i18n.ts +86 -0
  8. package/client/language.tsx +23 -0
  9. package/client/launcher.tsx +22 -17
  10. package/client/locales/en.ts +207 -0
  11. package/client/locales/zh-CN.ts +207 -0
  12. package/client/messages.ts +189 -0
  13. package/client/panel.tsx +77 -47
  14. package/client/pill-popover.tsx +8 -5
  15. package/client/pill-state.ts +4 -3
  16. package/client/pills.tsx +25 -11
  17. package/client/preview.tsx +18 -13
  18. package/client/questions.tsx +42 -18
  19. package/client/settings.tsx +58 -0
  20. package/client/show-more.tsx +4 -2
  21. package/client/spinner.tsx +7 -3
  22. package/client/status-badge.tsx +3 -9
  23. package/client/surfaces.ts +20 -8
  24. package/client/ticket-dialog.tsx +37 -30
  25. package/client/video.tsx +3 -1
  26. package/client/whats-new.tsx +125 -0
  27. package/client/when.tsx +5 -3
  28. package/client/write-up.tsx +8 -5
  29. package/dist/server/cli.js +85 -20
  30. package/images/1-panel.png +0 -0
  31. package/images/3-question.png +0 -0
  32. package/images/5-settings.png +0 -0
  33. package/index.client.tsx +13 -1
  34. package/index.server.ts +12 -2
  35. package/package.json +1 -1
  36. package/paseo-plugin.json +2 -1
  37. package/server/cli.ts +28 -11
  38. package/server/question-mode.ts +61 -0
  39. package/server/store.ts +21 -4
  40. package/server/whats-new.ts +127 -0
  41. package/shared/dashboard.ts +9 -2
  42. package/shared/events.ts +9 -4
  43. package/shared/question-modes.ts +45 -0
  44. package/shared/rpc.ts +18 -0
  45. package/shared/settings.ts +20 -0
  46. package/shared/show.ts +3 -1
  47. package/shared/ticket-story.ts +12 -10
  48. package/shared/whats-new.ts +16 -0
  49. package/shared/write-up.ts +9 -8
  50. package/skills/paseo-progress/SKILL.md +26 -68
@@ -19678,6 +19678,31 @@ function date4(params) {
19678
19678
  return _coercedDate(ZodDate, params);
19679
19679
  }
19680
19680
 
19681
+ // shared/question-modes.ts
19682
+ var QUESTION_MODES = ["stop", "work-around", "keep-building"];
19683
+ var DEFAULT_QUESTION_MODE = "work-around";
19684
+ var HOLD = "Build no option before the answer, not even your recommendation. Work on everything the question doesn't affect. If nothing else is left, end your turn, tell the user in chat which questions you're waiting on, and leave the run open.";
19685
+ var QUESTION_MODE_RULES = {
19686
+ stop: {
19687
+ label: "Stop",
19688
+ hint: "Agents stop all work when they ask, until you answer.",
19689
+ askRule: () => "Ask any other questions you already have, then stop all work and resume only after an answer. End your turn now, tell the user in chat which questions you're waiting on, and leave the run open.",
19690
+ state: () => "stopped"
19691
+ },
19692
+ "work-around": {
19693
+ label: "Work around",
19694
+ hint: "Agents hold what your answer affects and keep working on the rest.",
19695
+ askRule: () => HOLD,
19696
+ state: () => "waiting"
19697
+ },
19698
+ "keep-building": {
19699
+ label: "Keep building",
19700
+ hint: "Agents build their recommended option while you decide, and change course if you pick another. Big questions still wait.",
19701
+ askRule: ({ recommend, big }) => big ? `This question is big, so hold it like Work around. ${HOLD}` : `Build ${recommend} now and keep working. If the user picks another option, undo the work built on ${recommend} and build their choice first. If the rest of the work is done while it's still open, leave it open and tell the user in chat; don't withdraw it.`,
19702
+ state: (big) => big ? "waiting" : "building"
19703
+ }
19704
+ };
19705
+
19681
19706
  // shared/events.ts
19682
19707
  var PROGRESS_FILE_NAME = "progress.jsonl";
19683
19708
  var PANEL_OPENED_NAME = "progress-panel-opened";
@@ -19737,9 +19762,11 @@ var eventSchema = external_exports.discriminatedUnion("type", [
19737
19762
  // A blocker that is not a ticket status, like a failing external service.
19738
19763
  external_exports.object({ ...base, type: external_exports.literal("stuck.set"), id: text, reason: text, ticket: text.optional() }),
19739
19764
  external_exports.object({ ...base, type: external_exports.literal("stuck.clear"), id: text }),
19740
- // The agent asks, recommends an option, and builds none of them before the
19741
- // answer: it works only on what the question doesn't affect. Older questions
19742
- // carry `default` (the option the agent carried on with) and `waits` instead.
19765
+ // The agent asks and recommends an option. `mode` is the question mode when
19766
+ // asked; without it (0.3.0 to 1.3.x) the agent built nothing before the
19767
+ // answer, like "work-around". `big` says why the question holds even in
19768
+ // "keep-building" mode. Questions from before 0.3.0 carry `default`, the
19769
+ // option the agent carried on with unless it `waits`.
19743
19770
  external_exports.object({
19744
19771
  ...base,
19745
19772
  type: external_exports.literal("question.ask"),
@@ -19749,6 +19776,8 @@ var eventSchema = external_exports.discriminatedUnion("type", [
19749
19776
  options: external_exports.array(questionOptionSchema).max(26).optional(),
19750
19777
  // The agent's pick: an option letter, or a word such as "Looks right" when there are no options.
19751
19778
  recommend: text.optional(),
19779
+ mode: external_exports.enum(QUESTION_MODES).optional(),
19780
+ big: text.optional(),
19752
19781
  default: text.optional(),
19753
19782
  waits: external_exports.boolean().optional(),
19754
19783
  background: text.optional(),
@@ -19764,7 +19793,7 @@ var eventSchema = external_exports.discriminatedUnion("type", [
19764
19793
  choice: text,
19765
19794
  // The user's own words, quoted under the answer.
19766
19795
  words: text.optional(),
19767
- // Older answers: true when the agent had to undo work built on its default.
19796
+ // True when the agent had to undo work built on another option.
19768
19797
  changedCourse: external_exports.boolean().optional()
19769
19798
  }),
19770
19799
  // Adds detail to an open question; its reference and default stay.
@@ -19918,6 +19947,8 @@ function reduceProgress(parsed, now) {
19918
19947
  question: event.question,
19919
19948
  options: event.options ?? [],
19920
19949
  recommended: event.recommend ?? event.default ?? "",
19950
+ state: event.mode ? QUESTION_MODE_RULES[event.mode].state(Boolean(event.big)) : event.default !== void 0 && !event.waits ? "building" : "waiting",
19951
+ big: event.big,
19921
19952
  background: event.background,
19922
19953
  files: event.files ?? [],
19923
19954
  raisedBy: event.raisedBy,
@@ -20351,11 +20382,11 @@ async function installLauncher(path, platform = process.platform) {
20351
20382
 
20352
20383
  // server/store.ts
20353
20384
  import { execFile } from "node:child_process";
20354
- import { createHash } from "node:crypto";
20385
+ import { createHash, randomUUID } from "node:crypto";
20355
20386
  import { realpathSync } from "node:fs";
20356
- import { appendFile as appendFile2, mkdir as mkdir4, readdir, readFile as readFile3, rm as rm2, rmdir, stat as stat3, writeFile as writeFile3 } from "node:fs/promises";
20387
+ import { appendFile as appendFile2, mkdir as mkdir4, readdir, readFile as readFile3, rename, rm as rm2, rmdir, stat as stat3, writeFile as writeFile3 } from "node:fs/promises";
20357
20388
  import { homedir } from "node:os";
20358
- import { basename as basename2, join as join4, normalize, resolve as resolve2 } from "node:path";
20389
+ import { basename as basename2, dirname as dirname3, join as join4, normalize, resolve as resolve2 } from "node:path";
20359
20390
  import { promisify } from "node:util";
20360
20391
  var LOCK_NAME2 = `${PROGRESS_FILE_NAME}.lock`;
20361
20392
  var SAVE_RUNS_KEY = "paseo-progress.saveRuns";
@@ -20377,6 +20408,9 @@ async function git(cwd, args) {
20377
20408
  async function exists2(path) {
20378
20409
  return Boolean(await stat3(path).catch(() => null));
20379
20410
  }
20411
+ function stateRoot(home = homedir()) {
20412
+ return join4(home, ".local", "state", "paseo-progress");
20413
+ }
20380
20414
  function outsideDirectory(root, home = homedir()) {
20381
20415
  let real = root;
20382
20416
  try {
@@ -20384,7 +20418,7 @@ function outsideDirectory(root, home = homedir()) {
20384
20418
  } catch {
20385
20419
  }
20386
20420
  const hash2 = createHash("sha1").update(real).digest("hex").slice(0, 8);
20387
- return join4(home, ".local", "state", "paseo-progress", `${basename2(real)}-${hash2}`);
20421
+ return join4(stateRoot(home), `${basename2(real)}-${hash2}`);
20388
20422
  }
20389
20423
  var trackedLegacy = /* @__PURE__ */ new Map();
20390
20424
  async function legacyTracked(root, mtimeMs) {
@@ -20474,6 +20508,22 @@ async function saveRunCopy(root, text2) {
20474
20508
  `);
20475
20509
  }
20476
20510
 
20511
+ // server/question-mode.ts
20512
+ import { readFile as readFile4, rm as rm3 } from "node:fs/promises";
20513
+ import { homedir as homedir2 } from "node:os";
20514
+ import { join as join5 } from "node:path";
20515
+ var mirrorSchema = external_exports.object({ questionMode: external_exports.enum(QUESTION_MODES) });
20516
+ function questionModePath(home = homedir2()) {
20517
+ return join5(stateRoot(home), "settings.json");
20518
+ }
20519
+ async function readQuestionMode(home = homedir2()) {
20520
+ try {
20521
+ return mirrorSchema.parse(JSON.parse(await readFile4(questionModePath(home), "utf8"))).questionMode;
20522
+ } catch {
20523
+ return DEFAULT_QUESTION_MODE;
20524
+ }
20525
+ }
20526
+
20477
20527
  // shared/format.ts
20478
20528
  var MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
20479
20529
  function formatMinutes(totalMin) {
@@ -20504,6 +20554,7 @@ function minutesSince(iso, nowMs) {
20504
20554
 
20505
20555
  // shared/show.ts
20506
20556
  var STATUS = { not_started: "Not started", working: "Working", blocked: "Blocked", done: "Done", skipped: "Skipped" };
20557
+ var QUESTION_STATE = { stopped: "agent stopped until the answer", waiting: "waiting for the answer", building: "building with it" };
20507
20558
  function dashboardText(dashboard, nowMs) {
20508
20559
  if (!dashboard.run && !dashboard.tickets.length) return 'No run yet. Start one with: paseo-progress start "<title>"';
20509
20560
  const lines = [];
@@ -20541,7 +20592,7 @@ function dashboardText(dashboard, nowMs) {
20541
20592
  if (dashboard.questions.open.length) {
20542
20593
  lines.push("", "Questions waiting");
20543
20594
  for (const question of dashboard.questions.open) {
20544
- lines.push(` ${question.id} ${question.ticketId ? `[${question.ticketId}] ` : ""}(${question.title}): ${question.question} You recommended ${question.recommended}`);
20595
+ lines.push(` ${question.id} ${question.ticketId ? `[${question.ticketId}] ` : ""}(${question.title}): ${question.question} You recommended ${question.recommended}, ${QUESTION_STATE[question.state]}${question.big ? `. Big: ${question.big}` : ""}`);
20545
20596
  for (const option of question.options) lines.push(` ${option.letter}) ${option.label}${option.consequence ? `: ${option.consequence}` : ""}`);
20546
20597
  }
20547
20598
  }
@@ -20582,10 +20633,13 @@ function imageMimeType(path) {
20582
20633
  }
20583
20634
 
20584
20635
  // server/cli.ts
20585
- import { homedir as homedir2 } from "node:os";
20636
+ import { homedir as homedir3 } from "node:os";
20586
20637
  var UsageError = class extends Error {
20587
20638
  };
20588
20639
  var USAGE_NAME = "paseo-progress";
20640
+ function modeLine(mode) {
20641
+ return `Question mode: ${QUESTION_MODE_RULES[mode].label}, set by the user in Paseo's Settings. For each question, do what "question ask" prints.`;
20642
+ }
20589
20643
  function required2(value, name) {
20590
20644
  if (!value?.trim()) throw new UsageError(`Missing ${name}.`);
20591
20645
  return value.trim();
@@ -20691,11 +20745,12 @@ var commands = {
20691
20745
  usage: `start "<title>" [--subtitle <text>] [--item-label <word>]`,
20692
20746
  summary: "Start a new run. Earlier runs stay in the file but leave the dashboard.",
20693
20747
  options: { subtitle: { type: "string" }, "item-label": { type: "string" } },
20694
- run({ positionals, values }) {
20748
+ run({ positionals, values, mode }) {
20695
20749
  const title = required2(positionals[0], "run title");
20696
20750
  return {
20697
20751
  event: { type: "run.start", title, subtitle: stringOption(values.subtitle), itemLabel: stringOption(values["item-label"]) },
20698
- message: `Started run: ${title}`
20752
+ message: `Started run: ${title}
20753
+ ${modeLine(mode)}`
20699
20754
  };
20700
20755
  }
20701
20756
  },
@@ -20766,17 +20821,18 @@ var commands = {
20766
20821
  }
20767
20822
  },
20768
20823
  "question ask": {
20769
- 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>]`,
20770
- 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.",
20824
+ usage: `question ask "<short title>" "<question>" [--option "A=<label> | <consequence>" ...] --recommend <letter or word> [--big "<why, in a few plain words>"] [--background <text>] [--file <path or http(s) URL>[=<label>] ...] [--raised-by <text>] [--ticket <id>]`,
20825
+ summary: "Ask the user a question. Prints its reference (Q1, Q2, ...) and what to do until the answer, which depends on the question mode the user picks in Paseo's Settings: Stop (stop all work until an answer), Work around (build nothing the question affects, work on the rest), or Keep building (build your recommendation now, change course if the user picks another). --recommend is the option you would pick. Add --big with the reason when the question is big; it only changes what you do in Keep building, where a big question holds like Work around. A question is big when one of these holds, and only then: (1) building your recommendation before the answer would mean something leaves this computer: pushing, publishing, deploying, changing live data, sending messages, spending money; (2) the answer sets something people or programs outside this repo will rely on: a saved data format, a database schema, a public API, command or setting name, or removing or renaming something people already use; (3) most of the tickets or steps still ahead would be built on the answer. Every other question is small, large code changes included. When you can't name the test it meets, it's small. Name the test in the --big reason.",
20771
20826
  options: {
20772
20827
  option: { type: "string", multiple: true },
20773
20828
  recommend: { type: "string" },
20829
+ big: { type: "string" },
20774
20830
  background: { type: "string" },
20775
20831
  file: { type: "string", multiple: true },
20776
20832
  "raised-by": { type: "string" },
20777
20833
  ticket: { type: "string" }
20778
20834
  },
20779
- run({ positionals, values, state, cwd, root }) {
20835
+ run({ positionals, values, state, cwd, root, mode }) {
20780
20836
  const title = required2(positionals[0], "short title");
20781
20837
  const ticket = values.ticket === void 0 ? void 0 : existingTicket(state, stringOption(values.ticket));
20782
20838
  const question = required2(positionals[1], "question");
@@ -20792,6 +20848,9 @@ var commands = {
20792
20848
  recommend = recommend.toUpperCase();
20793
20849
  }
20794
20850
  const id = state.nextIds.question;
20851
+ const big = values.big === void 0 ? void 0 : required2(stringOption(values.big), "--big reason (why the question is big)");
20852
+ const rules = QUESTION_MODE_RULES[mode];
20853
+ const rule = `Mode: ${rules.label}. ${rules.askRule({ recommend, big: Boolean(big) })}`;
20795
20854
  return {
20796
20855
  event: {
20797
20856
  type: "question.ask",
@@ -20800,12 +20859,14 @@ var commands = {
20800
20859
  question,
20801
20860
  options: options.length ? options : void 0,
20802
20861
  recommend,
20862
+ mode,
20863
+ big,
20803
20864
  background: stringOption(values.background),
20804
20865
  files: listOption(values.file).length ? listOption(values.file).map((raw) => parseFile(raw, cwd, root)) : void 0,
20805
20866
  raisedBy: stringOption(values["raised-by"]),
20806
20867
  ticket: ticket?.id
20807
20868
  },
20808
- 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.`
20869
+ message: `Asked ${id} (${title}). You recommend ${recommend}. ${rule}`
20809
20870
  };
20810
20871
  }
20811
20872
  },
@@ -20822,9 +20883,11 @@ var commands = {
20822
20883
  if (letters.length && !letters.includes(choice)) throw new UsageError(`${question.id} has options ${letters.join(", ")}, not ${choice}.`);
20823
20884
  }
20824
20885
  const asRecommended = choice.toLowerCase() === question.recommended.toLowerCase();
20886
+ const built = question.state === "building";
20887
+ const next = !built ? "Build it now." : asRecommended ? "Carry on with it." : `Undo the work built on your recommendation and build ${choice} before anything else.`;
20825
20888
  return {
20826
- event: { type: "question.answer", id: question.id, choice, words: stringOption(values.words) },
20827
- message: `Answered ${question.id}: ${choice}${asRecommended ? " (as you recommended)" : ` (not your recommendation, ${question.recommended})`}. Build it now.`
20889
+ event: { type: "question.answer", id: question.id, choice, words: stringOption(values.words), changedCourse: built && !asRecommended ? true : void 0 },
20890
+ message: `Answered ${question.id}: ${choice}${asRecommended ? " (as you recommended)" : ` (not your recommendation, ${question.recommended})`}. ${next}`
20828
20891
  };
20829
20892
  }
20830
20893
  },
@@ -21016,11 +21079,12 @@ ${INSTALL_SUMMARY}`);
21016
21079
  const { dashboard } = await readProgress(store.directory, now());
21017
21080
  const added = addedByOthersText(addedByOthers(await readLatestRun(store.directory), dashboard, options.agentId), dashboard);
21018
21081
  out(`Worktree: ${root}
21082
+ ${modeLine(await readQuestionMode())}
21019
21083
 
21020
21084
  ${dashboardText(dashboard, now().getTime())}${added}`);
21021
21085
  return 0;
21022
21086
  }
21023
- const target = resolve3(options.cwd, (argv[1] ?? DEFAULT_LAUNCHER).replace(/^~(?=$|\/)/, homedir2()));
21087
+ const target = resolve3(options.cwd, (argv[1] ?? DEFAULT_LAUNCHER).replace(/^~(?=$|\/)/, homedir3()));
21024
21088
  out(await installLauncher(target) ? `Installed the launcher at ${target}` : `The launcher at ${target} is already current`);
21025
21089
  return 0;
21026
21090
  }
@@ -21048,12 +21112,13 @@ ${command.summary}`);
21048
21112
  }
21049
21113
  const root = findRoot(options.cwd);
21050
21114
  const store = await findStore(root);
21115
+ const mode = await readQuestionMode();
21051
21116
  const message = await appendProgress(store.directory, now, (state) => {
21052
21117
  const finished = state.dashboard.run?.finished;
21053
21118
  if (finished && command !== commands.start) {
21054
21119
  throw new UsageError(`The run "${state.dashboard.run.title}" is finished. Start new work with: ${USAGE_NAME} start "<title>"`);
21055
21120
  }
21056
- const { event, message: message2 } = command.run({ positionals: parsed.positionals, values: parsed.values, state, cwd: options.cwd, root });
21121
+ const { event, message: message2 } = command.run({ positionals: parsed.positionals, values: parsed.values, state, cwd: options.cwd, root, mode });
21057
21122
  const added = command === commands.start ? "" : addedByOthersText(addedByOthers(state.events, state.dashboard, options.agentId), state.dashboard);
21058
21123
  return { event: { ...event, by: options.agentId }, result: `${message2}${added}` };
21059
21124
  }, (text2) => saveRunCopy(root, text2));
Binary file
Binary file
Binary file
package/index.client.tsx CHANGED
@@ -1,6 +1,10 @@
1
1
  import type { PluginClientContext } from "@getpaseo/plugin/client";
2
2
  import { ProgressPanel } from "./client/panel";
3
3
  import { contributePills, progressPanel } from "./client/pills";
4
+ import { QuestionSettings } from "./client/settings";
5
+ import { ProgressLanguageProvider } from "./client/language";
6
+
7
+ const SETTINGS_ID = "settings";
4
8
 
5
9
  export default function contribute(client: PluginClientContext) {
6
10
  const stopPills = contributePills(client);
@@ -11,7 +15,7 @@ export default function contribute(client: PluginClientContext) {
11
15
  icon: "ListChecks",
12
16
  context: "workspace",
13
17
  locations: [panel.location],
14
- Component: ProgressPanel,
18
+ Component: (props) => <ProgressLanguageProvider><ProgressPanel {...props} openSettings={() => client.openSettings(SETTINGS_ID)} /></ProgressLanguageProvider>,
15
19
  });
16
20
  const stopCommand = client.addCommandCenterItem({
17
21
  id: "open-progress",
@@ -25,7 +29,15 @@ export default function contribute(client: PluginClientContext) {
25
29
  },
26
30
  });
27
31
 
32
+ const stopSettings = client.addSettingsScreen({
33
+ id: SETTINGS_ID,
34
+ title: "Settings",
35
+ icon: "Settings",
36
+ Component: (props) => <ProgressLanguageProvider><QuestionSettings {...props} /></ProgressLanguageProvider>,
37
+ });
38
+
28
39
  return () => {
40
+ void stopSettings();
29
41
  void stopPanel();
30
42
  void stopCommand();
31
43
  stopPills();
package/index.server.ts CHANGED
@@ -2,8 +2,11 @@ import type { PluginServerContext } from "@getpaseo/plugin/server";
2
2
  import { handleGetAttention, handleGetDashboard, handleListFolders, handleMarkPanelOpened, handleSetHistoryFolder } from "./server/dashboard";
3
3
  import { handleInstallLauncher, handleLauncherStatus } from "./server/launcher";
4
4
  import { handleCanOpenOnHost, handleOpenDeliverable, handlePreviewDeliverable, handleReadDeliverablePiece } from "./server/open";
5
+ import { mirrorQuestionMode } from "./server/question-mode";
5
6
  import { handleInstallSkill, handleSkillStatus } from "./server/skill";
6
- import { canOpenOnHost, getAttention, getDashboard, getLauncherStatus, getSkillStatus, installLauncher, installSkill, listFolders, markPanelOpened, openDeliverable, previewDeliverable, readDeliverablePiece, setHistoryFolder } from "./shared/rpc";
7
+ import { handleDismissWhatsNew, handleGetWhatsNew } from "./server/whats-new";
8
+ import { canOpenOnHost, dismissWhatsNew, getAttention, getDashboard, getLauncherStatus, getSkillStatus, getWhatsNew, installLauncher, installSkill, listFolders, markPanelOpened, openDeliverable, previewDeliverable, readDeliverablePiece, setHistoryFolder } from "./shared/rpc";
9
+ import { displaySettings, questionSettings } from "./shared/settings";
7
10
 
8
11
  export default function contribute(server: PluginServerContext) {
9
12
  server.handle(getDashboard, handleGetDashboard);
@@ -19,5 +22,12 @@ export default function contribute(server: PluginServerContext) {
19
22
  server.handle(installSkill, handleInstallSkill);
20
23
  server.handle(listFolders, handleListFolders);
21
24
  server.handle(setHistoryFolder, handleSetHistoryFolder);
22
- return () => {};
25
+ server.handle(getWhatsNew, handleGetWhatsNew);
26
+ server.handle(dismissWhatsNew, handleDismissWhatsNew);
27
+ // Settles a new install before any panel opens. Opening one makes a
28
+ // worktree folder, which would look like an earlier version's.
29
+ void handleGetWhatsNew().catch(() => {});
30
+ server.registerSettings(displaySettings);
31
+ const mirror = mirrorQuestionMode(server.registerSettings(questionSettings));
32
+ return mirror.stop;
23
33
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stevecastaneda/paseo-progress",
3
- "version": "1.3.1",
3
+ "version": "1.5.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",
package/paseo-plugin.json CHANGED
@@ -5,7 +5,8 @@
5
5
  "images/1-panel.png",
6
6
  "images/2-ticket.png",
7
7
  "images/3-question.png",
8
- "images/4-pill.png"
8
+ "images/4-pill.png",
9
+ "images/5-settings.png"
9
10
  ],
10
11
  "requirements": {
11
12
  "paseo": ">=0.11.0"
package/server/cli.ts CHANGED
@@ -6,10 +6,12 @@ import { fileURLToPath } from "node:url";
6
6
  import { parseArgs } from "node:util";
7
7
  import { isSettled, type Dashboard, type NextIds, type Ticket } from "../shared/dashboard.ts";
8
8
  import { DELIVERABLE_KINDS, TICKET_STATUSES, type FileLink, type ProgressEvent } from "../shared/events.ts";
9
+ import { QUESTION_MODE_RULES, type QuestionMode } from "../shared/question-modes.ts";
9
10
  import { appendProgress, InvalidEvent, readLatestRun, readProgress, type EventDraft } from "./progress-file.ts";
10
11
  import { findRoot, outside } from "./paths.ts";
11
12
  import { installLauncher } from "./launcher.ts";
12
13
  import { findStore, saveRunCopy } from "./store.ts";
14
+ import { readQuestionMode } from "./question-mode.ts";
13
15
  import { dashboardText } from "../shared/show.ts";
14
16
  import { formatMinutes } from "../shared/format.ts";
15
17
  import { imageMimeType } from "../shared/attachments.ts";
@@ -34,11 +36,15 @@ interface Command {
34
36
  usage: string;
35
37
  summary: string;
36
38
  options?: Record<string, { type: "string" | "boolean"; multiple?: boolean }>;
37
- run(args: { positionals: string[]; values: Record<string, string | boolean | Array<string | boolean> | undefined>; state: State; cwd: string; root: string }): { event: EventDraft; message: string };
39
+ run(args: { positionals: string[]; values: Record<string, string | boolean | Array<string | boolean> | undefined>; state: State; cwd: string; root: string; mode: QuestionMode }): { event: EventDraft; message: string };
38
40
  }
39
41
 
40
42
  const USAGE_NAME = "paseo-progress";
41
43
 
44
+ function modeLine(mode: QuestionMode): string {
45
+ return `Question mode: ${QUESTION_MODE_RULES[mode].label}, set by the user in Paseo's Settings. For each question, do what "question ask" prints.`;
46
+ }
47
+
42
48
  function required(value: string | undefined, name: string): string {
43
49
  if (!value?.trim()) throw new UsageError(`Missing ${name}.`);
44
50
  return value.trim();
@@ -168,11 +174,11 @@ const commands: Record<string, Command> = {
168
174
  usage: `start "<title>" [--subtitle <text>] [--item-label <word>]`,
169
175
  summary: "Start a new run. Earlier runs stay in the file but leave the dashboard.",
170
176
  options: { subtitle: { type: "string" }, "item-label": { type: "string" } },
171
- run({ positionals, values }) {
177
+ run({ positionals, values, mode }) {
172
178
  const title = required(positionals[0], "run title");
173
179
  return {
174
180
  event: { type: "run.start", title, subtitle: stringOption(values.subtitle), itemLabel: stringOption(values["item-label"]) },
175
- message: `Started run: ${title}`,
181
+ message: `Started run: ${title}\n${modeLine(mode)}`,
176
182
  };
177
183
  },
178
184
  },
@@ -244,17 +250,18 @@ const commands: Record<string, Command> = {
244
250
  },
245
251
  },
246
252
  "question ask": {
247
- 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>]`,
248
- 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.",
253
+ usage: `question ask "<short title>" "<question>" [--option "A=<label> | <consequence>" ...] --recommend <letter or word> [--big "<why, in a few plain words>"] [--background <text>] [--file <path or http(s) URL>[=<label>] ...] [--raised-by <text>] [--ticket <id>]`,
254
+ summary: "Ask the user a question. Prints its reference (Q1, Q2, ...) and what to do until the answer, which depends on the question mode the user picks in Paseo's Settings: Stop (stop all work until an answer), Work around (build nothing the question affects, work on the rest), or Keep building (build your recommendation now, change course if the user picks another). --recommend is the option you would pick. Add --big with the reason when the question is big; it only changes what you do in Keep building, where a big question holds like Work around. A question is big when one of these holds, and only then: (1) building your recommendation before the answer would mean something leaves this computer: pushing, publishing, deploying, changing live data, sending messages, spending money; (2) the answer sets something people or programs outside this repo will rely on: a saved data format, a database schema, a public API, command or setting name, or removing or renaming something people already use; (3) most of the tickets or steps still ahead would be built on the answer. Every other question is small, large code changes included. When you can't name the test it meets, it's small. Name the test in the --big reason.",
249
255
  options: {
250
256
  option: { type: "string", multiple: true },
251
257
  recommend: { type: "string" },
258
+ big: { type: "string" },
252
259
  background: { type: "string" },
253
260
  file: { type: "string", multiple: true },
254
261
  "raised-by": { type: "string" },
255
262
  ticket: { type: "string" },
256
263
  },
257
- run({ positionals, values, state, cwd, root }) {
264
+ run({ positionals, values, state, cwd, root, mode }) {
258
265
  const title = required(positionals[0], "short title");
259
266
  const ticket = values.ticket === undefined ? undefined : existingTicket(state, stringOption(values.ticket));
260
267
  const question = required(positionals[1], "question");
@@ -270,6 +277,9 @@ const commands: Record<string, Command> = {
270
277
  recommend = recommend.toUpperCase();
271
278
  }
272
279
  const id = state.nextIds.question;
280
+ const big = values.big === undefined ? undefined : required(stringOption(values.big), "--big reason (why the question is big)");
281
+ const rules = QUESTION_MODE_RULES[mode];
282
+ const rule = `Mode: ${rules.label}. ${rules.askRule({ recommend, big: Boolean(big) })}`;
273
283
  return {
274
284
  event: {
275
285
  type: "question.ask",
@@ -278,12 +288,14 @@ const commands: Record<string, Command> = {
278
288
  question,
279
289
  options: options.length ? options : undefined,
280
290
  recommend,
291
+ mode,
292
+ big,
281
293
  background: stringOption(values.background),
282
294
  files: listOption(values.file).length ? listOption(values.file).map((raw) => parseFile(raw, cwd, root)) : undefined,
283
295
  raisedBy: stringOption(values["raised-by"]),
284
296
  ticket: ticket?.id,
285
297
  },
286
- 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.`,
298
+ message: `Asked ${id} (${title}). You recommend ${recommend}. ${rule}`,
287
299
  };
288
300
  },
289
301
  },
@@ -300,9 +312,13 @@ const commands: Record<string, Command> = {
300
312
  if (letters.length && !letters.includes(choice)) throw new UsageError(`${question.id} has options ${letters.join(", ")}, not ${choice}.`);
301
313
  }
302
314
  const asRecommended = choice.toLowerCase() === question.recommended.toLowerCase();
315
+ const built = question.state === "building";
316
+ const next = !built ? "Build it now."
317
+ : asRecommended ? "Carry on with it."
318
+ : `Undo the work built on your recommendation and build ${choice} before anything else.`;
303
319
  return {
304
- event: { type: "question.answer", id: question.id, choice, words: stringOption(values.words) },
305
- message: `Answered ${question.id}: ${choice}${asRecommended ? " (as you recommended)" : ` (not your recommendation, ${question.recommended})`}. Build it now.`,
320
+ event: { type: "question.answer", id: question.id, choice, words: stringOption(values.words), changedCourse: built && !asRecommended ? true : undefined },
321
+ message: `Answered ${question.id}: ${choice}${asRecommended ? " (as you recommended)" : ` (not your recommendation, ${question.recommended})`}. ${next}`,
306
322
  };
307
323
  },
308
324
  },
@@ -496,7 +512,7 @@ export async function runCli(argv: string[], options: CliOptions): Promise<numbe
496
512
  const store = await findStore(root);
497
513
  const { dashboard } = await readProgress(store.directory, now());
498
514
  const added = addedByOthersText(addedByOthers(await readLatestRun(store.directory), dashboard, options.agentId), dashboard);
499
- out(`Worktree: ${root}\n\n${dashboardText(dashboard, now().getTime())}${added}`);
515
+ out(`Worktree: ${root}\n${modeLine(await readQuestionMode())}\n\n${dashboardText(dashboard, now().getTime())}${added}`);
500
516
  return 0;
501
517
  }
502
518
  const target = resolve(options.cwd, (argv[1] ?? DEFAULT_LAUNCHER).replace(/^~(?=$|\/)/, homedir()));
@@ -523,12 +539,13 @@ export async function runCli(argv: string[], options: CliOptions): Promise<numbe
523
539
  }
524
540
  const root = findRoot(options.cwd);
525
541
  const store = await findStore(root);
542
+ const mode = await readQuestionMode();
526
543
  const message = await appendProgress(store.directory, now, (state) => {
527
544
  const finished = state.dashboard.run?.finished;
528
545
  if (finished && command !== commands.start) {
529
546
  throw new UsageError(`The run "${state.dashboard.run!.title}" is finished. Start new work with: ${USAGE_NAME} start "<title>"`);
530
547
  }
531
- const { event, message } = command.run({ positionals: parsed.positionals, values: parsed.values, state, cwd: options.cwd, root });
548
+ const { event, message } = command.run({ positionals: parsed.positionals, values: parsed.values, state, cwd: options.cwd, root, mode });
532
549
  const added = command === commands.start ? "" : addedByOthersText(addedByOthers(state.events, state.dashboard, options.agentId), state.dashboard);
533
550
  return { event: { ...event, by: options.agentId }, result: `${message}${added}` };
534
551
  }, (text) => saveRunCopy(root, text));
@@ -0,0 +1,61 @@
1
+ // The question mode lives in Paseo's settings, which only the plugin's server
2
+ // process can read. It mirrors the mode into a file the paseo-progress command
3
+ // can read on its own.
4
+ import { readFile, rm } from "node:fs/promises";
5
+ import { homedir } from "node:os";
6
+ import { join } from "node:path";
7
+ import { z } from "zod";
8
+ import type { PluginSettings } from "@getpaseo/plugin/server";
9
+ import { DEFAULT_QUESTION_MODE, QUESTION_MODES, type QuestionMode } from "../shared/question-modes.ts";
10
+ import type { questionSettings } from "../shared/settings.ts";
11
+ import { stateRoot, writeAtomic } from "./store.ts";
12
+
13
+ const mirrorSchema = z.object({ questionMode: z.enum(QUESTION_MODES) });
14
+
15
+ export function questionModePath(home = homedir()): string {
16
+ return join(stateRoot(home), "settings.json");
17
+ }
18
+
19
+ // The default unless the mirror says otherwise, so a missing or damaged file
20
+ // never lets an agent build ahead of the user.
21
+ export async function readQuestionMode(home = homedir()): Promise<QuestionMode> {
22
+ try {
23
+ return mirrorSchema.parse(JSON.parse(await readFile(questionModePath(home), "utf8"))).questionMode;
24
+ } catch {
25
+ return DEFAULT_QUESTION_MODE;
26
+ }
27
+ }
28
+
29
+ export async function writeQuestionMode(mode: QuestionMode, home = homedir()): Promise<void> {
30
+ await writeAtomic(questionModePath(home), `${JSON.stringify({ questionMode: mode })}\n`);
31
+ }
32
+
33
+ type Settings = Pick<PluginSettings<(typeof questionSettings)["schema"]>, "read" | "subscribe">;
34
+ type State = Awaited<ReturnType<Settings["read"]>>;
35
+
36
+ // Writes the mode now and on every change. Stopping deletes the mirror, so
37
+ // agents fall back to the default while the plugin is off.
38
+ export function mirrorQuestionMode(settings: Settings, home = homedir()): { stop(): Promise<void>; settled: Promise<void> } {
39
+ let writes = Promise.resolve();
40
+ let changed = false;
41
+ const write = (state: State | null) => {
42
+ const mode = state?.status === "ready" ? state.values.mode : DEFAULT_QUESTION_MODE;
43
+ writes = writes.then(() => writeQuestionMode(mode, home)).catch(() => {});
44
+ return writes;
45
+ };
46
+ const unsubscribe = settings.subscribe((state) => {
47
+ changed = true;
48
+ return write(state);
49
+ });
50
+ // A change heard while the first read is in flight is newer than that read.
51
+ const settled = settings.read().catch(() => null).then((state) => (changed ? writes : write(state)));
52
+ return {
53
+ settled,
54
+ async stop() {
55
+ await unsubscribe();
56
+ await settled;
57
+ await writes;
58
+ await rm(questionModePath(home), { force: true });
59
+ },
60
+ };
61
+ }
package/server/store.ts CHANGED
@@ -3,11 +3,11 @@
3
3
  // one folder per worktree, so git never sees them and nothing needs hiding.
4
4
  // Only runs the user asks to save are written into the repo, one file per run.
5
5
  import { execFile } from "node:child_process";
6
- import { createHash } from "node:crypto";
6
+ import { createHash, randomUUID } from "node:crypto";
7
7
  import { realpathSync } from "node:fs";
8
- import { appendFile, mkdir, readdir, readFile, rm, rmdir, stat, writeFile } from "node:fs/promises";
8
+ import { appendFile, mkdir, readdir, readFile, rename, rm, rmdir, stat, writeFile } from "node:fs/promises";
9
9
  import { homedir } from "node:os";
10
- import { basename, join, normalize, resolve } from "node:path";
10
+ import { basename, dirname, join, normalize, resolve } from "node:path";
11
11
  import { promisify } from "node:util";
12
12
  import { LEGACY_FOLDER, PANEL_OPENED_NAME, PROGRESS_FILE_NAME } from "../shared/events.ts";
13
13
  import { withLock } from "./lock.ts";
@@ -40,6 +40,23 @@ async function exists(path: string): Promise<boolean> {
40
40
  return Boolean(await stat(path).catch(() => null));
41
41
  }
42
42
 
43
+ // The plugin's per-user folder on this computer.
44
+ export function stateRoot(home = homedir()): string {
45
+ return join(home, ".local", "state", "paseo-progress");
46
+ }
47
+
48
+ // Readers see the old file or the new one, never half of one.
49
+ export async function writeAtomic(path: string, text: string): Promise<void> {
50
+ await mkdir(dirname(path), { recursive: true });
51
+ const temp = `${path}.${randomUUID()}.tmp`;
52
+ try {
53
+ await writeFile(temp, text);
54
+ await rename(temp, path);
55
+ } finally {
56
+ await rm(temp, { force: true });
57
+ }
58
+ }
59
+
43
60
  // One folder per worktree on this computer, named so a person can tell which
44
61
  // worktree it belongs to.
45
62
  export function outsideDirectory(root: string, home = homedir()): string {
@@ -48,7 +65,7 @@ export function outsideDirectory(root: string, home = homedir()): string {
48
65
  real = realpathSync(root);
49
66
  } catch {}
50
67
  const hash = createHash("sha1").update(real).digest("hex").slice(0, 8);
51
- return join(home, ".local", "state", "paseo-progress", `${basename(real)}-${hash}`);
68
+ return join(stateRoot(home), `${basename(real)}-${hash}`);
52
69
  }
53
70
 
54
71
  // Whether git tracks a 0.1.x progress file, per worktree, until the file changes.