@stevecastaneda/paseo-progress 1.3.1 → 1.4.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/OVERVIEW.md +1 -1
- package/README.md +16 -4
- package/client/panel.tsx +30 -10
- package/client/questions.tsx +20 -4
- package/client/settings.tsx +40 -0
- package/client/surfaces.ts +20 -8
- package/client/ticket-dialog.tsx +2 -1
- package/client/whats-new.tsx +120 -0
- package/client/write-up.tsx +3 -2
- package/dist/server/cli.js +85 -20
- package/images/1-panel.png +0 -0
- package/images/3-question.png +0 -0
- package/images/5-settings.png +0 -0
- package/index.client.tsx +12 -1
- package/index.server.ts +11 -2
- package/package.json +1 -1
- package/paseo-plugin.json +2 -1
- package/server/cli.ts +28 -11
- package/server/question-mode.ts +61 -0
- package/server/store.ts +21 -4
- package/server/whats-new.ts +127 -0
- package/shared/dashboard.ts +9 -2
- package/shared/events.ts +9 -4
- package/shared/question-modes.ts +45 -0
- package/shared/rpc.ts +18 -0
- package/shared/settings.ts +10 -0
- package/shared/show.ts +3 -1
- package/shared/whats-new.ts +16 -0
- package/shared/write-up.ts +9 -8
- package/skills/paseo-progress/SKILL.md +26 -68
package/dist/server/cli.js
CHANGED
|
@@ -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
|
|
19741
|
-
//
|
|
19742
|
-
//
|
|
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
|
-
//
|
|
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,
|
|
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
|
|
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.
|
|
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}.
|
|
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})`}.
|
|
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(/^~(?=$|\/)/,
|
|
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));
|
package/images/1-panel.png
CHANGED
|
Binary file
|
package/images/3-question.png
CHANGED
|
Binary file
|
|
Binary file
|
package/index.client.tsx
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
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
|
+
|
|
6
|
+
const SETTINGS_ID = "settings";
|
|
4
7
|
|
|
5
8
|
export default function contribute(client: PluginClientContext) {
|
|
6
9
|
const stopPills = contributePills(client);
|
|
@@ -11,7 +14,7 @@ export default function contribute(client: PluginClientContext) {
|
|
|
11
14
|
icon: "ListChecks",
|
|
12
15
|
context: "workspace",
|
|
13
16
|
locations: [panel.location],
|
|
14
|
-
Component: ProgressPanel
|
|
17
|
+
Component: (props) => <ProgressPanel {...props} openSettings={() => client.openSettings(SETTINGS_ID)} />,
|
|
15
18
|
});
|
|
16
19
|
const stopCommand = client.addCommandCenterItem({
|
|
17
20
|
id: "open-progress",
|
|
@@ -25,7 +28,15 @@ export default function contribute(client: PluginClientContext) {
|
|
|
25
28
|
},
|
|
26
29
|
});
|
|
27
30
|
|
|
31
|
+
const stopSettings = client.addSettingsScreen({
|
|
32
|
+
id: SETTINGS_ID,
|
|
33
|
+
title: "Settings",
|
|
34
|
+
icon: "Settings",
|
|
35
|
+
Component: QuestionSettings,
|
|
36
|
+
});
|
|
37
|
+
|
|
28
38
|
return () => {
|
|
39
|
+
void stopSettings();
|
|
29
40
|
void stopPanel();
|
|
30
41
|
void stopCommand();
|
|
31
42
|
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 {
|
|
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 { questionSettings } from "./shared/settings";
|
|
7
10
|
|
|
8
11
|
export default function contribute(server: PluginServerContext) {
|
|
9
12
|
server.handle(getDashboard, handleGetDashboard);
|
|
@@ -19,5 +22,11 @@ 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
|
-
|
|
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
|
+
const mirror = mirrorQuestionMode(server.registerSettings(questionSettings));
|
|
31
|
+
return mirror.stop;
|
|
23
32
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stevecastaneda/paseo-progress",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.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
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.
|
|
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}.
|
|
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})`}.
|
|
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,
|
|
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.
|