@dreki-gg/pi-plan-mode 0.3.1 → 0.5.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 CHANGED
@@ -1,5 +1,31 @@
1
1
  # @dreki-gg/pi-plan-mode
2
2
 
3
+ ## 0.5.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [`32797ff`](https://github.com/dreki-gg/pi-extensions/commit/32797ff18d968e22c6c44e95c46e3393d8928cef) Thanks [@jalbarrang](https://github.com/jalbarrang)! - feat(plan-mode): add Windows compatibility — replace Unix shell commands with cross-platform Bun/Node APIs
8
+
9
+ Plan-mode no longer shells out to `cat`, `bash`, or `mkdir` via `pi.exec()`. File I/O now uses `Bun.file()` / `Bun.write()` and `node:fs/promises` `mkdir`, making the extension fully cross-platform. Destructive and safe command pattern lists now include Windows equivalents (`del`, `rd`, `copy`, `move`, `powershell`, `dir`, `where`, `tasklist`, etc.).
10
+
11
+ Also fixes Windows compatibility in three other packages:
12
+
13
+ - **browser-tools**: `spawn` now uses `shell: true` on Windows so `.cmd` wrappers resolve correctly; `shellEscape` uses double-quote style on Windows; install guidance is platform-aware (Homebrew shown only on macOS).
14
+ - **subagent**: `spawn` uses `shell: true` on Windows when the command is bare `pi`, allowing `pi.cmd` resolution.
15
+ - **lsp**: `globalConfigPath()` now uses `os.homedir()` on Windows instead of the unreliable `process.env.HOME`.
16
+
17
+ ## 0.4.0
18
+
19
+ ### Minor Changes
20
+
21
+ - [`c86c935`](https://github.com/dreki-gg/pi-extensions/commit/c86c9352150a5bed61602243c8164bdd5d679745) Thanks [@jalbarrang](https://github.com/jalbarrang)! - Add plans.json lifecycle tracking and CLI for cleaning completed plans
22
+
23
+ - Extension now writes `.plans/plans.json` to track plan status (`in-progress` / `done`) with timestamps and titles
24
+ - Plans are recorded as `in-progress` when created, marked `done` when all execution steps complete
25
+ - New `pi-plan-mode clean` CLI (`npx @dreki-gg/pi-plan-mode clean [--dry-run]`) removes completed plan directories while preserving in-flight plans
26
+ - Cleanup step added to publish.yml workflow to auto-clean done plans on merge to main
27
+ - Removed stale `docs/plans/` from browser-tools and subagent packages
28
+
3
29
  ## 0.3.1
4
30
 
5
31
  ### Patch Changes
package/README.md CHANGED
@@ -67,12 +67,84 @@ When **Execute Plan** is selected:
67
67
 
68
68
  ```
69
69
  .plans/
70
+ ├── plans.json # Tracking manifest — plan status lifecycle
70
71
  └── add-auth-middleware/
71
- ├── PLAN.md # Numbered plan with context
72
- ├── START-PROMPT.md # Self-contained executor handoff prompt
73
- └── ... # Optional supporting files
72
+ ├── PLAN.md # Numbered plan with context
73
+ ├── START-PROMPT.md # Self-contained executor handoff prompt
74
+ └── ... # Optional supporting files
74
75
  ```
75
76
 
77
+ ### plans.json
78
+
79
+ The extension automatically maintains `.plans/plans.json` to track plan lifecycle:
80
+
81
+ ```json
82
+ {
83
+ "add-auth-middleware": {
84
+ "status": "in-progress",
85
+ "title": "Add Authentication Middleware with JWT Support",
86
+ "created": "2026-05-08T12:00:00.000Z",
87
+ "completed": null
88
+ },
89
+ "fix-ci-flakes": {
90
+ "status": "done",
91
+ "title": "Fix CI Flaky Tests",
92
+ "created": "2026-05-07T10:00:00.000Z",
93
+ "completed": "2026-05-07T14:30:00.000Z"
94
+ }
95
+ }
96
+ ```
97
+
98
+ Plans start as `"in-progress"` when created and are marked `"done"` when all execution steps complete. This prevents accidental deletion of in-flight plans.
99
+
100
+ ## Cleaning completed plans
101
+
102
+ Use the CLI to remove completed plan directories:
103
+
104
+ ```bash
105
+ # Preview what would be deleted
106
+ npx @dreki-gg/pi-plan-mode clean --dry-run
107
+
108
+ # Delete completed plans and update plans.json
109
+ npx @dreki-gg/pi-plan-mode clean
110
+ ```
111
+
112
+ In-flight plans (`"status": "in-progress"`) are never touched.
113
+
114
+ ### GitHub Actions
115
+
116
+ Clean done plans automatically after merge — similar to changesets:
117
+
118
+ ```yaml
119
+ name: Clean Plans
120
+
121
+ on:
122
+ push:
123
+ branches: [main]
124
+ paths: ['.plans/**']
125
+
126
+ jobs:
127
+ clean:
128
+ runs-on: ubuntu-latest
129
+ steps:
130
+ - uses: actions/checkout@v5
131
+ - uses: actions/setup-node@v4
132
+ with:
133
+ node-version: '24'
134
+ - run: npx @dreki-gg/pi-plan-mode clean
135
+ - name: Commit cleanup
136
+ run: |
137
+ git config user.name "github-actions[bot]"
138
+ git config user.email "github-actions[bot]@users.noreply.github.com"
139
+ git add .plans/
140
+ git diff --cached --quiet || git commit -m "chore: clean completed plans"
141
+ git push
142
+ ```
143
+
144
+ ### Should you gitignore `.plans/`?
145
+
146
+ **No.** Commit your plans — they provide decision history and execution context. Use the `clean` CLI to remove done plans after merge, keeping the directory lean. Plans are execution blueprints, not permanent documentation; for lasting decisions, use ADRs.
147
+
76
148
  ## Footer indicators
77
149
 
78
150
  - `📝 plan` — plan mode active (opus-4-6:medium, strict bash)
@@ -81,3 +153,14 @@ When **Execute Plan** is selected:
81
153
  ## Bash safety
82
154
 
83
155
  In plan mode, bash is restricted to read-only commands (ls, grep, git status, cat, rg, etc.). Destructive commands (rm, mv, git commit, etc.) are blocked.
156
+
157
+ ## CLI reference
158
+
159
+ ```
160
+ pi-plan-mode clean [--dry-run]
161
+ ```
162
+
163
+ | Option | Description |
164
+ | ----------- | ------------------------------------------------ |
165
+ | `clean` | Remove completed plan directories, update manifest |
166
+ | `--dry-run` | Show what would be deleted without deleting |
@@ -0,0 +1,117 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * CLI to clean completed plans from `.plans/`.
4
+ *
5
+ * Usage:
6
+ * npx @dreki-gg/pi-plan-mode clean [--dry-run]
7
+ *
8
+ * Reads `.plans/plans.json`, deletes directories for plans with status "done",
9
+ * and updates `plans.json` to remove them.
10
+ *
11
+ * Designed for use in GitHub Actions after merge — similar to changesets.
12
+ */
13
+
14
+ import { readFileSync, writeFileSync, rmSync, readdirSync, existsSync } from 'node:fs';
15
+ import { resolve, join } from 'node:path';
16
+
17
+ const PLANS_DIR = '.plans';
18
+ const PLANS_JSON = join(PLANS_DIR, 'plans.json');
19
+
20
+ function main() {
21
+ const args = process.argv.slice(2);
22
+ const command = args[0];
23
+
24
+ if (command !== 'clean') {
25
+ console.error('Usage: pi-plan-mode clean [--dry-run]\n');
26
+ console.error('Commands:');
27
+ console.error(' clean Remove completed plan directories and update plans.json\n');
28
+ console.error('Options:');
29
+ console.error(' --dry-run Show what would be deleted without actually deleting');
30
+ process.exit(1);
31
+ }
32
+
33
+ const dryRun = args.includes('--dry-run');
34
+ const plansJsonPath = resolve(PLANS_JSON);
35
+
36
+ if (!existsSync(plansJsonPath)) {
37
+ console.log('No .plans/plans.json found — nothing to clean.');
38
+ process.exit(0);
39
+ }
40
+
41
+ /** @type {Record<string, { status: string; title: string; created: string; completed: string | null }>} */
42
+ let manifest;
43
+ try {
44
+ manifest = JSON.parse(readFileSync(plansJsonPath, 'utf-8'));
45
+ } catch (err) {
46
+ console.error(`Failed to parse ${PLANS_JSON}:`, err);
47
+ process.exit(1);
48
+ }
49
+
50
+ const donePlans = Object.entries(manifest).filter(([, entry]) => entry.status === 'done');
51
+ const inFlightPlans = Object.entries(manifest).filter(
52
+ ([, entry]) => entry.status === 'in-progress',
53
+ );
54
+
55
+ if (donePlans.length === 0) {
56
+ console.log('No completed plans to clean.');
57
+ if (inFlightPlans.length > 0) {
58
+ console.log(`\n${inFlightPlans.length} plan(s) still in progress:`);
59
+ for (const [name, entry] of inFlightPlans) {
60
+ console.log(` ○ ${name} — ${entry.title}`);
61
+ }
62
+ }
63
+ process.exit(0);
64
+ }
65
+
66
+ console.log(dryRun ? 'Dry run — would clean:\n' : 'Cleaning completed plans:\n');
67
+
68
+ let cleaned = 0;
69
+ for (const [name, entry] of donePlans) {
70
+ const planPath = resolve(join(PLANS_DIR, name));
71
+ const exists = existsSync(planPath);
72
+
73
+ if (dryRun) {
74
+ console.log(` ✓ ${name} — ${entry.title}${exists ? '' : ' (directory already missing)'}`);
75
+ } else {
76
+ if (exists) {
77
+ rmSync(planPath, { recursive: true, force: true });
78
+ console.log(` ✓ Deleted ${PLANS_DIR}/${name} — ${entry.title}`);
79
+ } else {
80
+ console.log(` ✓ ${name} — directory already missing, removing from manifest`);
81
+ }
82
+ delete manifest[name];
83
+ cleaned++;
84
+ }
85
+ }
86
+
87
+ if (!dryRun) {
88
+ const remaining = Object.keys(manifest).length;
89
+ if (remaining === 0) {
90
+ rmSync(plansJsonPath, { force: true });
91
+ // Remove .plans/ if completely empty
92
+ const plansDir = resolve(PLANS_DIR);
93
+ try {
94
+ if (existsSync(plansDir) && readdirSync(plansDir).length === 0) {
95
+ rmSync(plansDir, { recursive: true, force: true });
96
+ console.log(`\nRemoved empty ${PLANS_DIR}/`);
97
+ }
98
+ } catch {
99
+ // Directory might have other contents
100
+ }
101
+ } else {
102
+ writeFileSync(plansJsonPath, JSON.stringify(manifest, null, 2) + '\n');
103
+ }
104
+
105
+ console.log(`\nCleaned ${cleaned} plan(s).`);
106
+ if (remaining > 0) {
107
+ console.log(`${remaining} plan(s) still in progress.`);
108
+ }
109
+ } else {
110
+ console.log(`\n${donePlans.length} plan(s) would be cleaned.`);
111
+ if (inFlightPlans.length > 0) {
112
+ console.log(`${inFlightPlans.length} plan(s) still in progress (will be kept).`);
113
+ }
114
+ }
115
+ }
116
+
117
+ main();
@@ -22,12 +22,19 @@ import type { AgentMessage } from '@earendil-works/pi-agent-core';
22
22
  import type { AssistantMessage, TextContent } from '@earendil-works/pi-ai';
23
23
  import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent';
24
24
  import { Key } from '@earendil-works/pi-tui';
25
+ import { mkdir } from 'node:fs/promises';
25
26
  import {
26
27
  extractTodoItems,
27
28
  isSafeCommand,
28
29
  markCompletedSteps,
29
30
  type TodoItem,
30
31
  } from './utils.js';
32
+ import {
33
+ extractPlanTitle,
34
+ readPlansJson,
35
+ serializePlansJson,
36
+ type PlansManifest,
37
+ } from './plans-json.js';
31
38
 
32
39
  // ── Tool sets ────────────────────────────────────────────────────────────────
33
40
  // Plan phase: read-only + edit/write (for .plans/ files only, enforced by prompt)
@@ -99,6 +106,28 @@ export default function planMode(pi: ExtensionAPI): void {
99
106
  });
100
107
  }
101
108
 
109
+ // ── plans.json tracking ───────────────────────────────────────────────────
110
+ async function updatePlansManifest(
111
+ planName: string,
112
+ status: 'in-progress' | 'done',
113
+ title?: string,
114
+ ): Promise<void> {
115
+ const manifest = await readPlansJson();
116
+ const existing = manifest[planName];
117
+ const now = new Date().toISOString();
118
+
119
+ manifest[planName] = {
120
+ status,
121
+ title: title ?? existing?.title ?? 'Untitled plan',
122
+ created: existing?.created ?? now,
123
+ completed: status === 'done' ? now : null,
124
+ };
125
+
126
+ await mkdir('.plans', { recursive: true });
127
+ const content = serializePlansJson(manifest);
128
+ await Bun.write('.plans/plans.json', content);
129
+ }
130
+
102
131
  // ── UI updates ────────────────────────────────────────────────────────────
103
132
  function updateUI(ctx: ExtensionContext): void {
104
133
  const { theme } = ctx.ui;
@@ -384,7 +413,29 @@ Execute each step in order. You MUST include [DONE:n] in your response after com
384
413
  const match = path.match(/\.plans\/([^/]+)\//);
385
414
  if (match && !planDir) {
386
415
  planDir = `.plans/${match[1]}`;
416
+ const planName = match[1];
417
+
418
+ // Read PLAN.md to extract the title for plans.json
419
+ let title = 'Untitled plan';
420
+ if (path.endsWith('PLAN.md')) {
421
+ try {
422
+ const content = await Bun.file(path).text();
423
+ title = extractPlanTitle(content);
424
+ } catch {
425
+ // Fall through
426
+ }
427
+ }
428
+ await updatePlansManifest(planName, 'in-progress', title);
387
429
  persist();
430
+ } else if (match && planDir && path.endsWith('PLAN.md')) {
431
+ // planDir already set but PLAN.md just written — update title
432
+ try {
433
+ const content = await Bun.file(path).text();
434
+ const title = extractPlanTitle(content);
435
+ await updatePlansManifest(match[1], 'in-progress', title);
436
+ } catch {
437
+ // Fall through
438
+ }
388
439
  }
389
440
  });
390
441
 
@@ -393,6 +444,12 @@ Execute each step in order. You MUST include [DONE:n] in your response after com
393
444
  // Check execution completion
394
445
  if (executing && todos.length > 0) {
395
446
  if (todos.every((t) => t.completed)) {
447
+ // Mark plan as done in plans.json
448
+ if (planDir) {
449
+ const planName = planDir.replace(/^\.plans\//, '');
450
+ await updatePlansManifest(planName, 'done');
451
+ }
452
+
396
453
  const list = todos.map((t) => `~~${t.text}~~`).join('\n');
397
454
  pi.sendMessage(
398
455
  {
@@ -439,10 +496,7 @@ Execute each step in order. You MUST include [DONE:n] in your response after com
439
496
  // Read the plan to extract todos
440
497
  let planContent = '';
441
498
  try {
442
- const result = await pi.exec('cat', [planMdPath]);
443
- if (result.code === 0) {
444
- planContent = result.stdout;
445
- }
499
+ planContent = await Bun.file(planMdPath).text();
446
500
  } catch {
447
501
  // Fall through — will use empty plan content
448
502
  }
@@ -455,10 +509,7 @@ Execute each step in order. You MUST include [DONE:n] in your response after com
455
509
  // Read the start prompt for clean handoff
456
510
  let startPrompt = '';
457
511
  try {
458
- const result = await pi.exec('cat', [startPromptPath]);
459
- if (result.code === 0) {
460
- startPrompt = result.stdout.trim();
461
- }
512
+ startPrompt = (await Bun.file(startPromptPath).text()).trim();
462
513
  } catch {
463
514
  // Fall through
464
515
  }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Reads and writes `.plans/plans.json` — the tracking manifest for plan lifecycle.
3
+ *
4
+ * Schema:
5
+ * ```json
6
+ * {
7
+ * "<plan-name>": {
8
+ * "status": "in-progress" | "done",
9
+ * "title": "Human-readable plan title",
10
+ * "created": "2026-05-08T12:00:00.000Z",
11
+ * "completed": "2026-05-08T13:00:00.000Z" | null
12
+ * }
13
+ * }
14
+ * ```
15
+ */
16
+
17
+ export interface PlanEntry {
18
+ status: 'in-progress' | 'done';
19
+ title: string;
20
+ created: string;
21
+ completed: string | null;
22
+ }
23
+
24
+ export type PlansManifest = Record<string, PlanEntry>;
25
+
26
+ const PLANS_JSON = '.plans/plans.json';
27
+
28
+ /** Read plans.json, returning current manifest (empty object if missing). */
29
+ export async function readPlansJson(): Promise<PlansManifest> {
30
+ try {
31
+ const file = Bun.file(PLANS_JSON);
32
+ if (await file.exists()) {
33
+ const text = await file.text();
34
+ if (text.trim()) {
35
+ return JSON.parse(text) as PlansManifest;
36
+ }
37
+ }
38
+ } catch {
39
+ // File doesn't exist or isn't valid JSON
40
+ }
41
+ return {};
42
+ }
43
+
44
+ /** Serialize the manifest to a formatted JSON string. */
45
+ export function serializePlansJson(manifest: PlansManifest): string {
46
+ return `${JSON.stringify(manifest, null, 2)}\n`;
47
+ }
48
+
49
+ /** Extract the plan title from the first `# ...` heading in PLAN.md content. */
50
+ export function extractPlanTitle(planContent: string): string {
51
+ const match = planContent.match(/^#\s+(.+)$/m);
52
+ return match ? match[1].trim() : 'Untitled plan';
53
+ }
@@ -43,6 +43,17 @@ const DESTRUCTIVE_PATTERNS = [
43
43
  /\bsystemctl\s+(start|stop|restart|enable|disable)/i,
44
44
  /\bservice\s+\S+\s+(start|stop|restart)/i,
45
45
  /\b(vim?|nano|emacs|code|subl)\b/i,
46
+ // Windows equivalents
47
+ /\bdel\b/i,
48
+ /\brd\b/i,
49
+ /\bcopy\b/i,
50
+ /\bmove\b/i,
51
+ /\bren\b/i,
52
+ /\brename\b/i,
53
+ /\bicacls\b/i,
54
+ /\battrib\b/i,
55
+ /\bpowershell\b/i,
56
+ /\bpwsh\b/i,
46
57
  ];
47
58
 
48
59
  // ── Safe read-only bash patterns (allowed in plan mode) ─────────────────────
@@ -98,6 +109,12 @@ const SAFE_PATTERNS = [
98
109
  /^\s*fd\b/,
99
110
  /^\s*bat\b/,
100
111
  /^\s*eza\b/,
112
+ // Windows equivalents
113
+ /^\s*dir\b/,
114
+ /^\s*where\b/,
115
+ /^\s*set\b/,
116
+ /^\s*systeminfo\b/,
117
+ /^\s*tasklist\b/,
101
118
  ];
102
119
 
103
120
  export function isSafeCommand(command: string): boolean {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dreki-gg/pi-plan-mode",
3
- "version": "0.3.1",
3
+ "version": "0.5.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"
@@ -13,17 +13,21 @@
13
13
  "directory": "packages/plan-mode"
14
14
  },
15
15
  "type": "module",
16
+ "bin": {
17
+ "pi-plan-mode": "./bin/clean-plans.js"
18
+ },
16
19
  "files": [
17
20
  "extensions",
21
+ "bin",
18
22
  "README.md",
19
23
  "CHANGELOG.md",
20
24
  "package.json"
21
25
  ],
22
26
  "scripts": {
23
27
  "typecheck": "tsc --noEmit",
24
- "lint": "oxlint extensions",
25
- "format": "oxfmt --write extensions",
26
- "format:check": "oxfmt --check extensions"
28
+ "lint": "oxlint extensions bin",
29
+ "format": "oxfmt --write extensions bin",
30
+ "format:check": "oxfmt --check extensions bin"
27
31
  },
28
32
  "pi": {
29
33
  "extensions": [
@@ -32,6 +36,7 @@
32
36
  },
33
37
  "devDependencies": {
34
38
  "@types/node": "24",
39
+ "bun-types": "latest",
35
40
  "oxfmt": "^0.43.0",
36
41
  "oxlint": "^1.58.0",
37
42
  "typescript": "^6.0.0"