planmode 0.1.5 → 0.2.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.
@@ -0,0 +1,195 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { simpleGit } from "simple-git";
4
+ import { stringify } from "yaml";
5
+
6
+ const RECORDING_FILE = ".planmode-recording";
7
+
8
+ export interface RecordingStep {
9
+ title: string;
10
+ message: string;
11
+ filesChanged: string[];
12
+ sha: string;
13
+ }
14
+
15
+ export interface RecordingResult {
16
+ steps: RecordingStep[];
17
+ planContent: string;
18
+ manifestContent: string;
19
+ totalCommits: number;
20
+ totalFilesChanged: number;
21
+ }
22
+
23
+ export function startRecording(projectDir: string = process.cwd()): string {
24
+ const git = simpleGit(projectDir);
25
+ // We need to do this synchronously-ish, so we write a marker file
26
+ // The actual SHA will be resolved in the async wrapper
27
+ const recordingPath = path.join(projectDir, RECORDING_FILE);
28
+
29
+ if (fs.existsSync(recordingPath)) {
30
+ const existing = fs.readFileSync(recordingPath, "utf-8").trim();
31
+ throw new Error(
32
+ `Recording already in progress (started at ${existing}). Run \`planmode record stop\` first.`,
33
+ );
34
+ }
35
+
36
+ return recordingPath;
37
+ }
38
+
39
+ export async function startRecordingAsync(projectDir: string = process.cwd()): Promise<string> {
40
+ const recordingPath = startRecording(projectDir);
41
+ const git = simpleGit(projectDir);
42
+ const log = await git.log({ n: 1 });
43
+ const sha = log.latest?.hash;
44
+
45
+ if (!sha) {
46
+ throw new Error("No commits found in this repository.");
47
+ }
48
+
49
+ fs.writeFileSync(recordingPath, sha, "utf-8");
50
+ return sha;
51
+ }
52
+
53
+ export function isRecording(projectDir: string = process.cwd()): boolean {
54
+ return fs.existsSync(path.join(projectDir, RECORDING_FILE));
55
+ }
56
+
57
+ export async function stopRecording(
58
+ projectDir: string = process.cwd(),
59
+ options: { name?: string; author?: string } = {},
60
+ ): Promise<RecordingResult> {
61
+ const recordingPath = path.join(projectDir, RECORDING_FILE);
62
+
63
+ if (!fs.existsSync(recordingPath)) {
64
+ throw new Error("No recording in progress. Run `planmode record start` first.");
65
+ }
66
+
67
+ const startSha = fs.readFileSync(recordingPath, "utf-8").trim();
68
+ const git = simpleGit(projectDir);
69
+
70
+ // Get commits since the start SHA
71
+ const log = await git.log({ from: startSha, to: "HEAD" });
72
+
73
+ if (log.total === 0) {
74
+ fs.unlinkSync(recordingPath);
75
+ throw new Error("No commits since recording started. Nothing to capture.");
76
+ }
77
+
78
+ // Process each commit into a step (oldest first)
79
+ const commits = [...log.all].reverse();
80
+ const steps: RecordingStep[] = [];
81
+ const allFilesChanged = new Set<string>();
82
+
83
+ for (const commit of commits) {
84
+ // Get files changed in this commit
85
+ const diff = await git.diffSummary([`${commit.hash}~1`, commit.hash]).catch(() =>
86
+ // First commit in range might not have a parent in range
87
+ git.diffSummary([startSha, commit.hash]),
88
+ );
89
+
90
+ const filesChanged = diff.files.map((f) => f.file);
91
+ filesChanged.forEach((f) => allFilesChanged.add(f));
92
+
93
+ // Clean up commit message for use as step title
94
+ const firstLine = commit.message.split("\n")[0]!.trim();
95
+ const body = commit.message.split("\n").slice(1).join("\n").trim();
96
+
97
+ steps.push({
98
+ title: firstLine,
99
+ message: body || firstLine,
100
+ filesChanged,
101
+ sha: commit.hash.slice(0, 7),
102
+ });
103
+ }
104
+
105
+ // Generate plan content
106
+ const planName = options.name || inferPlanName(steps);
107
+ const planContent = generatePlanContent(planName, steps);
108
+ const manifestContent = generateManifest(planName, options.author || "");
109
+
110
+ // Clean up recording file
111
+ fs.unlinkSync(recordingPath);
112
+
113
+ return {
114
+ steps,
115
+ planContent,
116
+ manifestContent,
117
+ totalCommits: commits.length,
118
+ totalFilesChanged: allFilesChanged.size,
119
+ };
120
+ }
121
+
122
+ function inferPlanName(steps: RecordingStep[]): string {
123
+ // Try to infer a name from the commit messages
124
+ const words = steps
125
+ .map((s) => s.title.toLowerCase())
126
+ .join(" ")
127
+ .replace(/[^a-z0-9\s]/g, "")
128
+ .split(/\s+/)
129
+ .filter((w) => w.length > 2 && !["the", "and", "for", "add", "fix", "update", "set"].includes(w));
130
+
131
+ // Take the most common meaningful words
132
+ const counts = new Map<string, number>();
133
+ for (const word of words) {
134
+ counts.set(word, (counts.get(word) || 0) + 1);
135
+ }
136
+
137
+ const topWords = [...counts.entries()]
138
+ .sort((a, b) => b[1] - a[1])
139
+ .slice(0, 3)
140
+ .map(([w]) => w);
141
+
142
+ return topWords.length > 0 ? topWords.join("-") + "-setup" : "recorded-plan";
143
+ }
144
+
145
+ function generatePlanContent(name: string, steps: RecordingStep[]): string {
146
+ const lines: string[] = [];
147
+
148
+ lines.push(`# ${name}`);
149
+ lines.push("");
150
+ lines.push("## Steps");
151
+ lines.push("");
152
+
153
+ for (let i = 0; i < steps.length; i++) {
154
+ const step = steps[i]!;
155
+ lines.push(`### ${i + 1}. ${step.title}`);
156
+ lines.push("");
157
+
158
+ if (step.message !== step.title) {
159
+ lines.push(step.message);
160
+ lines.push("");
161
+ }
162
+
163
+ if (step.filesChanged.length > 0) {
164
+ lines.push("**Files changed:**");
165
+ for (const file of step.filesChanged) {
166
+ lines.push(`- \`${file}\``);
167
+ }
168
+ lines.push("");
169
+ }
170
+ }
171
+
172
+ lines.push("## Verification");
173
+ lines.push("");
174
+ lines.push("- [ ] All steps completed successfully");
175
+ lines.push("- [ ] Application builds without errors");
176
+ lines.push("- [ ] Tests pass");
177
+ lines.push("");
178
+
179
+ return lines.join("\n");
180
+ }
181
+
182
+ function generateManifest(name: string, author: string): string {
183
+ const manifest: Record<string, unknown> = {
184
+ name,
185
+ version: "1.0.0",
186
+ type: "plan",
187
+ description: `Plan recorded from git history`,
188
+ author: author || "unknown",
189
+ license: "MIT",
190
+ category: "other",
191
+ content_file: "plan.md",
192
+ };
193
+
194
+ return stringify(manifest);
195
+ }
@@ -0,0 +1,348 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { stringify } from "yaml";
4
+
5
+ // Config files to detect and describe
6
+ const CONFIG_FILES: Record<string, string> = {
7
+ "tsconfig.json": "TypeScript",
8
+ "tsconfig.base.json": "TypeScript (base)",
9
+ ".eslintrc": "ESLint",
10
+ ".eslintrc.js": "ESLint",
11
+ ".eslintrc.json": "ESLint",
12
+ "eslint.config.js": "ESLint (flat config)",
13
+ "eslint.config.mjs": "ESLint (flat config)",
14
+ ".prettierrc": "Prettier",
15
+ ".prettierrc.json": "Prettier",
16
+ "prettier.config.js": "Prettier",
17
+ "tailwind.config.js": "Tailwind CSS",
18
+ "tailwind.config.ts": "Tailwind CSS",
19
+ "tailwind.config.mjs": "Tailwind CSS",
20
+ "postcss.config.js": "PostCSS",
21
+ "postcss.config.mjs": "PostCSS",
22
+ "next.config.js": "Next.js",
23
+ "next.config.mjs": "Next.js",
24
+ "next.config.ts": "Next.js",
25
+ "vite.config.ts": "Vite",
26
+ "vite.config.js": "Vite",
27
+ "astro.config.mjs": "Astro",
28
+ "astro.config.ts": "Astro",
29
+ "svelte.config.js": "SvelteKit",
30
+ "nuxt.config.ts": "Nuxt",
31
+ "remix.config.js": "Remix",
32
+ "webpack.config.js": "Webpack",
33
+ "rollup.config.js": "Rollup",
34
+ "vitest.config.ts": "Vitest",
35
+ "jest.config.js": "Jest",
36
+ "jest.config.ts": "Jest",
37
+ "docker-compose.yml": "Docker Compose",
38
+ "docker-compose.yaml": "Docker Compose",
39
+ "Dockerfile": "Docker",
40
+ ".dockerignore": "Docker",
41
+ "prisma/schema.prisma": "Prisma",
42
+ "drizzle.config.ts": "Drizzle ORM",
43
+ ".env.example": "Environment variables",
44
+ ".github/workflows": "GitHub Actions",
45
+ "vercel.json": "Vercel",
46
+ "netlify.toml": "Netlify",
47
+ "wrangler.toml": "Cloudflare Workers",
48
+ "fly.toml": "Fly.io",
49
+ };
50
+
51
+ interface SnapshotData {
52
+ name: string;
53
+ dependencies: Record<string, string>;
54
+ devDependencies: Record<string, string>;
55
+ detectedTools: { name: string; file: string }[];
56
+ structure: string[];
57
+ scripts: Record<string, string>;
58
+ framework: string | null;
59
+ }
60
+
61
+ export interface SnapshotResult {
62
+ planContent: string;
63
+ manifestContent: string;
64
+ data: SnapshotData;
65
+ }
66
+
67
+ export function takeSnapshot(
68
+ projectDir: string = process.cwd(),
69
+ options: { name?: string; author?: string } = {},
70
+ ): SnapshotResult {
71
+ const data = analyzeProject(projectDir);
72
+
73
+ if (options.name) {
74
+ data.name = options.name;
75
+ }
76
+
77
+ const planContent = generatePlanFromSnapshot(data);
78
+ const manifestContent = generateManifestFromSnapshot(data, options.author || "");
79
+
80
+ return { planContent, manifestContent, data };
81
+ }
82
+
83
+ function analyzeProject(projectDir: string): SnapshotData {
84
+ let name = path.basename(projectDir) + "-setup";
85
+ const dependencies: Record<string, string> = {};
86
+ const devDependencies: Record<string, string> = {};
87
+ const scripts: Record<string, string> = {};
88
+
89
+ // Read package.json
90
+ const pkgPath = path.join(projectDir, "package.json");
91
+ if (fs.existsSync(pkgPath)) {
92
+ try {
93
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
94
+ if (pkg.name) name = pkg.name + "-setup";
95
+ if (pkg.dependencies) Object.assign(dependencies, pkg.dependencies);
96
+ if (pkg.devDependencies) Object.assign(devDependencies, pkg.devDependencies);
97
+ if (pkg.scripts) Object.assign(scripts, pkg.scripts);
98
+ } catch {
99
+ // Skip if invalid JSON
100
+ }
101
+ }
102
+
103
+ // Detect config files / tools
104
+ const detectedTools: { name: string; file: string }[] = [];
105
+ for (const [file, toolName] of Object.entries(CONFIG_FILES)) {
106
+ const fullPath = path.join(projectDir, file);
107
+ if (fs.existsSync(fullPath)) {
108
+ detectedTools.push({ name: toolName, file });
109
+ }
110
+ }
111
+
112
+ // Get directory structure (top 2 levels, skip node_modules/dist/.git)
113
+ const structure = getDirectoryStructure(projectDir, 2);
114
+
115
+ // Detect primary framework
116
+ const framework = detectFramework(dependencies, devDependencies);
117
+
118
+ // Sanitize name for planmode
119
+ name = name
120
+ .toLowerCase()
121
+ .replace(/[^a-z0-9-]/g, "-")
122
+ .replace(/-+/g, "-")
123
+ .replace(/^-|-$/g, "");
124
+
125
+ return { name, dependencies, devDependencies, detectedTools, structure, scripts, framework };
126
+ }
127
+
128
+ function detectFramework(
129
+ deps: Record<string, string>,
130
+ devDeps: Record<string, string>,
131
+ ): string | null {
132
+ const all = { ...deps, ...devDeps };
133
+ if (all["next"]) return "Next.js";
134
+ if (all["astro"]) return "Astro";
135
+ if (all["@sveltejs/kit"]) return "SvelteKit";
136
+ if (all["nuxt"]) return "Nuxt";
137
+ if (all["@remix-run/react"]) return "Remix";
138
+ if (all["vue"]) return "Vue";
139
+ if (all["react"]) return "React";
140
+ if (all["express"]) return "Express";
141
+ if (all["fastify"]) return "Fastify";
142
+ if (all["hono"]) return "Hono";
143
+ return null;
144
+ }
145
+
146
+ function getDirectoryStructure(dir: string, maxDepth: number, depth = 0): string[] {
147
+ const SKIP = new Set([
148
+ "node_modules", "dist", ".git", ".next", ".nuxt", ".svelte-kit",
149
+ ".astro", ".vercel", ".netlify", "build", "coverage", "__pycache__",
150
+ ".turbo", ".cache",
151
+ ]);
152
+
153
+ const results: string[] = [];
154
+
155
+ try {
156
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
157
+ for (const entry of entries) {
158
+ if (entry.name.startsWith(".") && entry.name !== ".github") continue;
159
+ if (SKIP.has(entry.name)) continue;
160
+
161
+ const indent = " ".repeat(depth);
162
+ if (entry.isDirectory()) {
163
+ results.push(`${indent}${entry.name}/`);
164
+ if (depth < maxDepth) {
165
+ results.push(...getDirectoryStructure(path.join(dir, entry.name), maxDepth, depth + 1));
166
+ }
167
+ } else {
168
+ results.push(`${indent}${entry.name}`);
169
+ }
170
+ }
171
+ } catch {
172
+ // Skip unreadable directories
173
+ }
174
+
175
+ return results;
176
+ }
177
+
178
+ function generatePlanFromSnapshot(data: SnapshotData): string {
179
+ const lines: string[] = [];
180
+
181
+ lines.push(`# ${data.name}`);
182
+ lines.push("");
183
+
184
+ if (data.framework) {
185
+ lines.push(`Set up a ${data.framework} project with the following tools and configuration.`);
186
+ } else {
187
+ lines.push("Set up a project with the following tools and configuration.");
188
+ }
189
+ lines.push("");
190
+
191
+ // Prerequisites
192
+ lines.push("## Prerequisites");
193
+ lines.push("");
194
+ lines.push("- Node.js 20+");
195
+ if (Object.keys(data.dependencies).length > 0 || Object.keys(data.devDependencies).length > 0) {
196
+ lines.push("- npm or your preferred package manager");
197
+ }
198
+
199
+ const toolNames = [...new Set(data.detectedTools.map((t) => t.name))];
200
+ if (toolNames.includes("Docker") || toolNames.includes("Docker Compose")) {
201
+ lines.push("- Docker");
202
+ }
203
+ if (toolNames.includes("Prisma")) {
204
+ lines.push("- A PostgreSQL database (or update the Prisma schema for your database)");
205
+ }
206
+ lines.push("");
207
+
208
+ // Steps
209
+ lines.push("## Steps");
210
+ lines.push("");
211
+
212
+ let stepNum = 1;
213
+
214
+ // Step: create project
215
+ if (data.framework) {
216
+ lines.push(`### ${stepNum}. Create ${data.framework} project`);
217
+ lines.push("");
218
+ lines.push(`Initialize a new ${data.framework} project.`);
219
+ lines.push("");
220
+ stepNum++;
221
+ }
222
+
223
+ // Step: install dependencies
224
+ const depNames = Object.keys(data.dependencies);
225
+ const devDepNames = Object.keys(data.devDependencies);
226
+
227
+ if (depNames.length > 0) {
228
+ lines.push(`### ${stepNum}. Install dependencies`);
229
+ lines.push("");
230
+ lines.push("```bash");
231
+ lines.push(`npm install ${depNames.join(" ")}`);
232
+ lines.push("```");
233
+ lines.push("");
234
+ stepNum++;
235
+ }
236
+
237
+ if (devDepNames.length > 0) {
238
+ lines.push(`### ${stepNum}. Install dev dependencies`);
239
+ lines.push("");
240
+ lines.push("```bash");
241
+ lines.push(`npm install -D ${devDepNames.join(" ")}`);
242
+ lines.push("```");
243
+ lines.push("");
244
+ stepNum++;
245
+ }
246
+
247
+ // Steps: configure each detected tool
248
+ for (const tool of data.detectedTools) {
249
+ // Avoid duplicating framework config if already mentioned
250
+ if (tool.name === data.framework) continue;
251
+ // Skip generic files
252
+ if (tool.name === "Environment variables") continue;
253
+
254
+ lines.push(`### ${stepNum}. Configure ${tool.name}`);
255
+ lines.push("");
256
+ lines.push(`Create or update \`${tool.file}\` with the appropriate configuration.`);
257
+ lines.push("");
258
+ stepNum++;
259
+ }
260
+
261
+ // Step: environment variables
262
+ if (data.detectedTools.some((t) => t.name === "Environment variables")) {
263
+ lines.push(`### ${stepNum}. Set up environment variables`);
264
+ lines.push("");
265
+ lines.push("Copy `.env.example` to `.env` and fill in the values:");
266
+ lines.push("");
267
+ lines.push("```bash");
268
+ lines.push("cp .env.example .env");
269
+ lines.push("```");
270
+ lines.push("");
271
+ stepNum++;
272
+ }
273
+
274
+ // Step: available scripts
275
+ if (Object.keys(data.scripts).length > 0) {
276
+ lines.push(`### ${stepNum}. Available scripts`);
277
+ lines.push("");
278
+ for (const [name, cmd] of Object.entries(data.scripts)) {
279
+ lines.push(`- \`npm run ${name}\` — \`${cmd}\``);
280
+ }
281
+ lines.push("");
282
+ stepNum++;
283
+ }
284
+
285
+ // Project structure
286
+ if (data.structure.length > 0) {
287
+ lines.push("## Project Structure");
288
+ lines.push("");
289
+ lines.push("```");
290
+ for (const line of data.structure.slice(0, 40)) {
291
+ lines.push(line);
292
+ }
293
+ if (data.structure.length > 40) {
294
+ lines.push(" ...");
295
+ }
296
+ lines.push("```");
297
+ lines.push("");
298
+ }
299
+
300
+ // Verification
301
+ lines.push("## Verification");
302
+ lines.push("");
303
+ lines.push("- [ ] All dependencies installed without errors");
304
+ lines.push("- [ ] Configuration files are in place");
305
+ if (data.scripts["build"]) lines.push("- [ ] `npm run build` succeeds");
306
+ if (data.scripts["test"]) lines.push("- [ ] `npm run test` passes");
307
+ if (data.scripts["dev"]) lines.push("- [ ] `npm run dev` starts without errors");
308
+ lines.push("");
309
+
310
+ return lines.join("\n");
311
+ }
312
+
313
+ function generateManifestFromSnapshot(data: SnapshotData, author: string): string {
314
+ const tags: string[] = [];
315
+ if (data.framework) tags.push(data.framework.toLowerCase().replace(/[^a-z0-9]/g, ""));
316
+
317
+ const toolTags = data.detectedTools
318
+ .map((t) => t.name.toLowerCase().replace(/[^a-z0-9]/g, "-"))
319
+ .filter((t) => t.length > 1);
320
+ tags.push(...[...new Set(toolTags)].slice(0, 8));
321
+
322
+ const category = detectCategory(data);
323
+
324
+ const manifest: Record<string, unknown> = {
325
+ name: data.name,
326
+ version: "1.0.0",
327
+ type: "plan",
328
+ description: data.framework
329
+ ? `Set up a ${data.framework} project with ${data.detectedTools.map((t) => t.name).slice(0, 3).join(", ")}`
330
+ : `Project setup with ${data.detectedTools.map((t) => t.name).slice(0, 3).join(", ")}`,
331
+ author: author || "unknown",
332
+ license: "MIT",
333
+ tags: tags.slice(0, 10),
334
+ category,
335
+ content_file: "plan.md",
336
+ };
337
+
338
+ return stringify(manifest);
339
+ }
340
+
341
+ function detectCategory(data: SnapshotData): string {
342
+ const all = { ...data.dependencies, ...data.devDependencies };
343
+ if (all["react"] || all["vue"] || all["svelte"] || all["next"] || all["astro"]) return "frontend";
344
+ if (all["express"] || all["fastify"] || all["hono"] || all["koa"]) return "backend";
345
+ if (data.detectedTools.some((t) => t.name === "Docker" || t.name === "Docker Compose")) return "devops";
346
+ if (all["prisma"] || all["drizzle-orm"] || all["typeorm"]) return "database";
347
+ return "other";
348
+ }
@@ -0,0 +1,60 @@
1
+ export function getPlanTemplate(name: string): string {
2
+ return `# ${name}
3
+
4
+ ## Prerequisites
5
+
6
+ - List any tools, dependencies, or setup required before starting
7
+
8
+ ## Steps
9
+
10
+ 1. **Step one** — Description of what to do first
11
+ 2. **Step two** — Description of what to do next
12
+ 3. **Step three** — Description of the final step
13
+
14
+ ## Verification
15
+
16
+ - [ ] Verify step one completed successfully
17
+ - [ ] Verify step two completed successfully
18
+ - [ ] Verify the final result works as expected
19
+ `;
20
+ }
21
+
22
+ export function getRuleTemplate(name: string): string {
23
+ return `# ${name}
24
+
25
+ ## Code Style
26
+
27
+ - Follow consistent naming conventions
28
+ - Keep functions small and focused
29
+
30
+ ## Best Practices
31
+
32
+ - Prefer composition over inheritance
33
+ - Write self-documenting code
34
+
35
+ ## Avoid
36
+
37
+ - Do not use deprecated APIs
38
+ - Do not ignore error handling
39
+ `;
40
+ }
41
+
42
+ export function getPromptTemplate(name: string): string {
43
+ return `# ${name}
44
+
45
+ {{description}}
46
+
47
+ ## Context
48
+
49
+ Provide any relevant context here.
50
+
51
+ ## Requirements
52
+
53
+ - Requirement one
54
+ - Requirement two
55
+
56
+ ## Output Format
57
+
58
+ Describe the expected output format.
59
+ `;
60
+ }