@pify/plan-mode 0.1.0 → 0.3.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/README.md +13 -0
- package/extensions/plan-mode.ts +134 -6
- package/package.json +13 -5
- package/src/export.ts +153 -0
- package/src/plans.ts +17 -1
- package/src/state.ts +13 -0
- package/src/steps.ts +123 -0
- package/src/types.ts +5 -0
package/README.md
CHANGED
|
@@ -24,6 +24,9 @@ Part of the [Pify suite](https://github.com/pifydev). Install with [`pify instal
|
|
|
24
24
|
/plan # toggle plan mode
|
|
25
25
|
/plan add oauth login # enter + start planning this
|
|
26
26
|
/plan off # leave without approval
|
|
27
|
+
/plan list # saved plans in .pi/plans/ (v0.2)
|
|
28
|
+
/plan steps # progress through the approved plan (v0.3)
|
|
29
|
+
/plan export [file] # standalone HTML next to the plan (v0.3)
|
|
27
30
|
pi --plan # start a session already in plan mode
|
|
28
31
|
```
|
|
29
32
|
|
|
@@ -36,6 +39,16 @@ pi remove npm:@narumitw/pi-plan-mode
|
|
|
36
39
|
pify install plan-mode
|
|
37
40
|
```
|
|
38
41
|
|
|
42
|
+
## After approval (v0.3)
|
|
43
|
+
|
|
44
|
+
An approved plan becomes a tracked step list rather than a document the agent re-reads each turn — which is how plans get quietly abandoned halfway. Steps are parsed from the markdown the agent already wrote (numbered list, or the bullets under a *Steps*-ish heading), so there is no second source of truth.
|
|
45
|
+
|
|
46
|
+
- `plan_step_done(index, evidence)` — the agent ticks off one step at a time, with evidence, and gets the next one back. Completing out of order is allowed but reported: the answer names the steps still open before it.
|
|
47
|
+
- The status badge follows execution — `📋 2/7 steps` — instead of disappearing at approval.
|
|
48
|
+
- `/plan steps` shows the list; progress survives `/reload` and branch switches with the rest of the plan state.
|
|
49
|
+
|
|
50
|
+
`/plan export` writes a self-contained HTML file next to the plan: no assets, no network, everything escaped before rendering — a plan containing HTML is shown as text, not executed.
|
|
51
|
+
|
|
39
52
|
## License
|
|
40
53
|
|
|
41
54
|
MIT © [Pify maintainers](https://github.com/pifydev)
|
package/extensions/plan-mode.ts
CHANGED
|
@@ -26,7 +26,20 @@ import type {
|
|
|
26
26
|
import { Type } from "typebox";
|
|
27
27
|
|
|
28
28
|
import { classifyToolCall } from "../src/policy.ts";
|
|
29
|
-
import {
|
|
29
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
30
|
+
import { basename, isAbsolute, join } from "node:path";
|
|
31
|
+
|
|
32
|
+
import { htmlPathFor, renderPlanHtml } from "../src/export.ts";
|
|
33
|
+
import { createPlanFile, listPlanFiles, plansDir } from "../src/plans.ts";
|
|
34
|
+
import {
|
|
35
|
+
completeStep,
|
|
36
|
+
formatSteps,
|
|
37
|
+
nextStep,
|
|
38
|
+
parseSteps,
|
|
39
|
+
progressLine,
|
|
40
|
+
skippedBefore,
|
|
41
|
+
type PlanStep,
|
|
42
|
+
} from "../src/steps.ts";
|
|
30
43
|
import {
|
|
31
44
|
ENTER_REMINDER,
|
|
32
45
|
EXIT_REMINDER,
|
|
@@ -38,6 +51,16 @@ import { INITIAL_STATE, PLAN_THINKING, type PlanState } from "../src/types.ts";
|
|
|
38
51
|
|
|
39
52
|
const REMINDER_TYPE = "plan-mode-reminder";
|
|
40
53
|
|
|
54
|
+
/** Read a file, or "" when it is missing/unreadable. */
|
|
55
|
+
function readFileSafe(file: string | null): string {
|
|
56
|
+
if (!file) return "";
|
|
57
|
+
try {
|
|
58
|
+
return readFileSync(file, "utf8");
|
|
59
|
+
} catch {
|
|
60
|
+
return "";
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
41
64
|
type UiContext = ExtensionContext;
|
|
42
65
|
|
|
43
66
|
export default function planMode(pi: ExtensionAPI) {
|
|
@@ -54,7 +77,14 @@ export default function planMode(pi: ExtensionAPI) {
|
|
|
54
77
|
|
|
55
78
|
function updateBadge(ctx: UiContext): void {
|
|
56
79
|
if (!ctx.hasUI) return;
|
|
57
|
-
|
|
80
|
+
if (state.active) {
|
|
81
|
+
ctx.ui.setStatus("plan", "📋 plan");
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
// After approval the badge follows execution instead of disappearing —
|
|
85
|
+
// that is exactly when a plan gets quietly abandoned halfway.
|
|
86
|
+
const open = state.steps.filter((s) => !s.done).length;
|
|
87
|
+
ctx.ui.setStatus("plan", open > 0 ? `📋 ${progressLine(state.steps)}` : undefined);
|
|
58
88
|
}
|
|
59
89
|
|
|
60
90
|
function notify(ctx: UiContext, message: string, level: "info" | "warning" | "error"): void {
|
|
@@ -75,6 +105,7 @@ export default function planMode(pi: ExtensionAPI) {
|
|
|
75
105
|
planFile: null,
|
|
76
106
|
buildThinking,
|
|
77
107
|
enteredAt: Date.now(),
|
|
108
|
+
steps: [],
|
|
78
109
|
});
|
|
79
110
|
try {
|
|
80
111
|
// Planning earns deeper thought (bacnh85); restored on exit.
|
|
@@ -87,10 +118,15 @@ export default function planMode(pi: ExtensionAPI) {
|
|
|
87
118
|
return true;
|
|
88
119
|
}
|
|
89
120
|
|
|
90
|
-
|
|
121
|
+
/**
|
|
122
|
+
* Leave plan mode. `keepSteps` carries the approved plan's step list into
|
|
123
|
+
* execution — the tracker only exists after an approval, never after a
|
|
124
|
+
* discard.
|
|
125
|
+
*/
|
|
126
|
+
function leavePlanMode(ctx: UiContext, keepSteps: PlanStep[] = []): void {
|
|
91
127
|
if (!state.active) return;
|
|
92
128
|
const restore = state.buildThinking;
|
|
93
|
-
commit(ctx, { ...INITIAL_STATE });
|
|
129
|
+
commit(ctx, { ...INITIAL_STATE, steps: keepSteps });
|
|
94
130
|
approvedTools.clear();
|
|
95
131
|
if (restore) {
|
|
96
132
|
try {
|
|
@@ -210,6 +246,42 @@ export default function planMode(pi: ExtensionAPI) {
|
|
|
210
246
|
},
|
|
211
247
|
});
|
|
212
248
|
|
|
249
|
+
pi.registerTool({
|
|
250
|
+
name: "plan_step_done",
|
|
251
|
+
label: "Plan step done",
|
|
252
|
+
description:
|
|
253
|
+
"Mark one step of the approved plan complete and get the next one. Call it as you finish each " +
|
|
254
|
+
"step, with evidence of what you verified — not at the end for all steps at once. Only available " +
|
|
255
|
+
"after a plan was approved.",
|
|
256
|
+
parameters: Type.Object({
|
|
257
|
+
index: Type.Number({ description: "1-based step number from the plan" }),
|
|
258
|
+
evidence: Type.String({ description: "What you verified for this step (command output, file state)" }),
|
|
259
|
+
}),
|
|
260
|
+
async execute(_id, params: { index: number; evidence: string }, _signal, _onUpdate, ctx) {
|
|
261
|
+
if (state.steps.length === 0) {
|
|
262
|
+
throw new Error("No approved plan is being tracked. plan_step_done only works after exit_plan_mode approval.");
|
|
263
|
+
}
|
|
264
|
+
if (!params.evidence.trim()) {
|
|
265
|
+
throw new Error("plan_step_done requires evidence: what you verified for this step.");
|
|
266
|
+
}
|
|
267
|
+
const result = completeStep(state.steps, params.index);
|
|
268
|
+
if (result.error) throw new Error(result.error);
|
|
269
|
+
|
|
270
|
+
const skipped = skippedBefore(state.steps, params.index);
|
|
271
|
+
commit(ctx as UiContext, { ...state, steps: result.steps });
|
|
272
|
+
|
|
273
|
+
const next = nextStep(result.steps);
|
|
274
|
+
const lines = [
|
|
275
|
+
`Step #${params.index} done (${progressLine(result.steps)}).`,
|
|
276
|
+
skipped.length > 0
|
|
277
|
+
? `Still open before it: ${skipped.map((s) => `#${s.index}`).join(", ")} — go back unless they no longer apply.`
|
|
278
|
+
: "",
|
|
279
|
+
next ? `Next: #${next.index} ${next.text}` : "All steps complete. Report the result to the user.",
|
|
280
|
+
].filter(Boolean);
|
|
281
|
+
return { content: [{ type: "text", text: lines.join("\n") }], details: { steps: result.steps } };
|
|
282
|
+
},
|
|
283
|
+
});
|
|
284
|
+
|
|
213
285
|
const APPROVE_HERE = "Approve — implement here";
|
|
214
286
|
const APPROVE_FRESH = "Approve — implement in a fresh session";
|
|
215
287
|
const REVISE = "Revise the plan";
|
|
@@ -309,7 +381,13 @@ export default function planMode(pi: ExtensionAPI) {
|
|
|
309
381
|
}
|
|
310
382
|
|
|
311
383
|
const planFile = state.planFile;
|
|
312
|
-
|
|
384
|
+
// v0.3: the approved plan becomes a tracked step list, so execution has
|
|
385
|
+
// a cursor instead of the agent re-reading the markdown each turn.
|
|
386
|
+
const steps = planFile ? parseSteps(readFileSafe(planFile)) : [];
|
|
387
|
+
leavePlanMode(uiCtx, steps);
|
|
388
|
+
if (steps.length > 0) {
|
|
389
|
+
notify(uiCtx, `Tracking ${steps.length} steps — /plan steps to view.`, "info");
|
|
390
|
+
}
|
|
313
391
|
|
|
314
392
|
if (decision === APPROVE_FRESH) {
|
|
315
393
|
try {
|
|
@@ -347,9 +425,59 @@ export default function planMode(pi: ExtensionAPI) {
|
|
|
347
425
|
// ── Command & shortcut ───────────────────────────────────────────────
|
|
348
426
|
|
|
349
427
|
pi.registerCommand("plan", {
|
|
350
|
-
description: "
|
|
428
|
+
description: "Plan mode: /plan [off | list | steps | export [file] | <first planning prompt>]",
|
|
351
429
|
handler: async (args, ctx) => {
|
|
352
430
|
const text = (args ?? "").trim();
|
|
431
|
+
if (text.toLowerCase() === "steps") {
|
|
432
|
+
notify(
|
|
433
|
+
ctx,
|
|
434
|
+
state.steps.length === 0
|
|
435
|
+
? "No plan is being tracked. Steps appear after a plan is approved with exit_plan_mode."
|
|
436
|
+
: formatSteps(state.steps),
|
|
437
|
+
"info",
|
|
438
|
+
);
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
if (text.toLowerCase() === "export" || text.toLowerCase().startsWith("export ")) {
|
|
442
|
+
const target = text.slice("export".length).trim() || state.planFile;
|
|
443
|
+
if (!target) {
|
|
444
|
+
notify(ctx, "Nothing to export — no current plan. Usage: /plan export [file.md]", "warning");
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
const file = isAbsolute(target) ? target : join(plansDir(ctx.cwd), target);
|
|
448
|
+
const markdown = readFileSafe(file);
|
|
449
|
+
if (!markdown) {
|
|
450
|
+
notify(ctx, `Could not read ${file}.`, "error");
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
const out = htmlPathFor(file);
|
|
454
|
+
try {
|
|
455
|
+
writeFileSync(
|
|
456
|
+
out,
|
|
457
|
+
renderPlanHtml(markdown, {
|
|
458
|
+
title: basename(file, ".md").replace(/^\d{4}-\d{2}-\d{2}-/, "").replace(/-/g, " "),
|
|
459
|
+
generatedAt: new Date().toISOString().replace("T", " ").slice(0, 16),
|
|
460
|
+
sourceFile: basename(file),
|
|
461
|
+
progress: state.steps.length > 0 ? progressLine(state.steps) : undefined,
|
|
462
|
+
}),
|
|
463
|
+
);
|
|
464
|
+
notify(ctx, `Exported ${out}`, "info");
|
|
465
|
+
} catch (err) {
|
|
466
|
+
notify(ctx, `Export failed: ${err instanceof Error ? err.message : String(err)}`, "error");
|
|
467
|
+
}
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
if (text.toLowerCase() === "list") {
|
|
471
|
+
const plans = listPlanFiles(ctx.cwd);
|
|
472
|
+
notify(
|
|
473
|
+
ctx,
|
|
474
|
+
plans.length > 0
|
|
475
|
+
? `${plans.map((p) => `${p.file} (${p.size} chars)`).join("\n")}\nLocation: .pi/plans/`
|
|
476
|
+
: "No saved plans yet (.pi/plans/ is empty).",
|
|
477
|
+
"info",
|
|
478
|
+
);
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
353
481
|
if (text.toLowerCase() === "off") {
|
|
354
482
|
if (!state.active) {
|
|
355
483
|
notify(ctx, "Plan mode is not active.", "info");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pify/plan-mode",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Read-only planning mode for pi with an explicit approve-then-execute gate: enforced tool policy, plan files, approach options, fresh-session handoff",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
|
@@ -31,8 +31,12 @@
|
|
|
31
31
|
"LICENSE"
|
|
32
32
|
],
|
|
33
33
|
"pi": {
|
|
34
|
-
"extensions": [
|
|
35
|
-
|
|
34
|
+
"extensions": [
|
|
35
|
+
"./extensions/plan-mode.ts"
|
|
36
|
+
],
|
|
37
|
+
"skills": [
|
|
38
|
+
"./skills"
|
|
39
|
+
]
|
|
36
40
|
},
|
|
37
41
|
"scripts": {
|
|
38
42
|
"typecheck": "tsc --noEmit",
|
|
@@ -44,8 +48,12 @@
|
|
|
44
48
|
"typebox": "*"
|
|
45
49
|
},
|
|
46
50
|
"peerDependenciesMeta": {
|
|
47
|
-
"@earendil-works/pi-coding-agent": {
|
|
48
|
-
|
|
51
|
+
"@earendil-works/pi-coding-agent": {
|
|
52
|
+
"optional": true
|
|
53
|
+
},
|
|
54
|
+
"typebox": {
|
|
55
|
+
"optional": true
|
|
56
|
+
}
|
|
49
57
|
},
|
|
50
58
|
"devDependencies": {
|
|
51
59
|
"@earendil-works/pi-coding-agent": "^0.84.4",
|
package/src/export.ts
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Standalone HTML export of a plan (v0.3, narumiruna's plan-export). Plans
|
|
3
|
+
* get shared with people who do not have the repository — a self-contained
|
|
4
|
+
* file with no assets and no network is what actually travels.
|
|
5
|
+
*
|
|
6
|
+
* The renderer is a small markdown subset on purpose: plans are headings,
|
|
7
|
+
* lists, code, and emphasis. Everything is escaped first, so a plan
|
|
8
|
+
* containing HTML (or a prompt-injection attempt aimed at the reader) is
|
|
9
|
+
* shown as text rather than executed.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export function escapeHtml(text: string): string {
|
|
13
|
+
return text
|
|
14
|
+
.replace(/&/g, "&")
|
|
15
|
+
.replace(/</g, "<")
|
|
16
|
+
.replace(/>/g, ">")
|
|
17
|
+
.replace(/"/g, """)
|
|
18
|
+
.replace(/'/g, "'");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Inline spans, applied to already-escaped text. */
|
|
22
|
+
function renderInline(escaped: string): string {
|
|
23
|
+
return escaped
|
|
24
|
+
.replace(/`([^`]+)`/g, "<code>$1</code>")
|
|
25
|
+
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
|
|
26
|
+
.replace(/(^|[^*])\*([^*\n]+)\*/g, "$1<em>$2</em>");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const STYLE = [
|
|
30
|
+
":root{color-scheme:light dark}",
|
|
31
|
+
"body{max-width:46rem;margin:2.5rem auto;padding:0 1.25rem;",
|
|
32
|
+
"font:16px/1.65 ui-sans-serif,-apple-system,Segoe UI,Roboto,sans-serif}",
|
|
33
|
+
"h1,h2,h3,h4{line-height:1.25;margin:1.8em 0 .6em}",
|
|
34
|
+
"h1{font-size:1.7rem}h2{font-size:1.3rem}h3{font-size:1.1rem}",
|
|
35
|
+
"code{background:rgba(127,127,127,.16);padding:.12em .35em;border-radius:4px;font-size:.9em}",
|
|
36
|
+
"pre{background:rgba(127,127,127,.12);padding:.9rem 1rem;border-radius:8px;overflow:auto}",
|
|
37
|
+
"pre code{background:none;padding:0}",
|
|
38
|
+
"li{margin:.3em 0}",
|
|
39
|
+
"footer{margin-top:3rem;font-size:.85rem;opacity:.65;border-top:1px solid rgba(127,127,127,.3);padding-top:.8rem}",
|
|
40
|
+
".done{opacity:.55;text-decoration:line-through}",
|
|
41
|
+
].join("");
|
|
42
|
+
|
|
43
|
+
interface ListState {
|
|
44
|
+
open: "ul" | "ol" | null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function closeList(state: ListState, out: string[]): void {
|
|
48
|
+
if (state.open) {
|
|
49
|
+
out.push(`</${state.open}>`);
|
|
50
|
+
state.open = null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function openList(state: ListState, kind: "ul" | "ol", out: string[]): void {
|
|
55
|
+
if (state.open !== kind) {
|
|
56
|
+
closeList(state, out);
|
|
57
|
+
out.push(`<${kind}>`);
|
|
58
|
+
state.open = kind;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Render the markdown subset plans are written in. */
|
|
63
|
+
export function renderMarkdown(markdown: string): string {
|
|
64
|
+
const out: string[] = [];
|
|
65
|
+
const state: ListState = { open: null };
|
|
66
|
+
let inCode = false;
|
|
67
|
+
|
|
68
|
+
for (const rawLine of (markdown ?? "").split("\n")) {
|
|
69
|
+
const line = rawLine.replace(/\r$/, "");
|
|
70
|
+
|
|
71
|
+
if (/^\s*```/.test(line)) {
|
|
72
|
+
closeList(state, out);
|
|
73
|
+
out.push(inCode ? "</code></pre>" : "<pre><code>");
|
|
74
|
+
inCode = !inCode;
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (inCode) {
|
|
78
|
+
out.push(escapeHtml(line));
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const heading = /^(#{1,6})\s+(.*)$/.exec(line);
|
|
83
|
+
if (heading) {
|
|
84
|
+
closeList(state, out);
|
|
85
|
+
const level = Math.min(heading[1]!.length, 6);
|
|
86
|
+
out.push(`<h${level}>${renderInline(escapeHtml(heading[2]!))}</h${level}>`);
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const ordered = /^\s*\d+[.)]\s+(.*)$/.exec(line);
|
|
91
|
+
const bullet = /^\s*[-*+]\s+(.*)$/.exec(line);
|
|
92
|
+
if (ordered || bullet) {
|
|
93
|
+
openList(state, ordered ? "ol" : "ul", out);
|
|
94
|
+
const body = (ordered ?? bullet)![1]!;
|
|
95
|
+
const checked = /^\[[xX]\]\s*/.test(body);
|
|
96
|
+
const text = renderInline(escapeHtml(body.replace(/^\[[ xX]\]\s*/, "")));
|
|
97
|
+
out.push(checked ? `<li class="done">${text}</li>` : `<li>${text}</li>`);
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (!line.trim()) {
|
|
102
|
+
closeList(state, out);
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
closeList(state, out);
|
|
106
|
+
out.push(`<p>${renderInline(escapeHtml(line))}</p>`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (inCode) out.push("</code></pre>");
|
|
110
|
+
closeList(state, out);
|
|
111
|
+
return out.join("\n");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface ExportOptions {
|
|
115
|
+
title: string;
|
|
116
|
+
/** Rendered into the footer; passed in so the module stays pure. */
|
|
117
|
+
generatedAt: string;
|
|
118
|
+
sourceFile?: string;
|
|
119
|
+
progress?: string;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function renderPlanHtml(markdown: string, options: ExportOptions): string {
|
|
123
|
+
const title = escapeHtml(options.title);
|
|
124
|
+
const footer = [
|
|
125
|
+
options.sourceFile ? escapeHtml(options.sourceFile) : "",
|
|
126
|
+
options.progress ? escapeHtml(options.progress) : "",
|
|
127
|
+
`exported ${escapeHtml(options.generatedAt)} by @pify/plan-mode`,
|
|
128
|
+
]
|
|
129
|
+
.filter(Boolean)
|
|
130
|
+
.join(" · ");
|
|
131
|
+
|
|
132
|
+
return [
|
|
133
|
+
"<!doctype html>",
|
|
134
|
+
'<html lang="en">',
|
|
135
|
+
"<head>",
|
|
136
|
+
'<meta charset="utf-8">',
|
|
137
|
+
'<meta name="viewport" content="width=device-width,initial-scale=1">',
|
|
138
|
+
`<title>${title}</title>`,
|
|
139
|
+
`<style>${STYLE}</style>`,
|
|
140
|
+
"</head>",
|
|
141
|
+
"<body>",
|
|
142
|
+
renderMarkdown(markdown),
|
|
143
|
+
`<footer>${footer}</footer>`,
|
|
144
|
+
"</body>",
|
|
145
|
+
"</html>",
|
|
146
|
+
"",
|
|
147
|
+
].join("\n");
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Sibling .html path for a plan file. */
|
|
151
|
+
export function htmlPathFor(planFile: string): string {
|
|
152
|
+
return planFile.replace(/\.md$/i, "") + ".html";
|
|
153
|
+
}
|
package/src/plans.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { mkdirSync, writeFileSync, existsSync } from "node:fs";
|
|
1
|
+
import { mkdirSync, writeFileSync, existsSync, readdirSync, statSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
|
|
4
4
|
/** Plan files live in .pi/plans/, reviewable and committable. */
|
|
@@ -25,6 +25,22 @@ export function localDateStr(d: Date = new Date()): string {
|
|
|
25
25
|
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
/** List saved plan files, newest first (v0.2: saved-plan library, minimal form). */
|
|
29
|
+
export function listPlanFiles(cwd: string): Array<{ file: string; size: number }> {
|
|
30
|
+
const dir = plansDir(cwd);
|
|
31
|
+
try {
|
|
32
|
+
return readdirSync(dir)
|
|
33
|
+
.filter((f) => f.endsWith(".md"))
|
|
34
|
+
.map((f) => {
|
|
35
|
+
const full = join(dir, f);
|
|
36
|
+
return { file: f, size: statSync(full).size };
|
|
37
|
+
})
|
|
38
|
+
.sort((a, b) => b.file.localeCompare(a.file));
|
|
39
|
+
} catch {
|
|
40
|
+
return [];
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
28
44
|
/** Create the plan file, uniquified when the slug collides on the same day. */
|
|
29
45
|
export function createPlanFile(cwd: string, title: string, content: string): string {
|
|
30
46
|
const dir = plansDir(cwd);
|
package/src/state.ts
CHANGED
|
@@ -1,7 +1,19 @@
|
|
|
1
1
|
import { INITIAL_STATE, isRecord, type BranchEntryLike, type PlanState } from "./types.ts";
|
|
2
|
+
import type { PlanStep } from "./steps.ts";
|
|
2
3
|
|
|
3
4
|
export const PLAN_STATE = "plan-mode-state";
|
|
4
5
|
|
|
6
|
+
/** Steps come back from an untrusted snapshot; drop anything malformed. */
|
|
7
|
+
function sanitizeSteps(raw: unknown): PlanStep[] {
|
|
8
|
+
if (!Array.isArray(raw)) return [];
|
|
9
|
+
const steps: PlanStep[] = [];
|
|
10
|
+
for (const item of raw) {
|
|
11
|
+
if (!isRecord(item) || typeof item.text !== "string" || typeof item.index !== "number") continue;
|
|
12
|
+
steps.push({ index: item.index, text: item.text, done: item.done === true });
|
|
13
|
+
}
|
|
14
|
+
return steps;
|
|
15
|
+
}
|
|
16
|
+
|
|
5
17
|
/** Snapshot-based replay: the last plan-mode-state entry on the branch wins. */
|
|
6
18
|
export function replayBranch(entries: BranchEntryLike[]): PlanState {
|
|
7
19
|
let state: PlanState = INITIAL_STATE;
|
|
@@ -14,6 +26,7 @@ export function replayBranch(entries: BranchEntryLike[]): PlanState {
|
|
|
14
26
|
planFile: typeof data.planFile === "string" ? data.planFile : null,
|
|
15
27
|
buildThinking: typeof data.buildThinking === "string" ? data.buildThinking : null,
|
|
16
28
|
enteredAt: typeof data.enteredAt === "number" ? data.enteredAt : null,
|
|
29
|
+
steps: sanitizeSteps(data.steps),
|
|
17
30
|
};
|
|
18
31
|
}
|
|
19
32
|
return state;
|
package/src/steps.ts
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Step tracking for an approved plan (v0.3, janvitos' plan-execution). A plan
|
|
3
|
+
* is approved as a whole and then executed as a list — without a tracker the
|
|
4
|
+
* agent re-reads the markdown every turn and quietly skips steps.
|
|
5
|
+
*
|
|
6
|
+
* Steps are parsed out of the plan file the agent already wrote, so there is
|
|
7
|
+
* no second source of truth: the markdown stays the plan, this is a cursor
|
|
8
|
+
* over it.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export interface PlanStep {
|
|
12
|
+
/** 1-based position in the plan. */
|
|
13
|
+
index: number;
|
|
14
|
+
text: string;
|
|
15
|
+
done: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const MAX_STEPS = 40;
|
|
19
|
+
const MAX_STEP_CHARS = 200;
|
|
20
|
+
|
|
21
|
+
/** Headings that introduce the step list; anything else is prose. */
|
|
22
|
+
const STEP_HEADING = /^#{1,6}\s*(implementation\s+)?(steps|plan|tasks|todo|work)\b/i;
|
|
23
|
+
const NON_STEP_HEADING = /^#{1,6}\s*(risk|verification|testing|open question|context|goal|background|note)/i;
|
|
24
|
+
|
|
25
|
+
function cleanStep(raw: string): string {
|
|
26
|
+
return raw
|
|
27
|
+
.replace(/^\s*(?:\d+[.)]|[-*+]|\[[ xX]\])\s*/, "")
|
|
28
|
+
.replace(/^\[[ xX]\]\s*/, "")
|
|
29
|
+
.replace(/\s+/g, " ")
|
|
30
|
+
.trim()
|
|
31
|
+
.slice(0, MAX_STEP_CHARS);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Extract the ordered steps from a plan's markdown. Numbered lists win: a
|
|
36
|
+
* plan that numbers its steps means those and only those. Otherwise the
|
|
37
|
+
* bullets under a steps-ish heading are used, which is how most plans that
|
|
38
|
+
* are not numbered are written.
|
|
39
|
+
*/
|
|
40
|
+
export function parseSteps(markdown: string): PlanStep[] {
|
|
41
|
+
const lines = (markdown ?? "").split("\n");
|
|
42
|
+
|
|
43
|
+
const numbered: string[] = [];
|
|
44
|
+
const underHeading: string[] = [];
|
|
45
|
+
let inStepSection = false;
|
|
46
|
+
|
|
47
|
+
for (const line of lines) {
|
|
48
|
+
if (/^#{1,6}\s/.test(line)) {
|
|
49
|
+
inStepSection = STEP_HEADING.test(line) && !NON_STEP_HEADING.test(line);
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (/^\s*\d+[.)]\s+\S/.test(line)) {
|
|
53
|
+
// Only top-level numbers: an indented "1." is a detail of a step.
|
|
54
|
+
if (!/^\s{2,}/.test(line)) numbered.push(cleanStep(line));
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (inStepSection && /^\s*[-*+]\s+\S/.test(line) && !/^\s{2,}/.test(line)) {
|
|
58
|
+
underHeading.push(cleanStep(line));
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const chosen = (numbered.length > 0 ? numbered : underHeading).filter(Boolean).slice(0, MAX_STEPS);
|
|
63
|
+
return chosen.map((text, i) => ({ index: i + 1, text, done: false }));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Merge parsed steps with what was already completed, matching on text. */
|
|
67
|
+
export function mergeProgress(steps: PlanStep[], previous: PlanStep[]): PlanStep[] {
|
|
68
|
+
const doneText = new Set(previous.filter((s) => s.done).map((s) => s.text));
|
|
69
|
+
return steps.map((step) => ({ ...step, done: doneText.has(step.text) }));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function nextStep(steps: PlanStep[]): PlanStep | null {
|
|
73
|
+
return steps.find((step) => !step.done) ?? null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface CompleteResult {
|
|
77
|
+
steps: PlanStep[];
|
|
78
|
+
step: PlanStep | null;
|
|
79
|
+
error: string | null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Mark a step done. Out-of-order completion is allowed but not silent: the
|
|
84
|
+
* caller reports which steps were skipped, since skipping is usually a
|
|
85
|
+
* mistake and occasionally the point.
|
|
86
|
+
*/
|
|
87
|
+
export function completeStep(steps: PlanStep[], index: number): CompleteResult {
|
|
88
|
+
const target = steps.find((step) => step.index === index);
|
|
89
|
+
if (!target) return { steps, step: null, error: `No step #${index} in the plan (it has ${steps.length}).` };
|
|
90
|
+
if (target.done) return { steps, step: target, error: `Step #${index} is already done.` };
|
|
91
|
+
return {
|
|
92
|
+
steps: steps.map((step) => (step.index === index ? { ...step, done: true } : step)),
|
|
93
|
+
step: target,
|
|
94
|
+
error: null,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function skippedBefore(steps: PlanStep[], index: number): PlanStep[] {
|
|
99
|
+
return steps.filter((step) => step.index < index && !step.done);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function progressLine(steps: PlanStep[]): string {
|
|
103
|
+
const done = steps.filter((s) => s.done).length;
|
|
104
|
+
return `${done}/${steps.length} steps`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const MAX_WIDGET_STEPS = 8;
|
|
108
|
+
|
|
109
|
+
/** Plain-text step list for a notify or a widget. */
|
|
110
|
+
export function formatSteps(steps: PlanStep[], limit = MAX_WIDGET_STEPS): string {
|
|
111
|
+
if (steps.length === 0) return "No steps parsed from the plan.";
|
|
112
|
+
const current = nextStep(steps);
|
|
113
|
+
const start = current ? Math.max(0, Math.min(steps.length - limit, current.index - 1 - 2)) : 0;
|
|
114
|
+
const window = steps.slice(start, start + limit);
|
|
115
|
+
const lines = window.map((step) => {
|
|
116
|
+
const mark = step.done ? "✔" : step === current ? "▸" : "◻";
|
|
117
|
+
return `${mark} ${step.index}. ${step.text.length > 70 ? `${step.text.slice(0, 69)}…` : step.text}`;
|
|
118
|
+
});
|
|
119
|
+
if (start > 0) lines.unshift(`… +${start} above`);
|
|
120
|
+
const after = steps.length - start - window.length;
|
|
121
|
+
if (after > 0) lines.push(`… +${after} more`);
|
|
122
|
+
return [progressLine(steps), ...lines].join("\n");
|
|
123
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
* No imports from pi packages: src/ typechecks and runs standalone.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
import type { PlanStep } from "./steps.ts";
|
|
7
|
+
|
|
6
8
|
export interface PlanState {
|
|
7
9
|
active: boolean;
|
|
8
10
|
/** Absolute path of the current plan file; edit/write to it is allowed. */
|
|
@@ -10,12 +12,15 @@ export interface PlanState {
|
|
|
10
12
|
/** Thinking level to restore when leaving plan mode. */
|
|
11
13
|
buildThinking: string | null;
|
|
12
14
|
enteredAt: number | null;
|
|
15
|
+
/** Steps of the approved plan, tracked through execution (v0.3). */
|
|
16
|
+
steps: PlanStep[];
|
|
13
17
|
}
|
|
14
18
|
|
|
15
19
|
export const INITIAL_STATE: PlanState = {
|
|
16
20
|
active: false,
|
|
17
21
|
planFile: null,
|
|
18
22
|
buildThinking: null,
|
|
23
|
+
steps: [],
|
|
19
24
|
enteredAt: null,
|
|
20
25
|
};
|
|
21
26
|
|