@dreki-gg/pi-plan-mode 0.20.1 → 0.23.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/CHANGELOG.md +21 -0
- package/README.md +2 -0
- package/extensions/plan-mode/__tests__/html-render.test.ts +37 -23
- package/extensions/plan-mode/__tests__/list-plans.test.ts +253 -0
- package/extensions/plan-mode/__tests__/revise-plan.test.ts +171 -0
- package/extensions/plan-mode/__tests__/set-active-plan.test.ts +72 -0
- package/extensions/plan-mode/commands/list-plans.ts +229 -0
- package/extensions/plan-mode/constants.ts +3 -0
- package/extensions/plan-mode/html/render.ts +41 -18
- package/extensions/plan-mode/index.ts +26 -5
- package/extensions/plan-mode/prompts.ts +3 -1
- package/extensions/plan-mode/resolve-plan.ts +18 -0
- package/extensions/plan-mode/tools/plan-status.ts +2 -1
- package/extensions/plan-mode/tools/preview-prototype.ts +14 -9
- package/extensions/plan-mode/tools/revise-plan.ts +189 -0
- package/extensions/plan-mode/tools/set-active-plan.ts +86 -0
- package/package.json +2 -3
- package/skills/visual-prototype/SKILL.md +4 -2
- package/extensions/plan-mode/html/templates/prototype.pug +0 -25
- package/extensions/plan-mode/pug.d.ts +0 -5
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* revise_plan tool — sister of submit_plan, available during the plan phase.
|
|
3
|
+
*
|
|
4
|
+
* Re-opens an EXISTING plan by name and rewrites its contents in place. Use
|
|
5
|
+
* when a plan was submitted (often prematurely) and follow-up changes arrive:
|
|
6
|
+
* instead of creating a new plan, revise the existing one.
|
|
7
|
+
*
|
|
8
|
+
* All content fields are optional — only what you pass is overwritten. When
|
|
9
|
+
* `tasks` is supplied it fully defines the new task set (tasks not listed are
|
|
10
|
+
* dropped); for any task whose `id` matches an existing one, its `status`,
|
|
11
|
+
* `notes`, `origin`, and `created_at` are preserved so progress survives a
|
|
12
|
+
* re-plan. Registry status is then re-derived from task state.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
|
|
16
|
+
import { Text } from '@earendil-works/pi-tui';
|
|
17
|
+
import { Type } from 'typebox';
|
|
18
|
+
import { Effect } from 'effect';
|
|
19
|
+
import { saveHandoff } from '../storage/plan-storage.js';
|
|
20
|
+
import { writeTasksJsonl } from '../storage/task-storage.js';
|
|
21
|
+
import {
|
|
22
|
+
readPlansManifest,
|
|
23
|
+
reconcilePlanStatus,
|
|
24
|
+
upsertPlanEntry,
|
|
25
|
+
} from '../storage/plans-manifest.js';
|
|
26
|
+
import { isPlanFinalizable } from '../task-status.js';
|
|
27
|
+
import type { RunPlanIO } from '../effects/runtime.js';
|
|
28
|
+
import type { PlanData, TaskMeta, TaskRecord } from '../types.js';
|
|
29
|
+
|
|
30
|
+
export interface RevisePlanCallbacks {
|
|
31
|
+
resolvePlan: (opts: { name?: string }) => Promise<{ plan?: PlanData; candidates: string[] }>;
|
|
32
|
+
onPlanRevised: (planDir: string, plan: PlanData) => void;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function registerRevisePlanTool(
|
|
36
|
+
pi: ExtensionAPI,
|
|
37
|
+
runPlanIO: RunPlanIO,
|
|
38
|
+
callbacks: RevisePlanCallbacks,
|
|
39
|
+
): void {
|
|
40
|
+
pi.registerTool({
|
|
41
|
+
name: 'revise_plan',
|
|
42
|
+
label: 'Revise Plan',
|
|
43
|
+
description:
|
|
44
|
+
'Rewrite an existing plan in place by name — change its title, handoff, and/or tasks. Use after submit_plan when follow-up changes arrive, instead of creating a new plan.',
|
|
45
|
+
promptSnippet: 'Rewrite an existing plan in place (title/handoff/tasks) by name',
|
|
46
|
+
promptGuidelines: [
|
|
47
|
+
'Use revise_plan instead of submit_plan when a plan with the same name already exists and the user asks for follow-up changes.',
|
|
48
|
+
'All content fields are optional — pass only what changes; omitted title/handoff/tasks are left as-is.',
|
|
49
|
+
'When you pass tasks, it fully replaces the task set (tasks you omit are dropped). Status and notes are preserved for any task whose id is unchanged.',
|
|
50
|
+
],
|
|
51
|
+
parameters: Type.Object({
|
|
52
|
+
plan: Type.String({ description: 'Plan name (or .plans/<name>) to revise' }),
|
|
53
|
+
title: Type.Optional(Type.String({ description: 'New human-readable plan title' })),
|
|
54
|
+
handoff: Type.Optional(Type.String({ description: 'New markdown content for HANDOFF.md' })),
|
|
55
|
+
tasks: Type.Optional(
|
|
56
|
+
Type.Array(
|
|
57
|
+
Type.Object({
|
|
58
|
+
id: Type.String({ description: 'Stable task ID, e.g. t-001' }),
|
|
59
|
+
description: Type.String({
|
|
60
|
+
description: 'Short task label for progress display (≤60 chars)',
|
|
61
|
+
}),
|
|
62
|
+
details: Type.Optional(
|
|
63
|
+
Type.String({ description: 'Full implementation instructions for this task.' }),
|
|
64
|
+
),
|
|
65
|
+
depends_on: Type.Optional(
|
|
66
|
+
Type.Array(Type.String({ description: 'Dependency task ID' })),
|
|
67
|
+
),
|
|
68
|
+
}),
|
|
69
|
+
{ minItems: 1 },
|
|
70
|
+
),
|
|
71
|
+
),
|
|
72
|
+
}),
|
|
73
|
+
|
|
74
|
+
async execute(_toolCallId, params) {
|
|
75
|
+
const { plan, candidates } = await callbacks.resolvePlan({ name: params.plan });
|
|
76
|
+
if (!plan) {
|
|
77
|
+
const notFound: Record<string, unknown> = {
|
|
78
|
+
error: 'not_found',
|
|
79
|
+
plan: params.plan,
|
|
80
|
+
candidates,
|
|
81
|
+
};
|
|
82
|
+
const hint = candidates.length ? ` In-progress: ${candidates.join(', ')}.` : '';
|
|
83
|
+
return {
|
|
84
|
+
content: [
|
|
85
|
+
{ type: 'text' as const, text: `Plan not found: ${params.plan}.${hint}` },
|
|
86
|
+
],
|
|
87
|
+
details: notFound,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const now = new Date().toISOString();
|
|
92
|
+
const newTitle = params.title ?? plan.title;
|
|
93
|
+
const newHandoff = params.handoff ?? plan.handoff;
|
|
94
|
+
|
|
95
|
+
let tasks = plan.tasks;
|
|
96
|
+
if (params.tasks) {
|
|
97
|
+
const previous = new Map(plan.tasks.map((task) => [task.id, task]));
|
|
98
|
+
tasks = params.tasks.map((task): TaskRecord => {
|
|
99
|
+
const existing = previous.get(task.id);
|
|
100
|
+
return {
|
|
101
|
+
_type: 'task',
|
|
102
|
+
id: task.id,
|
|
103
|
+
description: task.description.slice(0, 60),
|
|
104
|
+
details: task.details ?? '',
|
|
105
|
+
// Preserve progress for tasks whose id is unchanged.
|
|
106
|
+
status: existing?.status ?? 'pending',
|
|
107
|
+
origin: existing?.origin ?? 'plan',
|
|
108
|
+
depends_on: task.depends_on,
|
|
109
|
+
notes: existing?.notes,
|
|
110
|
+
created_at: existing?.created_at ?? now,
|
|
111
|
+
updated_at: now,
|
|
112
|
+
};
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const meta: TaskMeta = {
|
|
117
|
+
_type: 'meta',
|
|
118
|
+
title: newTitle,
|
|
119
|
+
plan_name: plan.planName,
|
|
120
|
+
created_at: plan.tasks[0]?.created_at ?? now,
|
|
121
|
+
};
|
|
122
|
+
const revised: PlanData = {
|
|
123
|
+
title: newTitle,
|
|
124
|
+
planName: plan.planName,
|
|
125
|
+
handoff: newHandoff,
|
|
126
|
+
tasks,
|
|
127
|
+
};
|
|
128
|
+
const planDir = `.plans/${plan.planName}`;
|
|
129
|
+
|
|
130
|
+
await runPlanIO(
|
|
131
|
+
Effect.gen(function* () {
|
|
132
|
+
yield* writeTasksJsonl(planDir, meta, tasks);
|
|
133
|
+
yield* saveHandoff(planDir, newHandoff);
|
|
134
|
+
// Persist a title change without flipping status, then let task state
|
|
135
|
+
// re-derive in-progress ⇄ done (never clobbers superseded/abandoned).
|
|
136
|
+
const manifest = yield* readPlansManifest();
|
|
137
|
+
const current = manifest.find((entry) => entry.name === plan.planName);
|
|
138
|
+
yield* upsertPlanEntry(plan.planName, {
|
|
139
|
+
status: current?.status ?? 'in-progress',
|
|
140
|
+
title: newTitle,
|
|
141
|
+
});
|
|
142
|
+
yield* reconcilePlanStatus(plan.planName, isPlanFinalizable(tasks), newTitle);
|
|
143
|
+
}),
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
callbacks.onPlanRevised(planDir, revised);
|
|
147
|
+
|
|
148
|
+
const changed = [
|
|
149
|
+
params.title ? 'title' : undefined,
|
|
150
|
+
params.handoff ? 'handoff' : undefined,
|
|
151
|
+
params.tasks ? 'tasks' : undefined,
|
|
152
|
+
].filter(Boolean);
|
|
153
|
+
return {
|
|
154
|
+
content: [
|
|
155
|
+
{
|
|
156
|
+
type: 'text' as const,
|
|
157
|
+
text: `Plan "${newTitle}" revised (${changed.join(', ') || 'no changes'}) in ${planDir}.`,
|
|
158
|
+
},
|
|
159
|
+
],
|
|
160
|
+
details: { planDir, plan: revised, changed },
|
|
161
|
+
};
|
|
162
|
+
},
|
|
163
|
+
|
|
164
|
+
renderCall(args, theme) {
|
|
165
|
+
const name = (args as { plan?: string }).plan ?? 'plan';
|
|
166
|
+
let content = theme.fg('toolTitle', theme.bold('revise_plan '));
|
|
167
|
+
content += theme.fg('accent', name);
|
|
168
|
+
return new Text(content, 0, 0);
|
|
169
|
+
},
|
|
170
|
+
|
|
171
|
+
renderResult(result, _options, theme) {
|
|
172
|
+
const details = result.details as
|
|
173
|
+
| { plan?: PlanData; changed?: string[] }
|
|
174
|
+
| undefined;
|
|
175
|
+
const plan = details?.plan;
|
|
176
|
+
if (!plan) return new Text(theme.fg('success', '✓ Plan revised'), 0, 0);
|
|
177
|
+
const changed = details?.changed?.length ? ` (${details.changed.join(', ')})` : '';
|
|
178
|
+
const lines = [
|
|
179
|
+
theme.fg('success', '✓ ') +
|
|
180
|
+
theme.fg('accent', theme.bold(plan.title)) +
|
|
181
|
+
theme.fg('dim', changed),
|
|
182
|
+
'',
|
|
183
|
+
];
|
|
184
|
+
for (const task of plan.tasks)
|
|
185
|
+
lines.push(` ${theme.fg('muted', task.id)} ${task.description}`);
|
|
186
|
+
return new Text(lines.join('\n'), 0, 0);
|
|
187
|
+
},
|
|
188
|
+
});
|
|
189
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* set_active_plan tool — pin a plan as the active one.
|
|
3
|
+
*
|
|
4
|
+
* The tool-callable form of `/plan focus <name>`. Attaches the named plan into
|
|
5
|
+
* session state so subsequent plan_status / update_task / add_task calls target
|
|
6
|
+
* it. Use when plan_status reports multiple in-progress plans and the agent
|
|
7
|
+
* needs to select one programmatically instead of waiting for `/plan focus`.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
|
|
11
|
+
import { Text } from '@earendil-works/pi-tui';
|
|
12
|
+
import { Type } from 'typebox';
|
|
13
|
+
import type { ResolvedPlan } from '../resolve-plan.js';
|
|
14
|
+
|
|
15
|
+
export interface SetActivePlanCallbacks {
|
|
16
|
+
/** Pin the named plan into state (clears stale plan, re-attaches from disk). */
|
|
17
|
+
setActivePlan: (name: string) => Promise<ResolvedPlan>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function registerSetActivePlanTool(
|
|
21
|
+
pi: ExtensionAPI,
|
|
22
|
+
callbacks: SetActivePlanCallbacks,
|
|
23
|
+
): void {
|
|
24
|
+
pi.registerTool({
|
|
25
|
+
name: 'set_active_plan',
|
|
26
|
+
label: 'Set Active Plan',
|
|
27
|
+
description:
|
|
28
|
+
'Pin a plan as the active one so plan_status / update_task / add_task target it. Use when multiple plans are in-progress and you need to select one.',
|
|
29
|
+
promptSnippet: 'Pin a plan as active so tracking calls target it',
|
|
30
|
+
promptGuidelines: [
|
|
31
|
+
'Call set_active_plan when plan_status reports multiple in-progress plans and you need to select one before update_task / add_task.',
|
|
32
|
+
'It is the tool form of the /plan focus command — it attaches the named plan into session state.',
|
|
33
|
+
],
|
|
34
|
+
parameters: Type.Object({
|
|
35
|
+
plan: Type.String({ description: 'Plan name (or .plans/<name>) to pin as active' }),
|
|
36
|
+
}),
|
|
37
|
+
|
|
38
|
+
async execute(_toolCallId, params) {
|
|
39
|
+
const { plan, candidates } = await callbacks.setActivePlan(params.plan);
|
|
40
|
+
if (!plan) {
|
|
41
|
+
const hint = candidates.length ? ` In-progress: ${candidates.join(', ')}.` : '';
|
|
42
|
+
const notFound: Record<string, unknown> = {
|
|
43
|
+
error: 'not_found',
|
|
44
|
+
plan: params.plan,
|
|
45
|
+
candidates,
|
|
46
|
+
};
|
|
47
|
+
return {
|
|
48
|
+
content: [{ type: 'text' as const, text: `Plan not found: ${params.plan}.${hint}` }],
|
|
49
|
+
details: notFound,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
content: [
|
|
55
|
+
{
|
|
56
|
+
type: 'text' as const,
|
|
57
|
+
text: `Active plan set to ${plan.title} (${plan.planName}).`,
|
|
58
|
+
},
|
|
59
|
+
],
|
|
60
|
+
details: { active: true, plan_name: plan.planName, title: plan.title },
|
|
61
|
+
};
|
|
62
|
+
},
|
|
63
|
+
|
|
64
|
+
renderCall(args, theme) {
|
|
65
|
+
const name = (args as { plan?: string }).plan ?? 'plan';
|
|
66
|
+
return new Text(
|
|
67
|
+
theme.fg('toolTitle', theme.bold('set_active_plan ')) + theme.fg('accent', name),
|
|
68
|
+
0,
|
|
69
|
+
0,
|
|
70
|
+
);
|
|
71
|
+
},
|
|
72
|
+
|
|
73
|
+
renderResult(result, _options, theme) {
|
|
74
|
+
const details = result.details as
|
|
75
|
+
| { active?: boolean; plan_name?: string; title?: string }
|
|
76
|
+
| undefined;
|
|
77
|
+
if (!details?.active) return new Text(theme.fg('dim', 'No plan set'), 0, 0);
|
|
78
|
+
return new Text(
|
|
79
|
+
theme.fg('success', '✓ ') +
|
|
80
|
+
theme.fg('accent', theme.bold(details.title ?? details.plan_name ?? 'plan')),
|
|
81
|
+
0,
|
|
82
|
+
0,
|
|
83
|
+
);
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dreki-gg/pi-plan-mode",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.23.0",
|
|
4
4
|
"description": "Two-phase planning workflow for pi — plan with claude-opus-4-6:medium, execute with gpt-5.5:low, with .plans/ file-based handoff",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package"
|
|
@@ -41,8 +41,7 @@
|
|
|
41
41
|
},
|
|
42
42
|
"dependencies": {
|
|
43
43
|
"@dreki-gg/pi-command-sandbox": "^0.3.0",
|
|
44
|
-
"effect": "^3.21.2"
|
|
45
|
-
"pug": "^3.0.3"
|
|
44
|
+
"effect": "^3.21.2"
|
|
46
45
|
},
|
|
47
46
|
"devDependencies": {
|
|
48
47
|
"@types/node": "24",
|
|
@@ -30,9 +30,11 @@ Call `preview_prototype` with:
|
|
|
30
30
|
|
|
31
31
|
- `title` — short name for the prototype
|
|
32
32
|
- `intent` — one line describing what it shows
|
|
33
|
-
- `
|
|
33
|
+
- `html` — a complete, self-contained HTML document you author **with full freedom**. There is no template engine and no imposed theme: pick your own markup, fonts, colors, layout, and inline `<style>`/`<script>`. Assume nothing about a host page.
|
|
34
34
|
|
|
35
|
-
The tool
|
|
35
|
+
The tool persists your HTML to `.plans/_prototypes/<slug>.html` and opens it for review. It does not wrap, restyle, or theme your markup — what you write is what the user sees. (A bare fragment is tolerated and dropped into a minimal unstyled shell, but prefer sending a full document.)
|
|
36
|
+
|
|
37
|
+
**Avoid generic boilerplate.** A dark dashboard with a purple accent and a card is not a design — it is slop. Design something that fits the actual product. For real design taste, delegate the markup to the `ux-designer` subagent and pass its HTML straight through `preview_prototype`.
|
|
36
38
|
|
|
37
39
|
### 3. Get a reaction before submitting
|
|
38
40
|
|
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
doctype html
|
|
2
|
-
html(lang="en")
|
|
3
|
-
head
|
|
4
|
-
meta(charset="utf-8")
|
|
5
|
-
meta(name="viewport" content="width=device-width, initial-scale=1")
|
|
6
|
-
title= title
|
|
7
|
-
style.
|
|
8
|
-
:root { color-scheme: dark; --bg:#0b0d12; --panel:#11141b; --muted:#8b93a7; --text:#eef1f7; --line:#242936; --accent:#8b5cf6; }
|
|
9
|
-
* { box-sizing: border-box; }
|
|
10
|
-
body { margin:0; background:radial-gradient(circle at top left,#1c1730,transparent 34rem),var(--bg); color:var(--text); font:14px/1.5 Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
|
|
11
|
-
main { width:min(1040px, calc(100% - 48px)); margin:48px auto; }
|
|
12
|
-
header { margin-bottom:24px; }
|
|
13
|
-
h1 { font-size:30px; line-height:1.1; margin:0 0 8px; letter-spacing:-0.04em; }
|
|
14
|
-
.intent { color:var(--muted); margin:0; }
|
|
15
|
-
.badge { display:inline-block; border:1px solid var(--line); border-radius:999px; padding:5px 10px; background:rgba(255,255,255,0.03); color:var(--muted); font-size:12px; margin-bottom:14px; }
|
|
16
|
-
.prototype { background:rgba(17,20,27,0.82); border:1px solid var(--line); border-radius:18px; padding:22px; box-shadow:0 24px 80px rgba(0,0,0,0.24); }
|
|
17
|
-
body
|
|
18
|
-
main
|
|
19
|
-
header
|
|
20
|
-
span.badge Prototype · #{generatedAt}
|
|
21
|
-
h1= title
|
|
22
|
-
if intent
|
|
23
|
-
p.intent= intent
|
|
24
|
-
section.prototype
|
|
25
|
-
!= prototypeHtml
|