@stevecastaneda/paseo-progress 1.13.1 → 1.14.1
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 +5 -3
- package/README.md +51 -7
- package/client/chat-row-view.tsx +78 -0
- package/client/chat-row.ts +83 -0
- package/client/launcher.tsx +19 -2
- package/client/locales/en.ts +25 -0
- package/client/locales/zh-CN.ts +25 -0
- package/client/messages.ts +14 -0
- package/client/panel.tsx +23 -10
- package/client/settings.tsx +19 -3
- package/dist/server/cli.js +61 -75
- package/images/5-settings.png +0 -0
- package/images/6-chat-rows.png +0 -0
- package/images/7-empty-tools-note.png +0 -0
- package/index.client.tsx +11 -0
- package/index.server.ts +4 -1
- package/package.json +4 -4
- package/paseo-plugin.json +3 -1
- package/server/cli.ts +8 -0
- package/server/open.ts +27 -5
- package/server/paths.ts +17 -5
- package/server/store.ts +48 -91
- package/server/tool.ts +174 -0
- package/shared/events.ts +0 -2
- package/shared/rpc.ts +11 -0
- package/shared/tools.ts +23 -0
- package/skills/paseo-progress/SKILL.md +16 -8
package/server/store.ts
CHANGED
|
@@ -5,30 +5,24 @@
|
|
|
5
5
|
import { execFile } from "node:child_process";
|
|
6
6
|
import { createHash, randomUUID } from "node:crypto";
|
|
7
7
|
import { realpathSync } from "node:fs";
|
|
8
|
-
import {
|
|
8
|
+
import { lstat, mkdir, readdir, realpath, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
9
9
|
import { homedir } from "node:os";
|
|
10
|
-
import { basename, dirname, join, normalize, resolve } from "node:path";
|
|
10
|
+
import { basename, dirname, join, normalize, resolve, sep } from "node:path";
|
|
11
11
|
import { promisify } from "node:util";
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
import { outside } from "./paths.ts";
|
|
12
|
+
import { latestRun, parseProgress } from "../shared/dashboard.ts";
|
|
13
|
+
import { hasGitEntry, inGitFolder, outside } from "./paths.ts";
|
|
15
14
|
|
|
16
|
-
const LOCK_NAME = `${PROGRESS_FILE_NAME}.lock`;
|
|
17
15
|
const SAVE_RUNS_KEY = "paseo-progress.saveRuns";
|
|
18
|
-
// Exactly what 0.1.x wrote into .scratch/.gitignore. Only this is ever removed.
|
|
19
|
-
const LEGACY_GITIGNORE = `# Added by the progress plugin for Paseo so its files stay out of git.
|
|
20
|
-
${PROGRESS_FILE_NAME}
|
|
21
|
-
${LOCK_NAME}/
|
|
22
|
-
${PANEL_OPENED_NAME}
|
|
23
|
-
.gitignore
|
|
24
|
-
`;
|
|
25
16
|
|
|
26
17
|
// `directory` holds the working files. `shown` names it for people.
|
|
27
18
|
export type Store = { directory: string; shown: string };
|
|
28
19
|
|
|
29
|
-
|
|
20
|
+
// Git runs in the worktree through -C, started from the folder Node itself
|
|
21
|
+
// lives in: Windows looks for a program in the folder it starts in before
|
|
22
|
+
// PATH, and a cloned repo can commit a git.exe.
|
|
23
|
+
export async function git(root: string, args: string[]): Promise<{ code: number; stdout: string }> {
|
|
30
24
|
try {
|
|
31
|
-
const { stdout } = await promisify(execFile)("git", args, { cwd });
|
|
25
|
+
const { stdout } = await promisify(execFile)("git", ["-C", resolve(root), ...args], { cwd: dirname(process.execPath) });
|
|
32
26
|
return { code: 0, stdout };
|
|
33
27
|
} catch (error) {
|
|
34
28
|
const failed = error as { code?: number | string; stdout?: string };
|
|
@@ -36,10 +30,6 @@ export async function git(cwd: string, args: string[]): Promise<{ code: number;
|
|
|
36
30
|
}
|
|
37
31
|
}
|
|
38
32
|
|
|
39
|
-
async function exists(path: string): Promise<boolean> {
|
|
40
|
-
return Boolean(await stat(path).catch(() => null));
|
|
41
|
-
}
|
|
42
|
-
|
|
43
33
|
// The plugin's per-user folder on this computer.
|
|
44
34
|
export function stateRoot(home = homedir()): string {
|
|
45
35
|
return join(home, ".local", "state", "paseo-progress");
|
|
@@ -68,76 +58,37 @@ export function outsideDirectory(root: string, home = homedir()): string {
|
|
|
68
58
|
return join(stateRoot(home), `${basename(real)}-${hash}`);
|
|
69
59
|
}
|
|
70
60
|
|
|
71
|
-
//
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
async function legacyTracked(root: string, mtimeMs: number): Promise<boolean> {
|
|
75
|
-
const cached = trackedLegacy.get(root);
|
|
76
|
-
if (cached && cached.mtimeMs === mtimeMs) return cached.tracked;
|
|
77
|
-
const { code } = await git(root, ["ls-files", "--error-unmatch", "--", `${LEGACY_FOLDER}/${PROGRESS_FILE_NAME}`]);
|
|
78
|
-
trackedLegacy.set(root, { mtimeMs, tracked: code === 0 });
|
|
79
|
-
return code === 0;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
// The worktree's working files. A run 0.1.x left in .scratch moves outside the
|
|
83
|
-
// first time anything asks, unless git tracks it: then it stays and is used in
|
|
84
|
-
// place, so nothing changes for a repo that commits it.
|
|
85
|
-
export async function findStore(root: string, home = homedir()): Promise<Store> {
|
|
61
|
+
// The worktree's working files, always outside the repo. Progress never reads
|
|
62
|
+
// a repo's .scratch, where 0.1.x kept them: a cloned repo could put anything there.
|
|
63
|
+
export function findStore(root: string, home = homedir()): Store {
|
|
86
64
|
const directory = outsideDirectory(root, home);
|
|
87
|
-
|
|
88
|
-
const legacy = join(root, LEGACY_FOLDER);
|
|
89
|
-
const info = await stat(join(legacy, PROGRESS_FILE_NAME)).catch(() => null);
|
|
90
|
-
if (!info) return store;
|
|
91
|
-
if (await legacyTracked(root, info.mtimeMs)) return { directory: legacy, shown: LEGACY_FOLDER };
|
|
92
|
-
await moveLegacy(legacy, directory);
|
|
93
|
-
return store;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
// Appends the old file's lines to the new one rather than replacing it, so a
|
|
97
|
-
// command that wrote to the old place mid-move loses nothing: the next move
|
|
98
|
-
// picks those lines up. Then tidies what 0.1.x left behind.
|
|
99
|
-
async function moveLegacy(legacy: string, directory: string): Promise<void> {
|
|
100
|
-
await mkdir(directory, { recursive: true });
|
|
101
|
-
await withLock(join(legacy, LOCK_NAME), async () => {
|
|
102
|
-
const text = await readFile(join(legacy, PROGRESS_FILE_NAME), "utf8").catch(() => null);
|
|
103
|
-
if (text === null) return;
|
|
104
|
-
await withLock(join(directory, LOCK_NAME), async () => {
|
|
105
|
-
const current = await readFile(join(directory, PROGRESS_FILE_NAME), "utf8").catch(() => "");
|
|
106
|
-
const lead = current && !current.endsWith("\n") ? "\n" : "";
|
|
107
|
-
await appendFile(join(directory, PROGRESS_FILE_NAME), `${lead}${text}`);
|
|
108
|
-
});
|
|
109
|
-
await rm(join(legacy, PROGRESS_FILE_NAME));
|
|
110
|
-
if (await exists(join(legacy, PANEL_OPENED_NAME))) {
|
|
111
|
-
await writeFile(join(directory, PANEL_OPENED_NAME), "");
|
|
112
|
-
await rm(join(legacy, PANEL_OPENED_NAME));
|
|
113
|
-
}
|
|
114
|
-
});
|
|
115
|
-
const ignore = await readFile(join(legacy, ".gitignore"), "utf8").catch(() => null);
|
|
116
|
-
if (ignore === LEGACY_GITIGNORE) await rm(join(legacy, ".gitignore"));
|
|
117
|
-
// Only if nothing else is in there; the user's own files keep the folder.
|
|
118
|
-
await rmdir(legacy).catch(() => {});
|
|
65
|
+
return { directory, shown: directory.startsWith(home) ? `~${directory.slice(home.length)}` : directory };
|
|
119
66
|
}
|
|
120
67
|
|
|
121
68
|
// The repo's saved-history folder, kept in its local git config, which every
|
|
122
69
|
// worktree of the repo shares and nothing commits. Polls cost one stat; git
|
|
123
|
-
// runs again only when the config file changes.
|
|
124
|
-
|
|
70
|
+
// runs again only when the config file changes. A worktree with no .git
|
|
71
|
+
// entry is no repo; when git fails in one that has it, the next poll asks again.
|
|
72
|
+
const configFiles = new Map<string, string>();
|
|
125
73
|
const savedFolders = new Map<string, { mtimeMs: number; folder: string | null }>();
|
|
126
74
|
|
|
127
75
|
async function configFile(root: string): Promise<string | null> {
|
|
76
|
+
if (!hasGitEntry(root)) return null;
|
|
128
77
|
if (!configFiles.has(root)) {
|
|
129
78
|
const { code, stdout } = await git(root, ["rev-parse", "--git-common-dir"]);
|
|
130
|
-
|
|
79
|
+
if (code !== 0) return null;
|
|
80
|
+
configFiles.set(root, join(resolve(root, stdout.trim()), "config"));
|
|
131
81
|
}
|
|
132
82
|
return configFiles.get(root)!;
|
|
133
83
|
}
|
|
134
84
|
|
|
135
|
-
// Whether
|
|
85
|
+
// Whether git answers for the worktree's repo, which saving run history needs.
|
|
136
86
|
export async function inGitRepo(root: string): Promise<boolean> {
|
|
137
87
|
return (await configFile(root)) !== null;
|
|
138
88
|
}
|
|
139
89
|
|
|
140
|
-
// The folder runs are saved to, relative to the worktree root, or null.
|
|
90
|
+
// The folder runs are saved to, relative to the worktree root, or null. A
|
|
91
|
+
// folder set outside Paseo gets the same check as one picked in it.
|
|
141
92
|
export async function historyFolder(root: string): Promise<string | null> {
|
|
142
93
|
const file = await configFile(root);
|
|
143
94
|
if (file === null) return null;
|
|
@@ -145,7 +96,8 @@ export async function historyFolder(root: string): Promise<string | null> {
|
|
|
145
96
|
const cached = savedFolders.get(file);
|
|
146
97
|
if (cached && cached.mtimeMs === mtimeMs) return cached.folder;
|
|
147
98
|
const { stdout } = await git(root, ["config", "--get", SAVE_RUNS_KEY]);
|
|
148
|
-
const
|
|
99
|
+
const checked = checkFolder(stdout);
|
|
100
|
+
const folder = "folder" in checked ? checked.folder : null;
|
|
149
101
|
savedFolders.set(file, { mtimeMs, folder });
|
|
150
102
|
return folder;
|
|
151
103
|
}
|
|
@@ -156,7 +108,7 @@ export function checkFolder(raw: string): { folder: string } | { error: string }
|
|
|
156
108
|
if (!trimmed) return { error: "Choose a folder" };
|
|
157
109
|
const folder = normalize(trimmed);
|
|
158
110
|
if (outside(folder) || folder === ".") return { error: "Choose a folder inside the repo" };
|
|
159
|
-
if (folder
|
|
111
|
+
if (inGitFolder(folder)) return { error: "Choose a folder outside .git" };
|
|
160
112
|
return { folder };
|
|
161
113
|
}
|
|
162
114
|
|
|
@@ -189,27 +141,32 @@ export function runFileName(startedAt: string, title: string): string {
|
|
|
189
141
|
export async function saveRunCopy(root: string, text: string): Promise<void> {
|
|
190
142
|
const folder = await historyFolder(root);
|
|
191
143
|
if (!folder) return;
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
144
|
+
// The run the dashboard shows. A start line it can't read is no run.
|
|
145
|
+
const [start] = latestRun(parseProgress(text).events);
|
|
146
|
+
if (start?.event.type !== "run.start") return;
|
|
147
|
+
const lines = text.split("\n").slice(start.line - 1).filter((line) => line.trim());
|
|
148
|
+
const directory = await runsFolder(root, folder);
|
|
149
|
+
if (!directory) return;
|
|
150
|
+
// A rename replaces a link at the run's file instead of writing through it.
|
|
151
|
+
await writeAtomic(join(directory, runFileName(start.event.ts, start.event.title)), `${lines.join("\n")}\n`);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// The saved-runs folder in the real worktree, made one step at a time, or null
|
|
155
|
+
// when any step is a link or a file: a cloned repo can commit a link that leads
|
|
156
|
+
// out of it.
|
|
157
|
+
async function runsFolder(root: string, folder: string): Promise<string | null> {
|
|
158
|
+
let directory = await realpath(root);
|
|
159
|
+
for (const name of folder.split(sep)) {
|
|
160
|
+
directory = join(directory, name);
|
|
161
|
+
const info = await lstat(directory).catch(() => null);
|
|
162
|
+
if (!info) await mkdir(directory);
|
|
163
|
+
else if (!info.isDirectory()) return null;
|
|
204
164
|
}
|
|
205
|
-
|
|
206
|
-
const directory = join(root, folder);
|
|
207
|
-
await mkdir(directory, { recursive: true });
|
|
208
|
-
await writeFile(join(directory, runFileName(event.ts, event.title)), `${lines.slice(start).join("\n")}\n`);
|
|
165
|
+
return directory;
|
|
209
166
|
}
|
|
210
167
|
|
|
211
168
|
// The folders directly inside one folder of the worktree ("" is the top), for
|
|
212
|
-
// the folder picker. .git
|
|
169
|
+
// the folder picker. .git folders are left out; so are links, which could leave the repo.
|
|
213
170
|
export async function listFolders(root: string, folder: string): Promise<{ folder: string; folders: string[] }> {
|
|
214
171
|
const checked = folder === "" ? { folder: "" } : checkFolder(folder);
|
|
215
172
|
if ("error" in checked) throw new Error(checked.error);
|
|
@@ -217,6 +174,6 @@ export async function listFolders(root: string, folder: string): Promise<{ folde
|
|
|
217
174
|
if (error.code === "ENOENT") return [];
|
|
218
175
|
throw error;
|
|
219
176
|
});
|
|
220
|
-
const folders = entries.filter((entry) => entry.isDirectory() && !(
|
|
177
|
+
const folders = entries.filter((entry) => entry.isDirectory() && !inGitFolder(entry.name)).map((entry) => entry.name);
|
|
221
178
|
return { folder: checked.folder, folders: folders.sort((a, b) => a.localeCompare(b)) };
|
|
222
179
|
}
|
package/server/tool.ts
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import type { AgentToolContext, AgentToolDefinition, AgentToolResult, PluginHandlerContext, PluginServerContext } from "@getpaseo/plugin/server";
|
|
2
|
+
import * as sdk from "@getpaseo/plugin/server";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { DELIVERABLE_KINDS, ticketStatusSchema } from "../shared/events.ts";
|
|
5
|
+
import type { ToolsState } from "../shared/rpc.ts";
|
|
6
|
+
import { PROGRESS_TOOL_NAMES, type ProgressToolName } from "../shared/tools.ts";
|
|
7
|
+
import { commandSummary, runCli } from "./cli.ts";
|
|
8
|
+
|
|
9
|
+
// Paseo supplies this SDK at runtime. Paseo 0.13's has no defineAgentTool and
|
|
10
|
+
// can't give agents plugin tools; reading the name calls nothing, so it's safe there.
|
|
11
|
+
export const agentToolsSupported = "defineAgentTool" in sdk;
|
|
12
|
+
|
|
13
|
+
// Importing defineAgentTool would stop Progress loading on Paseo 0.13. This
|
|
14
|
+
// mirrors its typing; Paseo validates the input before execute.
|
|
15
|
+
type Tool = Omit<AgentToolDefinition, "name">;
|
|
16
|
+
|
|
17
|
+
function agentTool<Input extends z.ZodType>(tool: Omit<Tool, "input" | "execute"> & {
|
|
18
|
+
input: Input;
|
|
19
|
+
execute(input: z.output<Input>, context: AgentToolContext): Promise<AgentToolResult>;
|
|
20
|
+
}): Tool {
|
|
21
|
+
return { ...tool, execute: (input, context) => tool.execute(input as z.output<Input>, context) };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function run(args: string[], { agent }: AgentToolContext): Promise<AgentToolResult> {
|
|
25
|
+
const lines: string[] = [];
|
|
26
|
+
const code = await runCli(args, { cwd: agent.cwd, agentId: agent.id, out: (line) => lines.push(line) });
|
|
27
|
+
return { text: lines.join("\n"), isError: code !== 0 };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
type Flags = Record<string, string | number | string[] | undefined>;
|
|
31
|
+
|
|
32
|
+
// Flags go as --name=value so a value starting with "-" stays a value, and the
|
|
33
|
+
// positionals follow "--" so a title starting with "-" isn't read as a flag.
|
|
34
|
+
function commandArgs(command: string, positionals: string[], flags: Flags = {}): string[] {
|
|
35
|
+
const options = Object.entries(flags).flatMap(([name, value]) => (value === undefined ? [] : [value].flat().map((entry) => `--${name}=${entry}`)));
|
|
36
|
+
return [...command.split(" "), ...options, "--", ...positionals];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// The command's --help summary, with its flags renamed to this tool's fields.
|
|
40
|
+
const FIELD_FOR_FLAG: Record<string, string> = { estimate: "estimateMinutes", option: "options", file: "files" };
|
|
41
|
+
function toolSummary(command: string): string {
|
|
42
|
+
return commandSummary(command).replace(/--([a-z]+(?:-[a-z]+)*)/g, (_, flag: string) =>
|
|
43
|
+
FIELD_FOR_FLAG[flag] ?? flag.replace(/-([a-z])/g, (_m, letter: string) => letter.toUpperCase()));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function commandTool<Input extends z.ZodType>(title: string, command: string, input: Input, args: (input: z.output<Input>) => string[], extra = "") {
|
|
47
|
+
return agentTool({
|
|
48
|
+
title: `Progress: ${title}`,
|
|
49
|
+
description: `Progress dashboard for this worktree in Paseo. ${toolSummary(command)}${extra}`,
|
|
50
|
+
input,
|
|
51
|
+
execute: (value, context) => run(args(value), context),
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const ticketId = (what: string) => z.string().describe(`${what}, like T03`);
|
|
56
|
+
const estimate = z.number().int().describe("Whole minutes, like 90");
|
|
57
|
+
const source = z.string().describe("Spec file path or http(s) link where the ticket is written up");
|
|
58
|
+
|
|
59
|
+
// Keyed by name, so the compiler holds this to the shared list.
|
|
60
|
+
const tools: Record<ProgressToolName, Tool> = {
|
|
61
|
+
progress_show: commandTool("Show", "show", z.object({}), () => ["show"]),
|
|
62
|
+
progress_start: commandTool("Start run", "start",
|
|
63
|
+
z.object({
|
|
64
|
+
title: z.string(),
|
|
65
|
+
subtitle: z.string().optional(),
|
|
66
|
+
itemLabel: z.string().optional().describe('What to call each item instead of "Ticket", like Step'),
|
|
67
|
+
}),
|
|
68
|
+
({ title, subtitle, itemLabel }) => commandArgs("start", [title], { subtitle, "item-label": itemLabel })),
|
|
69
|
+
progress_ticket_add: commandTool("Add ticket", "ticket add",
|
|
70
|
+
z.object({
|
|
71
|
+
title: z.string().describe('Like "Ticket 01: Export button"'),
|
|
72
|
+
estimateMinutes: estimate,
|
|
73
|
+
status: ticketStatusSchema.optional(),
|
|
74
|
+
waitsFor: ticketId("A ticket this one can't start before").optional(),
|
|
75
|
+
source: source.optional(),
|
|
76
|
+
}),
|
|
77
|
+
({ title, estimateMinutes, status, waitsFor, source }) => commandArgs("ticket add", [title], { estimate: estimateMinutes, status, "waits-for": waitsFor, source })),
|
|
78
|
+
progress_ticket_update: commandTool("Update ticket", "ticket update",
|
|
79
|
+
z.object({
|
|
80
|
+
id: ticketId("The ticket"),
|
|
81
|
+
status: ticketStatusSchema.optional(),
|
|
82
|
+
stage: z.string().optional().describe('The step in progress, like Build or Fixes; "" clears it'),
|
|
83
|
+
title: z.string().optional(),
|
|
84
|
+
estimateMinutes: estimate.optional(),
|
|
85
|
+
note: z.string().optional(),
|
|
86
|
+
waitsFor: z.string().optional().describe('A ticket this one can\'t start before, like T02; "" clears it'),
|
|
87
|
+
source: z.string().optional().describe('Spec file path or http(s) link where the ticket is written up; "" clears it'),
|
|
88
|
+
}),
|
|
89
|
+
({ id, status, stage, title, estimateMinutes, note, waitsFor, source }) =>
|
|
90
|
+
commandArgs("ticket update", [id], { status, stage, title, estimate: estimateMinutes, note, "waits-for": waitsFor, source })),
|
|
91
|
+
progress_question_ask: commandTool("Ask question", "question ask",
|
|
92
|
+
z.object({
|
|
93
|
+
title: z.string().describe("A short title"),
|
|
94
|
+
question: z.string(),
|
|
95
|
+
options: z.array(z.object({
|
|
96
|
+
letter: z.string().describe("A, B, C, ..."),
|
|
97
|
+
label: z.string(),
|
|
98
|
+
consequence: z.string().describe("What picking it means for the user"),
|
|
99
|
+
})),
|
|
100
|
+
recommend: z.string().describe("The option letter you would pick"),
|
|
101
|
+
big: z.string().optional().describe("Only for a big question: which test it meets, in a few plain words"),
|
|
102
|
+
background: z.string().optional(),
|
|
103
|
+
files: z.array(z.object({
|
|
104
|
+
target: z.string().describe("A file path or http(s) link, like a screenshot"),
|
|
105
|
+
label: z.string().optional(),
|
|
106
|
+
})).optional(),
|
|
107
|
+
ticket: ticketId("The ticket it's about").optional(),
|
|
108
|
+
}),
|
|
109
|
+
({ title, question, options, recommend, big, background, files, ticket }) => commandArgs("question ask", [title, question], {
|
|
110
|
+
option: options.map(({ letter, label, consequence }) => `${letter}=${label} | ${consequence}`),
|
|
111
|
+
recommend,
|
|
112
|
+
big,
|
|
113
|
+
background,
|
|
114
|
+
file: files?.map(({ target, label }) => (label ? `${target}=${label}` : target)),
|
|
115
|
+
ticket,
|
|
116
|
+
})),
|
|
117
|
+
progress_question_answer: commandTool("Record answer", "question answer",
|
|
118
|
+
z.object({
|
|
119
|
+
id: z.string().describe("The question, like Q2"),
|
|
120
|
+
choice: z.string().describe("An option letter, or the user's own short answer"),
|
|
121
|
+
words: z.string().optional().describe("What the user said"),
|
|
122
|
+
}),
|
|
123
|
+
({ id, choice, words }) => commandArgs("question answer", [id, choice], { words })),
|
|
124
|
+
progress_deliverable_add: commandTool("Add deliverable", "deliverable add",
|
|
125
|
+
z.object({
|
|
126
|
+
title: z.string(),
|
|
127
|
+
target: z.string().describe("A file or folder path, or an http(s) link"),
|
|
128
|
+
ticket: ticketId("The ticket it belongs to").optional(),
|
|
129
|
+
kind: z.enum(DELIVERABLE_KINDS).optional(),
|
|
130
|
+
}),
|
|
131
|
+
({ title, target, ticket, kind }) => commandArgs("deliverable add", [title, target], { ticket, kind })),
|
|
132
|
+
progress_activity_add: commandTool("Log activity", "activity add",
|
|
133
|
+
z.object({ text: z.string(), ticket: ticketId("The ticket it's about").optional() }),
|
|
134
|
+
({ text, ticket }) => commandArgs("activity add", [text], { ticket })),
|
|
135
|
+
progress_ticker: commandTool("Set ticker", "ticker set",
|
|
136
|
+
z.object({ text: z.string() }),
|
|
137
|
+
({ text }) => (text.trim() ? commandArgs("ticker set", [text]) : commandArgs("ticker clear", [])),
|
|
138
|
+
' Pass "" to hide it.'),
|
|
139
|
+
progress_finish: commandTool("Finish run", "finish",
|
|
140
|
+
z.object({ outcome: z.string().describe("One line") }),
|
|
141
|
+
({ outcome }) => commandArgs("finish", [outcome])),
|
|
142
|
+
paseo_progress: agentTool({
|
|
143
|
+
title: "Progress",
|
|
144
|
+
description: [
|
|
145
|
+
"Record progress for this worktree on the Progress panel in Paseo. Works like the paseo-progress command:",
|
|
146
|
+
'pass its arguments as a list, like ["ticket", "add", "Fix login", "--estimate", "30"].',
|
|
147
|
+
'Pass ["--help"] to list the commands, or add "--help" after any command for its options.',
|
|
148
|
+
].join(" "),
|
|
149
|
+
input: z.object({ args: z.array(z.string()).min(1).describe("Arguments for paseo-progress, one per item") }),
|
|
150
|
+
execute: ({ args }, context) => run(args, context),
|
|
151
|
+
}),
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
export const progressTools: AgentToolDefinition[] = PROGRESS_TOOL_NAMES.map((name) => ({ name, ...tools[name] }));
|
|
155
|
+
|
|
156
|
+
export function attachProgressTools(server: PluginServerContext): void {
|
|
157
|
+
server.before("agent.session_open", ({ request }) => {
|
|
158
|
+
// Paseo 0.13 has no tools list and fails the session open if a plugin adds one.
|
|
159
|
+
if (!Array.isArray(request.tools)) return;
|
|
160
|
+
return { ...request, tools: [...request.tools, ...progressTools] };
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Tools reach agents only while the host's "Enable Paseo tools" setting is on,
|
|
165
|
+
// which needs Paseo's tools (mcp.enabled) on as well.
|
|
166
|
+
export async function toolsState(supported: boolean, paseo: PluginHandlerContext["paseo"]): Promise<ToolsState> {
|
|
167
|
+
if (!supported) return "unsupported";
|
|
168
|
+
const { config } = await paseo.config.get();
|
|
169
|
+
return config.mcp.enabled !== false && config.mcp.injectIntoAgents ? "on" : "off";
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export async function handleToolsStatus(_input: unknown, { paseo }: PluginHandlerContext): Promise<{ state: ToolsState }> {
|
|
173
|
+
return { state: await toolsState(agentToolsSupported, paseo) };
|
|
174
|
+
}
|
package/shared/events.ts
CHANGED
|
@@ -7,8 +7,6 @@ import { QUESTION_MODES } from "./question-modes.ts";
|
|
|
7
7
|
export const PROGRESS_FILE_NAME = "progress.jsonl";
|
|
8
8
|
// Present once someone has opened the Progress panel for this worktree.
|
|
9
9
|
export const PANEL_OPENED_NAME = "progress-panel-opened";
|
|
10
|
-
// Where 0.1.x kept each worktree's files. store.ts moves them out.
|
|
11
|
-
export const LEGACY_FOLDER = ".scratch";
|
|
12
10
|
export const EVENT_VERSION = 1;
|
|
13
11
|
|
|
14
12
|
export const TICKET_STATUSES = ["not_started", "working", "blocked", "done", "skipped"] as const;
|
package/shared/rpc.ts
CHANGED
|
@@ -69,6 +69,17 @@ export const installLauncher = defineRpc({
|
|
|
69
69
|
output: launcherStatus,
|
|
70
70
|
});
|
|
71
71
|
|
|
72
|
+
// Whether agents get the Progress tools: "unsupported" before Paseo 0.14, then
|
|
73
|
+
// whether the host's "Enable Paseo tools" setting is "on" or "off".
|
|
74
|
+
export const TOOLS_STATES = ["unsupported", "off", "on"] as const;
|
|
75
|
+
export type ToolsState = (typeof TOOLS_STATES)[number];
|
|
76
|
+
|
|
77
|
+
export const getToolsStatus = defineRpc({
|
|
78
|
+
name: "progress.tools.status",
|
|
79
|
+
input: z.object({}),
|
|
80
|
+
output: z.object({ state: z.enum(TOOLS_STATES) }),
|
|
81
|
+
});
|
|
82
|
+
|
|
72
83
|
// The paseo-progress skill, linked into each agent's skills folder.
|
|
73
84
|
const skillState = z.enum(["missing", "current", "outdated", "foreign"]);
|
|
74
85
|
const skillStatus = z.object({
|
package/shared/tools.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// The tools Progress hands agents on Paseo 0.14 and later. The server defines
|
|
2
|
+
// one tool per name, and the chat draws Progress's own row for a call to any of them.
|
|
3
|
+
export const PROGRESS_TOOL_NAMES = [
|
|
4
|
+
"progress_show",
|
|
5
|
+
"progress_start",
|
|
6
|
+
"progress_ticket_add",
|
|
7
|
+
"progress_ticket_update",
|
|
8
|
+
"progress_question_ask",
|
|
9
|
+
"progress_question_answer",
|
|
10
|
+
"progress_deliverable_add",
|
|
11
|
+
"progress_activity_add",
|
|
12
|
+
"progress_ticker",
|
|
13
|
+
"progress_finish",
|
|
14
|
+
"paseo_progress",
|
|
15
|
+
] as const;
|
|
16
|
+
|
|
17
|
+
export type ProgressToolName = (typeof PROGRESS_TOOL_NAMES)[number];
|
|
18
|
+
|
|
19
|
+
// Chats record a tool under the agent's own prefix: Claude Code's
|
|
20
|
+
// mcp__paseo__progress_ticket_add, or Codex's paseo.progress_ticket_add.
|
|
21
|
+
export function progressToolName(name: string): ProgressToolName | undefined {
|
|
22
|
+
return PROGRESS_TOOL_NAMES.find((tool) => name === tool || name.endsWith(`__${tool}`) || name.endsWith(`.${tool}`));
|
|
23
|
+
}
|
|
@@ -1,15 +1,19 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: paseo-progress
|
|
3
|
-
description: Keep the Paseo Progress dashboard live with the paseo-progress command. Use when working through a spec or a set of tickets, on any job over 5 steps or 30 minutes, or when the user asks for a progress dashboard.
|
|
3
|
+
description: Keep the Paseo Progress dashboard live with the Progress tools or the paseo-progress command. Use when working through a spec or a set of tickets, on any job over 5 steps or 30 minutes, or when the user asks for a progress dashboard.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Paseo progress
|
|
7
7
|
|
|
8
|
-
The user watches a live dashboard in Paseo for this worktree. Keep it live:
|
|
8
|
+
The user watches a live dashboard in Paseo for this worktree. Keep it live: record each change the moment it happens, so the dashboard is a side effect of the work. Write every line for a product manager, in plain words.
|
|
9
|
+
|
|
10
|
+
Use the Progress tools when you have them: `progress_show`, `progress_start`, `progress_ticket_add`, `progress_ticket_update`, `progress_question_ask`, `progress_question_answer`, `progress_deliverable_add`, `progress_activity_add`, `progress_ticker`, `progress_finish`, and `paseo_progress` for any other command. Paseo hands them to agents when the user turns on Paseo tools. Claude Code names them like `mcp__paseo__progress_show` and keeps them hidden until you search, so search your tools for `progress_` before your first update. Otherwise, run the `paseo-progress` command from inside the worktree. When a tool call says the tool isn't found, use the command for the rest of that reply.
|
|
11
|
+
|
|
12
|
+
The tools and the command take the same values and print the same answers. The steps below use the command's words: `--estimate` is `estimateMinutes` in the tools, `--waits-for` is `waitsFor`. `paseo-progress --help` is the reference for every command and flag.
|
|
9
13
|
|
|
10
14
|
## 1. Set up
|
|
11
15
|
|
|
12
|
-
Run `
|
|
16
|
+
Run `show` and continue an open run for this job. A Finished run is closed, so `start` a new one, then add every ticket in order:
|
|
13
17
|
|
|
14
18
|
- Title each `"Ticket 01: <title>"`. The dashboard shortens titles to that prefix.
|
|
15
19
|
- Give each an `--estimate`, plus `--waits-for` when it can't start before another and `--source` when a spec file or issue describes it.
|
|
@@ -27,16 +31,20 @@ Move each ticket through `--status working --stage Build`, then the stages `Revi
|
|
|
27
31
|
- Put this in a subagent's prompt, with the worktree path and ticket id filled in. While you wait on it, its updates are the dashboard's only sign the work is moving:
|
|
28
32
|
|
|
29
33
|
```text
|
|
30
|
-
Record progress on the Paseo dashboard as you work
|
|
31
|
-
|
|
32
|
-
|
|
34
|
+
Record progress on the Paseo dashboard as you work. Use the Progress tools if you
|
|
35
|
+
have them (search your tools for progress_), otherwise the paseo-progress command.
|
|
36
|
+
If a tool call says the tool isn't found, use the command for the rest of that reply.
|
|
37
|
+
- at each new stage: progress_ticket_update with id T03 and stage <stage>,
|
|
38
|
+
or: cd <worktree> && paseo-progress ticket update T03 --stage <stage>
|
|
39
|
+
- between stages: progress_ticker with "<what you are doing now>",
|
|
40
|
+
or: cd <worktree> && paseo-progress ticker set "<what you are doing now>"
|
|
33
41
|
```
|
|
34
42
|
|
|
35
43
|
Done when every ticket is done or skipped, or waits on a question.
|
|
36
44
|
|
|
37
45
|
## 3. Ask
|
|
38
46
|
|
|
39
|
-
When a decision belongs to the user, ask it with `question ask`, lettered `--option`s and your `--recommend`. Read `paseo-progress question ask --help` before your first question.
|
|
47
|
+
When a decision belongs to the user, ask it with `question ask`, lettered `--option`s and your `--recommend`. Read the `progress_question_ask` description or `paseo-progress question ask --help` before your first question. Both define a big question, which you mark with `--big "<the test it meets>"`.
|
|
40
48
|
|
|
41
49
|
The user picks the question mode in Paseo's Settings, and `question ask` prints what that mode means for this question. Do what it prints. Record the user's reply ("Q7 A", or their own words) with `question answer Q7 A --words "<their words>"`, and do what that prints too.
|
|
42
50
|
|
|
@@ -45,6 +53,6 @@ The user picks the question mode in Paseo's Settings, and `question ask` prints
|
|
|
45
53
|
|
|
46
54
|
## 4. Finish
|
|
47
55
|
|
|
48
|
-
The job is over when every ticket is done or skipped and every question is answered. While questions wait on the user, leave the run open and tell the user in chat which ones. Then
|
|
56
|
+
The job is over when every ticket is done or skipped and every question is answered. While questions wait on the user, leave the run open and tell the user in chat which ones. Then `finish` with a one-line outcome, and settle whatever it lists if it refuses.
|
|
49
57
|
|
|
50
58
|
Done when `show` says Finished with the right outcome.
|