Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 84 additions & 5 deletions apps/hook/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,21 @@ import { resolveMarkdownFile } from "@plannotator/server/resolve-file";
import { registerSession, unregisterSession, listSessions } from "@plannotator/server/sessions";
import { openBrowser } from "@plannotator/server/browser";
import { detectProjectName } from "@plannotator/server/project";
import { planDenyFeedback } from "@plannotator/shared/feedback-templates";
import { planDenyFeedback, planApproveWithNotesFeedback } from "@plannotator/shared/feedback-templates";
import {
extractSessionId,
extractQuestions,
stripPlanMetadata,
generateSessionId,
embedSessionId,
} from "@plannotator/shared/questions";
import {
loadSession,
createSession,
recordIteration,
getPreviousIterations,
cleanupSessions,
} from "@plannotator/server/review-session";
import path from "path";

// Embed the built HTML at compile time
Expand Down Expand Up @@ -303,20 +317,51 @@ if (args[0] === "sessions") {

const planProject = (await detectProjectName()) ?? "_unknown";

// Start the plan review server
// --- Session tracking for context persistence ---
// Extract or create a session ID so we can accumulate context across deny/revise cycles
let sessionId = extractSessionId(planContent);
const isReturningSession = !!sessionId;

if (!sessionId) {
sessionId = generateSessionId();
}

// Extract any embedded clarification questions from the plan
const embeddedQuestions = extractQuestions(planContent);

// Strip metadata from the plan so the user sees clean markdown
const cleanPlan = stripPlanMetadata(planContent);

// Load previous iterations for context
const previousIterations = isReturningSession
? getPreviousIterations(sessionId)
: [];

// Ensure session exists on disk
if (!isReturningSession) {
createSession(sessionId, planProject, "");
}

// Periodically clean up old sessions (non-blocking)
cleanupSessions();

// Start the plan review server (use clean plan without metadata comments)
const server = await startPlannotatorServer({
plan: planContent,
plan: cleanPlan,
origin: "claude-code",
permissionMode,
sharingEnabled,
shareBaseUrl,
pasteApiUrl,
htmlContent: planHtmlContent,
sessionId,
previousIterations,
questions: embeddedQuestions,
onReady: async (url, isRemote, port) => {
handleServerReady(url, isRemote, port);

if (isRemote && sharingEnabled) {
await writeRemoteShareLink(planContent, shareBaseUrl, "review the plan", "plan only").catch(() => {});
await writeRemoteShareLink(cleanPlan, shareBaseUrl, "review the plan", "plan only").catch(() => {});
}
},
});
Expand All @@ -340,6 +385,15 @@ if (args[0] === "sessions") {
// Cleanup
server.stop();

// Record this iteration in the session store for context persistence
recordIteration(sessionId, {
plan: cleanPlan,
feedback: result.feedback || "",
questions: embeddedQuestions,
answers: result.answers || [],
decision: result.approved ? "approved" : "denied",
});

// Output JSON for PermissionRequest hook decision control
if (result.approved) {
// Build updatedPermissions to preserve the current permission mode
Expand All @@ -359,18 +413,43 @@ if (args[0] === "sessions") {
decision: {
behavior: "allow",
...(updatedPermissions.length > 0 && { updatedPermissions }),
// Pass through user annotations as implementation notes when approving with feedback
...(result.feedback && {
message: planApproveWithNotesFeedback(result.feedback, {
clarificationQuestions: embeddedQuestions,
clarificationAnswers: result.answers || [],
}),
}),
// If no annotations but questions were answered, still include Q&A context
...(!result.feedback && embeddedQuestions.length > 0 && result.answers?.length && {
message: planApproveWithNotesFeedback("", {
clarificationQuestions: embeddedQuestions,
clarificationAnswers: result.answers,
}),
}),
},
},
})
);
} else {
// Include session ID in deny feedback so the AI embeds it in the next plan submission
const sessionHint = `\n\nIMPORTANT: Include this session marker at the top of your next plan so review context is preserved:\n<!-- plannotator:session ${sessionId} -->`;

console.log(
JSON.stringify({
hookSpecificOutput: {
hookEventName: "PermissionRequest",
decision: {
behavior: "deny",
message: planDenyFeedback(result.feedback || "", "ExitPlanMode"),
message: planDenyFeedback(
(result.feedback || "") + sessionHint,
"ExitPlanMode",
{
previousIterations,
clarificationQuestions: embeddedQuestions,
clarificationAnswers: result.answers || [],
},
),
},
},
})
Expand Down
91 changes: 75 additions & 16 deletions apps/opencode-plugin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,19 @@ import {
import { getGitContext, runGitDiff } from "@plannotator/server/git";
import { writeRemoteShareLink } from "@plannotator/server/share-url";
import { resolveMarkdownFile } from "@plannotator/server/resolve-file";
import { planDenyFeedback } from "@plannotator/shared/feedback-templates";
import { planDenyFeedback, planApproveWithNotesFeedback } from "@plannotator/shared/feedback-templates";
import {
extractSessionId,
extractQuestions,
stripPlanMetadata,
generateSessionId,
} from "@plannotator/shared/questions";
import {
createSession,
recordIteration,
getPreviousIterations,
cleanupSessions,
} from "@plannotator/server/review-session";

// @ts-ignore - Bun import attribute for text
import indexHtml from "./plannotator.html" with { type: "text" };
Expand Down Expand Up @@ -348,17 +360,44 @@ Do NOT proceed with implementation until your plan is approved.
},

async execute(args, context) {
// --- Session tracking for context persistence ---
let sessionId = extractSessionId(args.plan);
const isReturningSession = !!sessionId;

if (!sessionId) {
sessionId = generateSessionId();
}

// Extract embedded questions and strip metadata
const embeddedQuestions = extractQuestions(args.plan);
const cleanPlan = stripPlanMetadata(args.plan);

// Load previous iterations for context
const previousIterations = isReturningSession
? getPreviousIterations(sessionId)
: [];

if (!isReturningSession) {
createSession(sessionId, process.cwd(), "");
}

// Periodically clean up old sessions (non-blocking)
cleanupSessions();

const server = await startPlannotatorServer({
plan: args.plan,
plan: cleanPlan,
origin: "opencode",
sharingEnabled: await getSharingEnabled(),
shareBaseUrl: getShareBaseUrl(),
htmlContent,
opencodeClient: ctx.client,
sessionId,
previousIterations,
questions: embeddedQuestions,
onReady: async (url, isRemote, port) => {
handleServerReady(url, isRemote, port);
if (isRemote && await getSharingEnabled()) {
await writeRemoteShareLink(args.plan, getShareBaseUrl(), "review the plan", "plan only").catch(() => {});
await writeRemoteShareLink(cleanPlan, getShareBaseUrl(), "review the plan", "plan only").catch(() => {});
}
},
});
Expand Down Expand Up @@ -386,6 +425,15 @@ Do NOT proceed with implementation until your plan is approved.
await Bun.sleep(1500);
server.stop();

// Record this iteration in the session store for context persistence
recordIteration(sessionId, {
plan: cleanPlan,
feedback: result.feedback || "",
questions: embeddedQuestions,
answers: result.answers || [],
decision: result.approved ? "approved" : "denied",
});

if (result.approved) {
// Check agent switch setting (defaults to 'build' if not set)
const shouldSwitchAgent = result.agentSwitch && result.agentSwitch !== 'disabled';
Expand Down Expand Up @@ -419,28 +467,39 @@ Do NOT proceed with implementation until your plan is approved.
}
}

// If user approved with annotations, include them as notes for implementation
if (result.feedback) {
return `Plan approved with notes!
// If user approved with annotations or answered questions, include context
const hasQA = embeddedQuestions.length > 0 && result.answers?.length;
if (result.feedback || hasQA) {
const approveMessage = planApproveWithNotesFeedback(
result.feedback || "",
{
clarificationQuestions: embeddedQuestions,
clarificationAnswers: result.answers || [],
},
);
return `${approveMessage}

Plan Summary: ${args.summary}
${result.savedPath ? `Saved to: ${result.savedPath}` : ""}

## Implementation Notes

The user approved your plan but added the following notes to consider during implementation:

${result.feedback}

Proceed with implementation, incorporating these notes where applicable.`;
${result.savedPath ? `Saved to: ${result.savedPath}` : ""}`;
}

return `Plan approved!

Plan Summary: ${args.summary}
${result.savedPath ? `Saved to: ${result.savedPath}` : ""}`;
} else {
return planDenyFeedback(result.feedback || "", "submit_plan");
// Include session ID in deny feedback so the AI embeds it in the next plan submission
const sessionHint = `\n\nIMPORTANT: Include this session marker at the top of your next plan so review context is preserved:\n<!-- plannotator:session ${sessionId} -->`;

return planDenyFeedback(
(result.feedback || "") + sessionHint,
"submit_plan",
{
previousIterations,
clarificationQuestions: embeddedQuestions,
clarificationAnswers: result.answers || [],
},
);
}
},
}),
Expand Down
Loading