Skip to content
Merged
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
7 changes: 5 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ claude --plugin-dir ./apps/hook
| `PLANNOTATOR_ORIGIN` | Explicit agent-origin override at the top of the detection chain. Valid values: `claude-code`, `amp`, `droid`, `opencode`, `codex`, `copilot-cli`, `gemini-cli`, `kiro-cli`, `pi`. Invalid values silently fall through to env-based detection. Unset by default. |
| `PLANNOTATOR_JINA` | Set to `0` / `false` to disable Jina Reader for URL annotation, or `1` / `true` to enable. Default: enabled. Can also be set via `~/.plannotator/config.json` (`{ "jina": false }`) or per-invocation via `--no-jina`. |
| `PLANNOTATOR_ANNOTATE_HISTORY` | Set to `0` / `false` to disable per-file version history in annotate mode (no copies of annotated files are written to the data dir; the annotate version diff is unavailable). Default: enabled. Can also be set via `~/.plannotator/config.json` (`{ "annotateHistory": false }`); the env var takes precedence. |
| `PLANNOTATOR_GUIDE_HISTORY` | Set to `0` / `false` to disable persisting successful Guided Reviews (no guide copies are written to the data dir; the "Previous guides" list is then never populated, though already-saved guides remain readable and listed). Default: enabled. Can also be set via `~/.plannotator/config.json` (`{ "guideHistory": false }`); the env var takes precedence. |
| `PLANNOTATOR_CURSOR_SANDBOX` | Set to `0` / `false` / `disabled` to stop passing `--sandbox enabled` when launching Cursor's `agent` CLI for review jobs — the flag pair is omitted entirely, deferring to the user's own Cursor Agent sandbox configuration. For systems where Cursor's sandbox cannot start (NixOS, AppArmor-restricted Linux). Default: enabled (`--sandbox enabled` is passed). Can also be set via `~/.plannotator/config.json` (`{ "cursorSandbox": false }`); the env var takes precedence. Note: opting out means the review job's write protection relies on `--mode ask` plus the user's own Cursor configuration. |
| `JINA_API_KEY` | Optional Jina Reader API key for higher rate limits (500 RPM vs 20 RPM unauthenticated). Free keys include 10M tokens. |
| `PLANNOTATOR_DATA_DIR` | Override the base data directory. Supports `~` expansion. Default: `~/.plannotator`. When unset, an existing `~/.plannotator` always wins; if it doesn't exist and `$XDG_DATA_HOME` is set to an absolute path, `$XDG_DATA_HOME/plannotator` is used; otherwise `~/.plannotator` (the XDG spec's implicit `~/.local/share` default is deliberately not applied). All data (plans, history, drafts, config, hooks, sessions, debug logs, IPC registry) is stored under this directory. |
Expand Down Expand Up @@ -364,8 +365,10 @@ During normal plan review, an Archive sidebar tab provides the same browsing via
| `/api/pr-switch` | POST | Switch to a different PR in-place (body: `{ url }`). Response includes `semanticDiff?`. |
| `/api/tour/:jobId` | GET | Fetch Code Tour result (greeting, stops, checklist) for a completed tour job |
| `/api/tour/:jobId/checklist` | PUT | Persist checklist item state for a Code Tour |
| `/api/guide/:jobId` | GET | Fetch Guided Review result (ordered sections with overviews + file refs) for a completed guide job |
| `/api/guide/:jobId/reviewed` | PUT | Persist per-section reviewed state for a guide |
| `/api/guide/:jobId` | GET | Fetch Guided Review result (ordered sections with overviews + file refs) for a completed guide job, or a persisted guide via the `saved:{id}` pseudo job id |
| `/api/guide/:jobId/reviewed` | PUT | Persist per-section reviewed state for a guide (live job ids write through to the job's autosaved file; `saved:{id}` ids persist directly) |
| `/api/guides` | GET | List persisted guides for the current repo: `[{ id, label, title, savedAt, progress: { reviewed, total }, moved }]` — `moved` flags a stored head sha that differs from the head currently under review |
| `/api/guides/:id` | DELETE | Delete a persisted guide |
| `/api/guide/:jobId/output` | GET | Fetch a failed guide job's captured raw output for manual repair (404 if none captured) |
| `/api/guide/:jobId/submit` | POST | Manually submit corrected guide JSON for a failed job (body: `{ payload }`) |
| `/api/code-nav/resolve` | POST | Search for symbol definitions and references via ripgrep (body: `{ symbol, filePath, line, charStart, side, language? }`) |
Expand Down
10 changes: 9 additions & 1 deletion apps/pi-extension/server/agent-jobs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,10 @@ export interface AgentJobHandlerOptions {
* model planned section placement against, not whatever patch is on
* screen when the job happens to finish. */
changedFilesSnapshot?: string[];
/** Launch-time review-target snapshot (guide provider only) — stored on
* AgentJobInfo so guide persistence labels the envelope with the context
* the guide was generated against (see AgentJobInfo.guideContext). */
guideContext?: AgentJobInfo["guideContext"];
} | null>;
/** Called when a job completes successfully — parse results and push annotations. */
onJobComplete?: (job: AgentJobInfo, meta: { outputPath?: string; stdout?: string; cwd?: string; changedFilesSnapshot?: string[] }) => void | Promise<void>;
Expand Down Expand Up @@ -214,7 +218,7 @@ export function createAgentJobHandler(options: AgentJobHandlerOptions) {
command: string[],
label: string,
outputPath?: string,
spawnOptions?: { captureStdout?: boolean; stdinPrompt?: string; cwd?: string; prompt?: string; engine?: string; model?: string; effort?: string; reasoningEffort?: string; fastMode?: boolean; thinking?: string; prUrl?: string; diffScope?: string; diffContext?: AgentJobInfo["diffContext"]; reviewProfileId?: string; reviewProfileLabel?: string; changedFilesSnapshot?: string[] },
spawnOptions?: { captureStdout?: boolean; stdinPrompt?: string; cwd?: string; prompt?: string; engine?: string; model?: string; effort?: string; reasoningEffort?: string; fastMode?: boolean; thinking?: string; prUrl?: string; diffScope?: string; diffContext?: AgentJobInfo["diffContext"]; reviewProfileId?: string; reviewProfileLabel?: string; changedFilesSnapshot?: string[]; guideContext?: AgentJobInfo["guideContext"] },
): AgentJobInfo {
const source = jobSource(id);

Expand All @@ -236,6 +240,7 @@ export function createAgentJobHandler(options: AgentJobHandlerOptions) {
...(spawnOptions?.prUrl && { prUrl: spawnOptions.prUrl }),
...(spawnOptions?.diffScope && { diffScope: spawnOptions.diffScope }),
...(spawnOptions?.diffContext && { diffContext: spawnOptions.diffContext }),
...(spawnOptions?.guideContext && { guideContext: spawnOptions.guideContext }),
...(spawnOptions?.reviewProfileId && { reviewProfileId: spawnOptions.reviewProfileId }),
...(spawnOptions?.reviewProfileLabel && { reviewProfileLabel: spawnOptions.reviewProfileLabel }),
};
Expand Down Expand Up @@ -621,6 +626,7 @@ export function createAgentJobHandler(options: AgentJobHandlerOptions) {
let jobReviewProfileId: string | undefined;
let jobReviewProfileLabel: string | undefined;
let jobChangedFilesSnapshot: string[] | undefined;
let jobGuideContext: AgentJobInfo["guideContext"] | undefined;
const jobId = crypto.randomUUID();
if (options.buildCommand) {
// Thread config from POST body to buildCommand
Expand Down Expand Up @@ -654,6 +660,7 @@ export function createAgentJobHandler(options: AgentJobHandlerOptions) {
jobReviewProfileId = built.reviewProfileId;
jobReviewProfileLabel = built.reviewProfileLabel;
jobChangedFilesSnapshot = built.changedFilesSnapshot;
jobGuideContext = built.guideContext;
}
}

Expand All @@ -679,6 +686,7 @@ export function createAgentJobHandler(options: AgentJobHandlerOptions) {
reviewProfileId: jobReviewProfileId,
reviewProfileLabel: jobReviewProfileLabel,
changedFilesSnapshot: jobChangedFilesSnapshot,
guideContext: jobGuideContext,
});
json(res, { job }, 201);
} catch (err) {
Expand Down
105 changes: 98 additions & 7 deletions apps/pi-extension/server/serverReview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { basename, resolve as resolvePath } from "node:path";

import { SingleFlight } from "../generated/single-flight.ts";
import { contentHash, deleteDraft } from "../generated/draft.ts";
import { loadConfig, saveConfig, detectGitUser, getServerConfig, resolveSharingEnabled, resolveCursorSandbox } from "../generated/config.ts";
import { loadConfig, saveConfig, detectGitUser, getServerConfig, resolveSharingEnabled, resolveCursorSandbox, resolveGuideHistory } from "../generated/config.ts";

export type {
DiffOption,
Expand Down Expand Up @@ -122,6 +122,7 @@ import {
} from "../generated/claude-review.ts";
import { createTourSession, TOUR_EMPTY_OUTPUT_ERROR } from "../generated/tour-review.ts";
import { createGuideSession, GUIDE_EMPTY_OUTPUT_ERROR } from "../generated/guide-review.ts";
import { createGuideStoreSession, SAVED_GUIDE_ID_PREFIX } from "../generated/guide-store.ts";
import {
MARKER_ENGINES,
composeMarkerReviewPrompt,
Expand Down Expand Up @@ -767,6 +768,34 @@ export async function startReviewServer(options: {
}
const tour = createTourSession();
const guide = createGuideSession();
// Durable guide persistence (#1112): autosaves validated guides to
// ${PLANNOTATOR_DATA_DIR}/guides/{repo-key}/ and serves them back through
// the existing guide endpoints as `saved:{id}` pseudo job ids. All getters
// are late-bound — prMeta/currentDiffType can change mid-session.
// Mirrors packages/server/review.ts's guideStore wiring.
const guideStore = createGuideStoreSession({
runGit: async (args, cwd) => {
const result = await reviewRuntime.runGit(args, { cwd });
return result.exitCode === 0 ? result.stdout : null;
},
getGitCwd: () =>
isPRMode || isWorkspaceMode
? undefined
: options.gitContext
? resolveVcsCwd(currentDiffType as DiffType, options.gitContext.cwd) ?? options.gitContext.cwd ?? process.cwd()
: undefined,
getPRInfo: () =>
prMeta
? {
url: prMeta.url,
headSha: prMeta.headSha,
label: `${getMRLabel(prMeta)} ${getMRNumberLabel(prMeta)}`,
}
: null,
getBranchLabel: () => clientGitContext?.currentBranch || options.gitContext?.currentBranch,
getFallbackDir: () => workspace?.root ?? options.agentCwd ?? process.cwd(),
writesEnabled: () => resolveGuideHistory(loadConfig()),
});
const semanticDiffScratchCwd = getSemanticDiffScratchCwd();
function resolveSemanticDiffCwd(diffType: DiffType = currentDiffType as DiffType): string {
if (workspace) return workspace.root;
Expand Down Expand Up @@ -1048,6 +1077,15 @@ export async function startReviewServer(options: {
const changedFilesSnapshot = repairOf
? guide.getLaunchChangedFiles(repairOf) ?? changedFiles.map((f) => f.path)
: changedFiles.map((f) => f.path);
// Snapshot the launch-time review-target context (#1112): guide jobs
// run for minutes while the session supports mid-generation PR/diff
// switching, so the persisted envelope must be labeled with the
// context this guide is GENERATED against — captured now, carried on
// the job (guideContext), and read back at completion instead of the
// live session state. Repairs reuse the FAILED job's own snapshot,
// same as changedFilesSnapshot above. Mirrors packages/server/review.ts.
const guideContext = (repairOf ? agentJobs.getJob(repairOf)?.guideContext : undefined)
?? await guideStore.captureLaunchContext();
return {
...built,
prUrl: launchPrUrl,
Expand All @@ -1056,6 +1094,7 @@ export async function startReviewServer(options: {
reviewProfileId: reviewProfile.id,
reviewProfileLabel: reviewProfile.label,
changedFilesSnapshot,
guideContext,
};
}

Expand Down Expand Up @@ -1292,6 +1331,13 @@ export async function startReviewServer(options: {
const { summary, error } = await guide.onJobComplete({ job, meta, changedFiles });
if (summary) {
job.summary = summary;
// Autosave (#1112): only guides that passed validateGuideOutput
// ever reach guideResults, so a getGuide hit here IS the
// validation gate. Failed/invalid guides never write. The job's
// launch-time context snapshot labels the envelope — never the
// live session state, which may have PR/diff-switched mid-run.
const validated = guide.getGuide(job.id);
if (validated) await guideStore.saveForJob(job, validated, job.guideContext);
} else {
// Same fail-closed precedent as Tour: an exit-0 job with empty,
// malformed, or fully-invalidated output must not look like a
Expand Down Expand Up @@ -1357,32 +1403,72 @@ export async function startReviewServer(options: {
return;
}

// API: Get guide result
// API: Get guide result — live job ids, or `saved:{id}` for a
// persisted guide loaded from the on-disk store (#1112).
if (url.pathname.match(/^\/api\/guide\/[^/]+$/) && req.method === "GET") {
const jobId = url.pathname.slice("/api/guide/".length);
const jobId = decodeURIComponent(url.pathname.slice("/api/guide/".length));
if (jobId.startsWith(SAVED_GUIDE_ID_PREFIX)) {
const saved = await guideStore.getSavedGuideData(jobId.slice(SAVED_GUIDE_ID_PREFIX.length));
if (!saved) {
json(res, { error: "Guide not found" }, 404);
return;
}
json(res, saved);
return;
}
const result = guide.getGuide(jobId);
if (!result) {
json(res, { error: "Guide not found" }, 404);
return;
}
json(res, result);
json(res, { ...result, ...(guideStore.isJobSaved(jobId) ? { saved: true } : {}) });
return;
}

// API: Save guide reviewed state
// API: Save guide reviewed state. Live job ids also write through to
// the job's autosaved file; `saved:{id}` ids persist directly.
const reviewedMatch = url.pathname.match(/^\/api\/guide\/([^/]+)\/reviewed$/);
if (reviewedMatch && req.method === "PUT") {
const jobId = reviewedMatch[1];
const jobId = decodeURIComponent(reviewedMatch[1]);
try {
const body = await parseBody(req) as { reviewed: boolean[] };
if (Array.isArray(body.reviewed)) guide.saveReviewed(jobId, body.reviewed);
if (Array.isArray(body.reviewed)) {
if (jobId.startsWith(SAVED_GUIDE_ID_PREFIX)) {
const ok = await guideStore.updateSavedReviewed(jobId.slice(SAVED_GUIDE_ID_PREFIX.length), body.reviewed);
if (!ok) {
json(res, { error: "Guide not found" }, 404);
return;
}
} else {
guide.saveReviewed(jobId, body.reviewed);
await guideStore.writeThroughReviewed(jobId, body.reviewed);
}
}
json(res, { ok: true });
} catch {
json(res, { error: "Invalid JSON" }, 400);
}
return;
}

// API: List saved guides for the current repo (#1112)
if (url.pathname === "/api/guides" && req.method === "GET") {
json(res, await guideStore.listSaved());
return;
}

// API: Delete a saved guide (#1112)
const savedGuideDeleteMatch = url.pathname.match(/^\/api\/guides\/([^/]+)$/);
if (savedGuideDeleteMatch && req.method === "DELETE") {
const ok = await guideStore.deleteSaved(decodeURIComponent(savedGuideDeleteMatch[1]));
if (!ok) {
json(res, { error: "Guide not found" }, 404);
return;
}
json(res, { ok: true });
return;
}

// API: Get a failed guide job's captured raw output for manual repair
const guideOutputMatch = url.pathname.match(/^\/api\/guide\/([^/]+)\/output$/);
if (guideOutputMatch && req.method === "GET") {
Expand Down Expand Up @@ -1427,6 +1513,11 @@ export async function startReviewServer(options: {
explanation: `${sections} section${sections !== 1 ? "s" : ""}, ${files} file${files !== 1 ? "s" : ""} placed (manually repaired)`,
confidence: 1,
});
// A manually repaired guide passed the same validateGuideOutput
// gate as an automatic one — persist it too (#1112), labeled
// with the job's own launch-time context snapshot.
const repaired = guide.getGuide(jobId);
if (repaired) await guideStore.saveForJob(existingJob, repaired, existingJob.guideContext);
json(res, { ok: true, sections, files });
} catch {
json(res, { error: "Invalid JSON" }, 400);
Expand Down
2 changes: 1 addition & 1 deletion apps/pi-extension/vendor.sh
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ for f in config-types storage-types workspace-status-types; do
done

# Everything else in the original flat list stays sourced from packages/shared.
for f in prompts review-core diff-paths cli-pagination jj-core gitbutler-core vcs-core review-args draft pr-types pr-context-live pr-artifact-document pr-provider pr-stack pr-github pr-gitlab checklist integrations-common repo reference-common resolve-file annotate-reference-roots-node worktree worktree-pool html-to-markdown html-diff html-assets html-assets-node url-to-markdown tour annotate-args at-reference review-workspace-node review-workspace pfm-reminder improvement-hooks code-nav data-dir semantic-diff-types semantic-diff single-flight source-save-node review-profiles guide commit-avatars commit-history port-range; do
for f in prompts review-core diff-paths cli-pagination jj-core gitbutler-core vcs-core review-args draft pr-types pr-context-live pr-artifact-document pr-provider pr-stack pr-github pr-gitlab checklist integrations-common repo reference-common resolve-file annotate-reference-roots-node worktree worktree-pool html-to-markdown html-diff html-assets html-assets-node url-to-markdown tour annotate-args at-reference review-workspace-node review-workspace pfm-reminder improvement-hooks code-nav data-dir semantic-diff-types semantic-diff single-flight source-save-node review-profiles guide guide-store commit-avatars commit-history port-range; do
src="../../packages/shared/$f.ts"
printf '// @generated — DO NOT EDIT. Source: packages/shared/%s.ts\n' "$f" | cat - "$src" > "generated/$f.ts"
done
Expand Down
13 changes: 13 additions & 0 deletions packages/core/agent-jobs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,19 @@ export interface AgentJobInfo {
reviewProfileId?: string;
/** Resolved review profile label — rides on findings so the UI can show a profile tag. */
reviewProfileLabel?: string;
/**
* Launch-time review-target snapshot for guide jobs (#1112). Guide jobs run
* for minutes while the session supports mid-generation PR/diff switching,
* so the persisted envelope must be labeled with the context the guide was
* GENERATED against — captured here at build time (same discipline as
* changedFilesSnapshot), never re-read from live session state at
* completion. Structurally matches guide-store's GuideLaunchContext.
*/
guideContext?: {
pr?: { url: string; headSha: string; label: string } | null;
branchLabel?: string;
headSha?: string;
};
}

export interface AgentCapability {
Expand Down
12 changes: 12 additions & 0 deletions packages/review-editor/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -997,6 +997,17 @@ const ReviewApp: React.FC = () => {
[prMetadata, activeWorktreePath],
);

// A `saved:{id}` guide has no AgentJobInfo, so GuideScreen's context match
// passes trivially (unknown ids are tolerated by design, for the demo path).
// Without this, switching PR or worktree while a saved guide is open would
// leave it mounted over the NEW context's diff with a stale `moved` flag.
// Clear it on any review-context switch — simplest and predictable; the
// user can reopen it from the Previous guides list. Live job ids are left
// alone: GuideScreen's own matchesContext already handles them.
useEffect(() => {
setActiveGuideJobId(prev => (prev?.startsWith('saved:') ? null : prev));
}, [prMetadata?.url, activeWorktreePath]);

// Auto-open tour dialog when a tour job completes — scoped to the current
// review context, same rule and same deferred-open semantics as the guide
// effect below (an away-context tour stays unmarked so it opens when the
Expand Down Expand Up @@ -3365,6 +3376,7 @@ const ReviewApp: React.FC = () => {
killJob={agentJobs.killJob}
onClose={() => setGuideOpen(false)}
onOpenFixedGuide={handleOpenGuide}
onOpenSavedGuide={handleOpenGuide}
/>
</div>
)}
Expand Down
Loading