@justjoshn/pi-pstack 0.15.36-pi.11 → 0.15.36-pi.13
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/CHANGELOG.md +28 -0
- package/README.md +14 -14
- package/agents/thermo-nuclear-code-quality-review.md +1 -1
- package/automations/benny/FOR_AGENTS.md +1 -1
- package/automations/benny/skills/reproduce-and-fix-issues/SKILL.md +3 -3
- package/automations/benny/skills/setup-benny/SKILL.md +3 -3
- package/automations/benny/skills/triage-issue-reports/SKILL.md +2 -2
- package/docs/native-gaps.md +1 -1
- package/docs/parity-status.md +55 -11
- package/package.json +4 -3
- package/skills/make-bot-ui/SKILL.md +1 -1
- package/skills/pr-review-canvas/SKILL.md +1 -1
- package/skills/statechart/scripts/test/cli.test.ts +10 -1
- package/skills/teach/SKILL.md +1 -1
- package/src/ask.ts +24 -10
- package/src/child-env.ts +4 -0
- package/src/generate-image.ts +98 -0
- package/src/index.ts +19 -11
- package/src/loop.ts +113 -60
- package/src/mode.ts +84 -46
- package/src/plan.ts +338 -46
- package/src/process-lock.ts +76 -0
- package/src/readonly-shell.ts +6 -8
- package/src/sections.ts +6 -1
- package/src/subscriptions/durable-store.ts +2 -75
- package/src/subscriptions/index.ts +2 -17
- package/src/subscriptions/supervisor.ts +1 -1
- package/src/task/agents.ts +39 -3
- package/src/task/card.ts +4 -2
- package/src/task/format.ts +17 -3
- package/src/task/records.ts +37 -32
- package/src/task/registry.ts +2 -1
- package/src/task/runner.ts +4 -1
- package/src/task/stop.ts +3 -3
- package/src/task/tool.ts +62 -22
- package/src/todo.ts +50 -7
- package/src/path-skills.ts +0 -104
package/src/todo.ts
CHANGED
|
@@ -8,18 +8,21 @@ export interface TodoItem {
|
|
|
8
8
|
id: string;
|
|
9
9
|
content: string;
|
|
10
10
|
status: TodoStatus;
|
|
11
|
+
dependencies?: string[];
|
|
11
12
|
}
|
|
12
13
|
|
|
13
14
|
export interface TodoInput {
|
|
14
15
|
id: string;
|
|
15
16
|
content?: string;
|
|
16
17
|
status?: TodoStatus;
|
|
18
|
+
dependencies?: string[];
|
|
17
19
|
}
|
|
18
20
|
|
|
19
21
|
interface TodoDetails {
|
|
20
22
|
todos: TodoItem[];
|
|
21
23
|
}
|
|
22
24
|
|
|
25
|
+
const TODO_ENTRY = 'pstack/todos';
|
|
23
26
|
const WIDGET_KEY = 'pstack-todos';
|
|
24
27
|
const MARKS: Record<TodoStatus, string> = { pending: '[ ]', in_progress: '[>]', completed: '[x]', cancelled: '[-]' };
|
|
25
28
|
const GLYPHS: Record<TodoStatus, string> = { pending: '○', in_progress: '▶', completed: '✓', cancelled: '✗' };
|
|
@@ -43,7 +46,8 @@ function complete(item: TodoInput, existing: TodoItem | undefined): TodoItem {
|
|
|
43
46
|
const content = item.content ?? existing?.content;
|
|
44
47
|
const status = item.status ?? existing?.status;
|
|
45
48
|
if (content === undefined || status === undefined) throw new Error(`Todo "${item.id}" is new, so it needs both content and status.`);
|
|
46
|
-
|
|
49
|
+
const dependencies = item.dependencies ?? existing?.dependencies;
|
|
50
|
+
return { id: item.id, content, status, ...(dependencies === undefined ? {} : { dependencies }) };
|
|
47
51
|
}
|
|
48
52
|
|
|
49
53
|
/** TodoWrite: merge updates by id and appends new ids; otherwise the call replaces the list. */
|
|
@@ -68,18 +72,25 @@ export function formatTodos(todos: readonly TodoItem[]): string {
|
|
|
68
72
|
const done = todos.filter((item) => item.status === 'completed').length;
|
|
69
73
|
const active = todos.filter((item) => item.status === 'in_progress').length;
|
|
70
74
|
const header = `Todos (${done}/${todos.length} completed${active > 0 ? `, ${active} in progress` : ''}):`;
|
|
71
|
-
return [header, ...todos.map((item) => `${MARKS[item.status]} ${item.id}: ${item.content}`)].join('\n');
|
|
75
|
+
return [header, ...todos.map((item) => `${MARKS[item.status]} ${item.id}: ${item.content}${item.dependencies === undefined || item.dependencies.length === 0 ? '' : ` (depends on ${item.dependencies.join(', ')})`}`)].join('\n');
|
|
72
76
|
}
|
|
73
77
|
|
|
74
78
|
interface BranchEntry {
|
|
75
79
|
type: string;
|
|
80
|
+
customType?: string;
|
|
81
|
+
data?: unknown;
|
|
76
82
|
message?: { role?: string; toolName?: string; isError?: boolean; details?: unknown };
|
|
77
83
|
}
|
|
78
84
|
|
|
79
|
-
/** The list as of the active branch
|
|
85
|
+
/** The list as of the active branch, from a plan import or the last successful TodoWrite. */
|
|
80
86
|
export function todosFromBranch(entries: readonly BranchEntry[]): TodoItem[] {
|
|
81
87
|
for (let index = entries.length - 1; index >= 0; index -= 1) {
|
|
82
|
-
const
|
|
88
|
+
const entry = entries[index];
|
|
89
|
+
if (entry?.type === 'custom' && entry.customType === TODO_ENTRY) {
|
|
90
|
+
const todos = (entry.data as Partial<TodoDetails> | undefined)?.todos;
|
|
91
|
+
if (Array.isArray(todos)) return todos;
|
|
92
|
+
}
|
|
93
|
+
const message = entry?.message;
|
|
83
94
|
const todos = (message?.details as Partial<TodoDetails> | undefined)?.todos;
|
|
84
95
|
if (entries[index]?.type === 'message' && message?.role === 'toolResult' && message.toolName === 'TodoWrite' && message.isError !== true && Array.isArray(todos)) return todos;
|
|
85
96
|
}
|
|
@@ -96,7 +107,27 @@ export function todoSection(todos: readonly TodoItem[]): string | undefined {
|
|
|
96
107
|
return todos.length === 0 ? undefined : `Current todo list, owned by this session:\n${formatTodos(todos)}`;
|
|
97
108
|
}
|
|
98
109
|
|
|
99
|
-
|
|
110
|
+
// Cursor's TodoWrite rules: when to keep a list, the four states, one task in progress at a time, and batching.
|
|
111
|
+
const TODO_DESCRIPTION = [
|
|
112
|
+
'Use this tool to create and manage a structured task list for your current coding session. It tracks progress, organizes complex tasks, and shows the user what you are doing. Each item has an id, content, and status.',
|
|
113
|
+
'',
|
|
114
|
+
'Other than when you first create the list, do not tell the user you are updating todos. Just update them.',
|
|
115
|
+
'',
|
|
116
|
+
'Use it for:',
|
|
117
|
+
'- A task with three or more distinct steps, or non-trivial work that needs planning.',
|
|
118
|
+
'- An explicit request for a todo list, or several tasks given at once (numbered or comma-separated).',
|
|
119
|
+
'- New instructions: capture the requirements as todos.',
|
|
120
|
+
'- A finished task: mark it completed (merge=true) and add any follow-ups.',
|
|
121
|
+
'- A task you start: mark it in_progress.',
|
|
122
|
+
'',
|
|
123
|
+
'Skip it for a single straightforward task, a trivial task done in fewer than three steps, or a purely conversational or informational request. Do not add a task to test a change unless asked.',
|
|
124
|
+
'',
|
|
125
|
+
'States: pending (not started), in_progress (working on it now), completed (finished), cancelled (no longer needed). Update states as you work, mark a task completed as soon as it is done, keep only one task in_progress at a time, and finish the current task before you start a new one. Write specific, actionable items with clear names.',
|
|
126
|
+
'',
|
|
127
|
+
'Prefer creating the first todo as in_progress, and start on it with tool calls in the same batch as the write. Batch todo updates with your other tool calls.',
|
|
128
|
+
].join('\n');
|
|
129
|
+
|
|
130
|
+
export function registerTodoWrite(pi: ExtensionAPI, options: { onWrite?: (todos: readonly TodoItem[]) => void } = {}) {
|
|
100
131
|
let todos: TodoItem[] = [];
|
|
101
132
|
const show = (ctx: ExtensionContext) => {
|
|
102
133
|
if (ctx.hasUI) ctx.ui.setWidget(WIDGET_KEY, widgetLines(todos));
|
|
@@ -111,7 +142,7 @@ export function registerTodoWrite(pi: ExtensionAPI): { section: () => string | u
|
|
|
111
142
|
pi.registerTool({
|
|
112
143
|
name: 'TodoWrite',
|
|
113
144
|
label: 'Todos',
|
|
114
|
-
description:
|
|
145
|
+
description: TODO_DESCRIPTION,
|
|
115
146
|
promptSnippet: 'Create and update the todo list for this session',
|
|
116
147
|
promptGuidelines: ['When a skill or playbook says to open a todolist or todo list, call TodoWrite before other work, with the steps it names as the first items.'],
|
|
117
148
|
parameters: TodoParams,
|
|
@@ -119,9 +150,21 @@ export function registerTodoWrite(pi: ExtensionAPI): { section: () => string | u
|
|
|
119
150
|
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
120
151
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
121
152
|
todos = applyTodoWrite(todos, params.todos, params.merge);
|
|
153
|
+
try {
|
|
154
|
+
options.onWrite?.(todos);
|
|
155
|
+
} catch (error) {
|
|
156
|
+
ctx.ui.notify(`The todo list was updated, but its plan file could not be synchronized: ${error instanceof Error ? error.message : String(error)}`, 'warning');
|
|
157
|
+
}
|
|
122
158
|
show(ctx);
|
|
123
159
|
return { content: [{ type: 'text', text: formatTodos(todos) }], details: { todos } };
|
|
124
160
|
},
|
|
125
161
|
});
|
|
126
|
-
return {
|
|
162
|
+
return {
|
|
163
|
+
section: () => todoSection(todos),
|
|
164
|
+
replace: (items: readonly TodoItem[], ctx: ExtensionContext) => {
|
|
165
|
+
todos = applyTodoWrite([], items, false);
|
|
166
|
+
pi.appendEntry(TODO_ENTRY, { todos });
|
|
167
|
+
show(ctx);
|
|
168
|
+
},
|
|
169
|
+
};
|
|
127
170
|
}
|
package/src/path-skills.ts
DELETED
|
@@ -1,104 +0,0 @@
|
|
|
1
|
-
// Cursor attaches a skill to the conversation when the agent reads a file matching its `paths`/`globs` frontmatter.
|
|
2
|
-
// Pi has no path-scoped skills, so this module tracks the files a session reads or edits and attaches the matching
|
|
3
|
-
// skill body once per session, in the same <skill> block shape Pi's own /skill:name expansion uses.
|
|
4
|
-
import { existsSync, readFileSync } from 'node:fs';
|
|
5
|
-
import { dirname, isAbsolute, matchesGlob, relative, sep } from 'node:path';
|
|
6
|
-
|
|
7
|
-
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
|
|
8
|
-
import { parseFrontmatter } from '@earendil-works/pi-coding-agent';
|
|
9
|
-
|
|
10
|
-
/** A skill whose frontmatter scopes it to file patterns. */
|
|
11
|
-
export interface PathSkill {
|
|
12
|
-
name: string;
|
|
13
|
-
file: string;
|
|
14
|
-
globs: string[];
|
|
15
|
-
body: string;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
const TOUCHED_TOOLS = new Set(['read', 'edit', 'write']);
|
|
19
|
-
const SECTION_LIMIT = 40_000;
|
|
20
|
-
|
|
21
|
-
function globsOf(value: unknown): string[] {
|
|
22
|
-
if (typeof value === 'string')
|
|
23
|
-
return value
|
|
24
|
-
.split(',')
|
|
25
|
-
.map((part) => part.trim())
|
|
26
|
-
.filter((part) => part !== '');
|
|
27
|
-
if (!Array.isArray(value)) return [];
|
|
28
|
-
return value.filter((part): part is string => typeof part === 'string' && part.trim() !== '').map((part) => part.trim());
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
/** Every loaded skill that declares `paths` (Cursor's name for skill globs) or `globs`. */
|
|
32
|
-
export function loadPathSkills(commands: readonly { name: string; source: string; sourceInfo: { path: string } }[]): PathSkill[] {
|
|
33
|
-
const skills: PathSkill[] = [];
|
|
34
|
-
for (const command of commands) {
|
|
35
|
-
if (command.source !== 'skill' || !command.name.startsWith('skill:')) continue;
|
|
36
|
-
const file = command.sourceInfo.path;
|
|
37
|
-
if (!existsSync(file)) continue;
|
|
38
|
-
const { frontmatter, body } = parseFrontmatter(readFileSync(file, 'utf8'));
|
|
39
|
-
// `metadata` is the portable Agent Skills extension point; Cursor's own field is the top-level `paths`/`globs`.
|
|
40
|
-
const metadata = typeof frontmatter['metadata'] === 'object' && frontmatter['metadata'] !== null ? (frontmatter['metadata'] as Record<string, unknown>) : {};
|
|
41
|
-
const globs = [...new Set([...globsOf(frontmatter['paths']), ...globsOf(frontmatter['globs']), ...globsOf(metadata['paths']), ...globsOf(metadata['globs'])])];
|
|
42
|
-
if (globs.length === 0) continue;
|
|
43
|
-
skills.push({ name: command.name.slice('skill:'.length), file, globs, body: body.trim() });
|
|
44
|
-
}
|
|
45
|
-
return skills;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
const slash = (path: string) => path.split(sep).join('/');
|
|
49
|
-
|
|
50
|
-
/** The skills whose patterns match a path the session read or edited. Paths outside the workspace are ignored. */
|
|
51
|
-
export function matchPathSkills(skills: readonly PathSkill[], paths: Iterable<string>, cwd: string): PathSkill[] {
|
|
52
|
-
const relativePaths = [...paths].map((path) => slash(isAbsolute(path) ? relative(cwd, path) : path)).filter((path) => path !== '' && path !== '..' && !path.startsWith('../'));
|
|
53
|
-
return skills.filter((skill) => relativePaths.some((path) => skill.globs.some((glob) => matchesGlob(path, glob))));
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
/** The attached bodies as <skill> blocks, in the shape Pi's /skill:name expansion builds. */
|
|
57
|
-
export function pathSkillsSection(skills: readonly PathSkill[]): string | undefined {
|
|
58
|
-
if (skills.length === 0) return undefined;
|
|
59
|
-
const blocks: string[] = [];
|
|
60
|
-
let size = 0;
|
|
61
|
-
for (const skill of skills) {
|
|
62
|
-
const block = [`<skill name="${skill.name}" location="${skill.file}">`, `References are relative to ${dirname(skill.file)}.`, '', skill.body, '</skill>'].join('\n');
|
|
63
|
-
if (size + block.length > SECTION_LIMIT) break;
|
|
64
|
-
size += block.length;
|
|
65
|
-
blocks.push(block);
|
|
66
|
-
}
|
|
67
|
-
return blocks.length === 0 ? undefined : blocks.join('\n\n');
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
export interface PathSkillsHandle {
|
|
71
|
-
/** The skills to attach to the next request, empty until a matching file has been read or edited. */
|
|
72
|
-
section: () => string | undefined;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/** Tracks read and edit paths and attaches each matching skill once per session. */
|
|
76
|
-
export function createPathSkills(pi: ExtensionAPI): PathSkillsHandle {
|
|
77
|
-
let catalog: PathSkill[] | undefined;
|
|
78
|
-
let cwd = process.cwd();
|
|
79
|
-
let touched = new Set<string>();
|
|
80
|
-
const injected = new Set<string>();
|
|
81
|
-
|
|
82
|
-
pi.on('session_start', (_event, ctx) => {
|
|
83
|
-
catalog = undefined;
|
|
84
|
-
touched = new Set();
|
|
85
|
-
injected.clear();
|
|
86
|
-
cwd = ctx.cwd;
|
|
87
|
-
});
|
|
88
|
-
pi.on('tool_call', (event) => {
|
|
89
|
-
if (!TOUCHED_TOOLS.has(event.toolName)) return;
|
|
90
|
-
const path = (event.input as { path?: unknown }).path;
|
|
91
|
-
if (typeof path === 'string' && path !== '') touched.add(path);
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
return {
|
|
95
|
-
section: () => {
|
|
96
|
-
if (touched.size === 0) return undefined;
|
|
97
|
-
catalog ??= loadPathSkills(pi.getCommands());
|
|
98
|
-
const matches = matchPathSkills(catalog, touched, cwd).filter((skill) => !injected.has(skill.name));
|
|
99
|
-
if (matches.length === 0) return undefined;
|
|
100
|
-
for (const skill of matches) injected.add(skill.name);
|
|
101
|
-
return pathSkillsSection(matches);
|
|
102
|
-
},
|
|
103
|
-
};
|
|
104
|
-
}
|