Skip to content

Commit 90b8f5f

Browse files
authored
Merge pull request #35 from backnotprop/feature/plan-saving
Add plan saving to ~/.plannotator/plans/
2 parents e026134 + 4de46b5 commit 90b8f5f

3 files changed

Lines changed: 141 additions & 19 deletions

File tree

apps/opencode-plugin/index.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ Do NOT proceed with implementation until your plan is approved.
101101
return `Plan approved with notes!
102102
103103
Plan Summary: ${args.summary}
104+
${result.savedPath ? `Saved to: ${result.savedPath}` : ""}
104105
105106
## Implementation Notes
106107
@@ -113,9 +114,11 @@ Proceed with implementation, incorporating these notes where applicable.`;
113114

114115
return `Plan approved!
115116
116-
Plan Summary: ${args.summary}`;
117+
Plan Summary: ${args.summary}
118+
${result.savedPath ? `Saved to: ${result.savedPath}` : ""}`;
117119
} else {
118120
return `Plan needs revision.
121+
${result.savedPath ? `\nSaved to: ${result.savedPath}` : ""}
119122
120123
The user has requested changes to your plan. Please review their feedback below and revise your plan accordingly.
121124

packages/server/index.ts

Lines changed: 47 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,18 @@ import {
1919
type ObsidianConfig,
2020
type BearConfig,
2121
} from "./integrations";
22+
import {
23+
generateSlug,
24+
savePlan,
25+
saveAnnotations,
26+
saveFinalSnapshot,
27+
} from "./storage";
2228

2329
// Re-export utilities
2430
export { isRemoteSession, getServerPort } from "./remote";
2531
export { openBrowser } from "./browser";
2632
export * from "./integrations";
33+
export * from "./storage";
2734

2835
// --- Types ---
2936

@@ -46,7 +53,11 @@ export interface ServerResult {
4653
/** Whether running in remote mode */
4754
isRemote: boolean;
4855
/** Wait for user decision (approve/deny) */
49-
waitForDecision: () => Promise<{ approved: boolean; feedback?: string }>;
56+
waitForDecision: () => Promise<{
57+
approved: boolean;
58+
feedback?: string;
59+
savedPath?: string;
60+
}>;
5061
/** Stop the server */
5162
stop: () => void;
5263
}
@@ -73,13 +84,23 @@ export async function startPlannotatorServer(
7384
const isRemote = isRemoteSession();
7485
const configuredPort = getServerPort();
7586

87+
// Generate slug and save plan immediately
88+
const slug = generateSlug(plan);
89+
const planPath = savePlan(slug, plan);
90+
7691
// Decision promise
77-
let resolveDecision: (result: { approved: boolean; feedback?: string }) => void;
78-
const decisionPromise = new Promise<{ approved: boolean; feedback?: string }>(
79-
(resolve) => {
80-
resolveDecision = resolve;
81-
}
82-
);
92+
let resolveDecision: (result: {
93+
approved: boolean;
94+
feedback?: string;
95+
savedPath?: string;
96+
}) => void;
97+
const decisionPromise = new Promise<{
98+
approved: boolean;
99+
feedback?: string;
100+
savedPath?: string;
101+
}>((resolve) => {
102+
resolveDecision = resolve;
103+
});
83104

84105
// Start server with retry logic
85106
let server: ReturnType<typeof Bun.serve> | null = null;
@@ -183,25 +204,33 @@ export async function startPlannotatorServer(
183204
console.error(`[Integration] Error:`, err);
184205
}
185206

186-
resolveDecision({ approved: true, feedback });
187-
return Response.json({ ok: true });
207+
// Save annotations and final snapshot
208+
const diff = feedback || "";
209+
if (diff) {
210+
saveAnnotations(slug, diff);
211+
}
212+
const savedPath = saveFinalSnapshot(slug, "approved", plan, diff);
213+
214+
resolveDecision({ approved: true, feedback, savedPath });
215+
return Response.json({ ok: true, savedPath });
188216
}
189217

190218
// API: Deny with feedback
191219
if (url.pathname === "/api/deny" && req.method === "POST") {
220+
let feedback = "Plan rejected by user";
192221
try {
193222
const body = (await req.json()) as { feedback?: string };
194-
resolveDecision({
195-
approved: false,
196-
feedback: body.feedback || "Plan rejected by user",
197-
});
223+
feedback = body.feedback || feedback;
198224
} catch {
199-
resolveDecision({
200-
approved: false,
201-
feedback: "Plan rejected by user",
202-
});
225+
// Use default feedback
203226
}
204-
return Response.json({ ok: true });
227+
228+
// Save annotations and final snapshot
229+
saveAnnotations(slug, feedback);
230+
const savedPath = saveFinalSnapshot(slug, "denied", plan, feedback);
231+
232+
resolveDecision({ approved: false, feedback, savedPath });
233+
return Response.json({ ok: true, savedPath });
205234
}
206235

207236
// Serve embedded HTML for all other routes (SPA)

packages/server/storage.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
/**
2+
* Plan Storage Utility
3+
*
4+
* Saves plans and annotations to ~/.plannotator/plans/
5+
* Cross-platform: works on Windows, macOS, and Linux.
6+
*/
7+
8+
import { homedir } from "os";
9+
import { join } from "path";
10+
import { mkdirSync, writeFileSync } from "fs";
11+
import { sanitizeTag } from "./project";
12+
13+
/**
14+
* Get the plan storage directory, creating it if needed.
15+
* Cross-platform: uses os.homedir() for Windows/macOS/Linux compatibility.
16+
*/
17+
export function getPlanDir(): string {
18+
const home = homedir();
19+
const planDir = join(home, ".plannotator", "plans");
20+
mkdirSync(planDir, { recursive: true });
21+
return planDir;
22+
}
23+
24+
/**
25+
* Extract the first heading from markdown content.
26+
*/
27+
function extractFirstHeading(markdown: string): string | null {
28+
const match = markdown.match(/^#\s+(.+)$/m);
29+
if (!match) return null;
30+
return match[1].trim();
31+
}
32+
33+
/**
34+
* Generate a slug from plan content.
35+
* Format: YYYY-MM-DD-{sanitized-heading}
36+
*/
37+
export function generateSlug(plan: string): string {
38+
const date = new Date().toISOString().split("T")[0]; // YYYY-MM-DD
39+
40+
const heading = extractFirstHeading(plan);
41+
const slug = heading ? sanitizeTag(heading) : null;
42+
43+
return slug ? `${date}-${slug}` : `${date}-plan`;
44+
}
45+
46+
/**
47+
* Save the plan markdown to disk.
48+
* Returns the full path to the saved file.
49+
*/
50+
export function savePlan(slug: string, content: string): string {
51+
const planDir = getPlanDir();
52+
const filePath = join(planDir, `${slug}.md`);
53+
writeFileSync(filePath, content, "utf-8");
54+
return filePath;
55+
}
56+
57+
/**
58+
* Save annotations (diff) to disk.
59+
* Returns the full path to the saved file.
60+
*/
61+
export function saveAnnotations(slug: string, diffContent: string): string {
62+
const planDir = getPlanDir();
63+
const filePath = join(planDir, `${slug}.diff.md`);
64+
writeFileSync(filePath, diffContent, "utf-8");
65+
return filePath;
66+
}
67+
68+
/**
69+
* Save the final snapshot on approve/deny.
70+
* Combines plan and diff into a single file with status suffix.
71+
* Returns the full path to the saved file.
72+
*/
73+
export function saveFinalSnapshot(
74+
slug: string,
75+
status: "approved" | "denied",
76+
plan: string,
77+
diff: string
78+
): string {
79+
const planDir = getPlanDir();
80+
const filePath = join(planDir, `${slug}-${status}.md`);
81+
82+
// Combine plan with diff appended
83+
let content = plan;
84+
if (diff && diff !== "No changes detected.") {
85+
content += "\n\n---\n\n" + diff;
86+
}
87+
88+
writeFileSync(filePath, content, "utf-8");
89+
return filePath;
90+
}

0 commit comments

Comments
 (0)